diff --git a/CHANGELOG.md b/CHANGELOG.md index adf03020..f082ce48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to RoboCo are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Pluggable agent providers + a Grok (xAI) backend.** A new `roboco/llm/providers/` seam (an `AgentProvider` lifecycle ABC + a `ProviderRegistry` keyed by `ModelProvider`) lets the orchestrator drive agent backends other than Claude Code. The first is `GrokProvider` for xAI's `grok-build-0.1`: xAI is OpenAI-compatible only (no Anthropic-Messages endpoint), so a Grok agent runs an OpenAI-protocol runtime pointed at `https://api.x.ai/v1` rather than the `ANTHROPIC_BASE_URL` injection the other providers use — and it reuses the orchestrator's existing mount/auth assembly, so it gets the same MCP gateway + tool-manifest wiring as every other agent by construction (and passes its prompt via env, never an argv positional). The change is purely additive: only `GROK` routes through the registry; Anthropic / Ollama Cloud / self-hosted spawns are untouched. Includes the `grok` enum value (migration 038), a seeded Grok provider row (migration 039), a `grok-build-0.1` catalog entry, and `GET/PUT /api/providers/grok-key` to store the xAI key (Fernet-encrypted, reusing the existing provider-key machinery). The dedicated `roboco-agent-grok` image plus the exact OpenAI-protocol CLI invocation are the remaining piece to finalize together with xAI. + ## [0.6.0] - 2026-06-17 ### Added diff --git a/alembic/versions/038_modelprovider_grok.py b/alembic/versions/038_modelprovider_grok.py new file mode 100644 index 00000000..6f72d0c2 --- /dev/null +++ b/alembic/versions/038_modelprovider_grok.py @@ -0,0 +1,33 @@ +"""Add 'grok' to the postgres modelprovider enum. + +Grok (``ModelProvider.GROK`` — xAI's OpenAI-compatible grok-build-0.1) is a new +agent backend. Seeding its provider row (migration 039) and routing agents to it +requires the postgres ``modelprovider`` enum to carry the value. Mirrors the +enum-add pattern of migration 037; the row seed is split into 039 because a +newly added enum value cannot be used in the same transaction that adds it. + +Revision ID: 038_modelprovider_grok +Revises: 037_agentrole_pr_reviewer +Create Date: 2026-06-18 +""" + +from __future__ import annotations + +from alembic import op + +revision = "038_modelprovider_grok" +down_revision = "037_agentrole_pr_reviewer" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Unguarded (renders in offline --sql so the enum-migration-parity test + # sees it) and idempotent. PG 16 permits ADD VALUE inside a transaction. + op.execute("ALTER TYPE modelprovider ADD VALUE IF NOT EXISTS 'grok'") + + +def downgrade() -> None: + # Postgres does not support removing enum values without a destructive + # type recreation. Forward-only by design (see migration 037). + pass diff --git a/alembic/versions/039_seed_grok_provider.py b/alembic/versions/039_seed_grok_provider.py new file mode 100644 index 00000000..e8475c4b --- /dev/null +++ b/alembic/versions/039_seed_grok_provider.py @@ -0,0 +1,60 @@ +"""Idempotently seed the Grok (xAI) provider row. + +The ``modelprovider`` enum carries ``'grok'`` as of migration 038. This +migration seeds the corresponding ``provider_configs`` row so the Settings UI +can store the xAI key without an extra provisioning call. + +The row starts disabled with the public xAI base URL and no key — the operator +sets the key via PUT /api/providers/grok/key, which encrypts it and enables the +provider. ON CONFLICT (name) DO NOTHING keeps this safe to re-run. + +Revision ID: 039_seed_grok_provider +Revises: 038_modelprovider_grok +Create Date: 2026-06-18 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "039_seed_grok_provider" +down_revision = "038_modelprovider_grok" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + sa.text( + """ + INSERT INTO provider_configs + (id, name, type, base_url, auth_token_encrypted, enabled, created_at) + VALUES + ( + gen_random_uuid(), + 'Grok (xAI)', + 'grok', + 'https://api.x.ai/v1', + NULL, + false, + now() + ) + ON CONFLICT (name) DO NOTHING + """ + ) + ) + + +def downgrade() -> None: + # Drop model_assignments pointing at the Grok row first to avoid a FK + # RESTRICT violation on provider_configs.id. + op.execute( + sa.text( + "DELETE FROM model_assignments " + "WHERE provider_config_id IN (" + " SELECT id FROM provider_configs WHERE name = 'Grok (xAI)'" + ")" + ) + ) + op.execute(sa.text("DELETE FROM provider_configs WHERE name = 'Grok (xAI)'")) diff --git a/roboco/api/routes/provider.py b/roboco/api/routes/provider.py index a85c00db..8a8e8d6f 100644 --- a/roboco/api/routes/provider.py +++ b/roboco/api/routes/provider.py @@ -15,12 +15,14 @@ from roboco.api.deps import CurrentAgentContext, DbSession, require_pm_or_above from roboco.api.schemas.provider import ( ApplyModeRequest, CatalogEntryResponse, + GrokKeyStatus, ModeResponse, OllamaKeyStatus, SelfHostedConfigRequest, SelfHostedConfigResponse, SelfHostedModelEntry, SelfHostedTestResponse, + SetGrokKeyRequest, SetOllamaKeyRequest, assignment_to_response, ) @@ -113,6 +115,56 @@ async def set_ollama_key( ) +# ============================================================================= +# GROK (xAI) API KEY +# ============================================================================= + + +@router.get("/grok-key", response_model=GrokKeyStatus) +async def get_grok_key_status( + db: DbSession, + agent: CurrentAgentContext, +) -> GrokKeyStatus: + """Return whether the Grok (xAI) key is set + enabled.""" + require_pm_or_above(agent.role, "view the Grok key status") + provider_svc = get_provider_service(db) + providers = await provider_svc.list_providers(include_disabled=True) + grok = next((p for p in providers if p.type == ModelProvider.GROK), None) + if grok is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Grok provider not seeded. Run alembic upgrade head.", + ) + return GrokKeyStatus( + has_key=bool(grok.auth_token_encrypted), + enabled=grok.enabled, + ) + + +@router.put("/grok-key", response_model=GrokKeyStatus) +async def set_grok_key( + data: SetGrokKeyRequest, + db: DbSession, + agent: CurrentAgentContext, +) -> GrokKeyStatus: + """Set or clear the Grok (xAI) API key. + + Empty string → clears and disables the provider. Any other value → + Fernet-encrypts + marks enabled. Used against https://api.x.ai/v1. + """ + require_pm_or_above(agent.role, "set the Grok key") + routing = get_model_routing_service(db) + try: + provider = await routing.set_grok_api_key(data.api_key) + except NotFoundError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) from e + await db.commit() + return GrokKeyStatus( + has_key=bool(provider.auth_token_encrypted), + enabled=provider.enabled, + ) + + # ============================================================================= # SELF-HOSTED (LOCAL) OLLAMA SERVER # ============================================================================= diff --git a/roboco/api/schemas/provider.py b/roboco/api/schemas/provider.py index 91e86019..f514566d 100644 --- a/roboco/api/schemas/provider.py +++ b/roboco/api/schemas/provider.py @@ -58,6 +58,29 @@ class SetOllamaKeyRequest(BaseModel): api_key: str = Field(default="") +# ============================================================================= +# GROK (xAI) API KEY +# ============================================================================= + + +class GrokKeyStatus(BaseModel): + """Whether the Grok (xAI) provider has a stored key.""" + + has_key: bool + enabled: bool + + +class SetGrokKeyRequest(BaseModel): + """Set or clear the Grok (xAI) API key. + + Pass an empty string to clear. Pass a non-empty string to save + (encrypted with Fernet) and mark the Grok provider enabled. This is the + standard xAI key used against https://api.x.ai/v1. + """ + + api_key: str = Field(default="") + + # ============================================================================= # SELF-HOSTED (LOCAL) OLLAMA SERVER # ============================================================================= diff --git a/roboco/llm/providers/__init__.py b/roboco/llm/providers/__init__.py new file mode 100644 index 00000000..8ad798d2 --- /dev/null +++ b/roboco/llm/providers/__init__.py @@ -0,0 +1,27 @@ +"""LLM agent providers. + +Each provider implements the :class:`AgentProvider` lifecycle for one backend. +The :class:`ProviderRegistry` maps :class:`~roboco.models.base.ModelProvider` +values to provider instances; the orchestrator looks a provider up at spawn time +and falls back to its built-in Claude Code spawn when none is registered. + +Backends: +- :class:`ClaudeCodeProvider` — Anthropic-protocol Claude Code container (default; + also serves Ollama Cloud / self-hosted via ``ANTHROPIC_BASE_URL`` injection). +- :class:`GrokProvider` — xAI ``grok-build-0.1`` over the OpenAI protocol. +""" + +from roboco.llm.providers.base import AgentProvider, ProviderError, SpawnResult +from roboco.llm.providers.claude_code import ClaudeCodeProvider +from roboco.llm.providers.grok import GrokProvider +from roboco.llm.providers.registry import ProviderNotRegisteredError, ProviderRegistry + +__all__ = [ + "AgentProvider", + "ClaudeCodeProvider", + "GrokProvider", + "ProviderError", + "ProviderNotRegisteredError", + "ProviderRegistry", + "SpawnResult", +] diff --git a/roboco/llm/providers/_docker.py b/roboco/llm/providers/_docker.py new file mode 100644 index 00000000..190b0987 --- /dev/null +++ b/roboco/llm/providers/_docker.py @@ -0,0 +1,42 @@ +"""Shared Docker container-lifecycle helpers for providers. + +Small, dependency-free wrappers around the ``docker`` CLI so each provider's +``stop`` / ``health_check`` reads the same way. Spawn and remove stay in the +providers (spawn is backend-specific; remove delegates to the orchestrator so +its log-dump-before-remove behaviour is preserved). +""" + +from __future__ import annotations + +import asyncio + +from roboco.llm.providers.base import ProviderError + + +async def stop_container(name: str, graceful: bool = True) -> None: + """Stop a container by name/id (``docker stop`` = SIGTERM, ``kill`` = SIGKILL).""" + verb = "stop" if graceful else "kill" + proc = await asyncio.create_subprocess_exec( + "docker", + verb, + name, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, + ) + _, stderr = await proc.communicate() + if proc.returncode != 0: + raise ProviderError(f"docker {verb} {name} failed: {stderr.decode().strip()}") + + +async def container_running(name: str) -> bool: + """Return True if the named container exists and is running.""" + proc = await asyncio.create_subprocess_exec( + "docker", + "inspect", + "--format={{.State.Running}}", + name, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + ) + stdout, _ = await proc.communicate() + return proc.returncode == 0 and stdout.decode().strip() == "true" diff --git a/roboco/llm/providers/base.py b/roboco/llm/providers/base.py new file mode 100644 index 00000000..759129cb --- /dev/null +++ b/roboco/llm/providers/base.py @@ -0,0 +1,92 @@ +"""Abstract base class for agent lifecycle providers. + +An ``AgentProvider`` encapsulates *how* an agent is spawned, stopped, +health-checked, and removed for one LLM backend. The orchestrator stays +provider-agnostic: it resolves a provider from the :class:`ProviderRegistry` +by the agent's :class:`~roboco.models.base.ModelProvider` and calls these +methods without knowing whether the agent runs as a Claude Code container, an +OpenAI-protocol subprocess, or a remote host. + +This is the seam new backends plug into. ``GROK`` (xAI / grok-build-0.1) is the +first OpenAI-protocol provider; see :mod:`roboco.llm.providers.grok`. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + + from roboco.models.runtime import OrchestratorAgentConfig as AgentConfig + + +@dataclass(frozen=True) +class SpawnResult: + """Result of a successful agent spawn. + + Attributes: + instance_id: Provider-specific handle (container id, PID, ...). + agent_state: Initial state after a successful spawn. + extra: Provider-specific metadata (container name, model, url, ...). + """ + + instance_id: str + agent_state: str = "active" + extra: dict[str, object] = field(default_factory=dict) + + +class ProviderError(Exception): + """Raised when an agent-lifecycle operation fails inside a provider. + + Attributes: + message: Human-readable description. + agent_id: The agent being operated on, if known. + cause: The original exception, if any. + """ + + def __init__( + self, + message: str, + agent_id: str | None = None, + cause: BaseException | None = None, + ) -> None: + super().__init__(message) + self.message = message + self.agent_id = agent_id + self.cause = cause + + +class AgentProvider(ABC): + """Abstract base for an agent-lifecycle backend. + + Every concrete provider implements the full lifecycle so the orchestrator + can drive any backend through one interface. + """ + + @abstractmethod + async def spawn( + self, + config: AgentConfig, + initial_prompt: str | None = None, + agent_settings_path: Path | None = None, + ) -> SpawnResult: + """Spawn an agent instance and return a handle to it.""" + ... + + @abstractmethod + async def stop(self, instance_id: str, graceful: bool = True) -> None: + """Stop a running instance (graceful shutdown unless ``graceful=False``).""" + ... + + @abstractmethod + async def health_check(self, instance_id: str) -> bool: + """Return True if the instance is still alive.""" + ... + + @abstractmethod + async def remove(self, instance_id: str) -> None: + """Remove the instance, releasing all resources.""" + ... diff --git a/roboco/llm/providers/claude_code.py b/roboco/llm/providers/claude_code.py new file mode 100644 index 00000000..271f6645 --- /dev/null +++ b/roboco/llm/providers/claude_code.py @@ -0,0 +1,92 @@ +"""Claude Code provider — the default Anthropic-protocol Docker backend. + +This is a thin adapter over the orchestrator's existing Docker spawn +(``_spawn_container`` / ``_remove_container``). It exists so the registry has a +first-class provider for the Claude Code runtime and so new backends +(:mod:`roboco.llm.providers.grok`) have a reference to mirror. + +It deliberately *delegates* to the orchestrator rather than copying ~hundreds of +lines of mount/auth/CLI assembly. Moving that body into this class is the job of +the separate orchestrator-decomposition refactor; keeping it delegated here +means this seam adds the abstraction without destabilising the live spawn path. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol + +from roboco.llm.providers._docker import container_running, stop_container +from roboco.llm.providers.base import AgentProvider, ProviderError, SpawnResult + +if TYPE_CHECKING: + from pathlib import Path + + from roboco.models.runtime import OrchestratorAgentConfig as AgentConfig + + +class _ClaudeCodeHost(Protocol): + """The slice of the orchestrator that ``ClaudeCodeProvider`` delegates to. + + Typing against a Protocol (rather than importing ``AgentOrchestrator``) + keeps this module import-cycle-free and trivially mockable in tests. + """ + + async def _spawn_container( + self, + config: AgentConfig, + initial_prompt: str | None = ..., + agent_settings_path: Path | None = ..., + ) -> str: ... + + async def _remove_container(self, container_name: str) -> None: ... + + +def _container_name(agent_id: str) -> str: + """The container name the orchestrator uses for an agent.""" + return f"roboco-agent-{agent_id}" + + +class ClaudeCodeProvider(AgentProvider): + """Spawn agents as Claude Code Docker containers (Anthropic protocol). + + Non-Anthropic models that *speak the Anthropic Messages API* (Ollama Cloud, + self-hosted) also run through this provider — they are routed purely by + ``ANTHROPIC_BASE_URL`` / ``ANTHROPIC_AUTH_TOKEN`` injection at spawn, which + the orchestrator already handles. Backends that speak a *different* wire + protocol (e.g. OpenAI-compatible xAI) need their own provider. + """ + + def __init__(self, host: _ClaudeCodeHost) -> None: + self._host = host + + async def spawn( + self, + config: AgentConfig, + initial_prompt: str | None = None, + agent_settings_path: Path | None = None, + ) -> SpawnResult: + try: + container_id = await self._host._spawn_container( + config, initial_prompt, agent_settings_path + ) + except Exception as exc: + # Re-wrap any spawn failure as a typed ProviderError for callers. + raise ProviderError( + f"Claude Code spawn failed: {exc}", + agent_id=config.agent_id, + cause=exc, + ) from exc + return SpawnResult( + instance_id=_container_name(config.agent_id), + extra={"container_id": container_id, "model": config.model}, + ) + + async def stop(self, instance_id: str, graceful: bool = True) -> None: + await stop_container(instance_id, graceful) + + async def health_check(self, instance_id: str) -> bool: + return await container_running(instance_id) + + async def remove(self, instance_id: str) -> None: + # Delegate so the orchestrator's log-dump-before-remove behaviour is kept. + await self._host._remove_container(instance_id) diff --git a/roboco/llm/providers/grok.py b/roboco/llm/providers/grok.py new file mode 100644 index 00000000..7bdff812 --- /dev/null +++ b/roboco/llm/providers/grok.py @@ -0,0 +1,199 @@ +"""Grok provider — xAI ``grok-build-0.1`` as a native OpenAI-protocol agent. + +xAI's API is OpenAI-compatible *only* (``https://api.x.ai/v1``) — there is no +native Anthropic-Messages endpoint — so a Grok agent cannot run through the +Claude Code path (which speaks the Anthropic Messages API via +``ANTHROPIC_BASE_URL`` injection). Instead it runs an OpenAI-protocol agent CLI +(the OpenCode pattern), pointed at ``api.x.ai/v1`` with the operator's xAI key. + +Design — this provider deliberately **reuses the orchestrator's proven container +assembly** (``_build_mount_args`` / ``_append_agent_auth_env`` / +``_append_git_context_env``). That means a Grok agent gets the *same* RoboCo MCP +gateway wiring as every other agent **by construction**: + + * ``/app/mcp-config.json`` (the ``roboco-flow`` / ``roboco-do`` gateway) and + ``/app/system-prompt.md`` are mounted by ``_build_mount_args``; + * the spawn manifest + ``ROBOCO_GATEWAY_ENABLED`` are set there too; + * the agent HMAC identity is injected by ``_append_agent_auth_env``. + +An OpenAI-protocol provider that built its own bespoke spawn would have to +re-wire the MCP config itself, and skipping it leaves agents with zero gateway +verbs. Reusing the shared assembly makes the gateway wiring non-optional here. + +Only two things differ from the Claude Code spawn: + 1. **LLM env** — ``OPENAI_BASE_URL`` / ``OPENAI_API_KEY`` (xAI) instead of the + ``ANTHROPIC_*`` injection. The provider routing fields are blanked before + the shared mount step so the xAI endpoint is never mislabelled as Anthropic. + 2. **Runtime** — the ``roboco-agent-grok`` image, whose entrypoint launches the + OpenAI-protocol CLI from the env below (model, MCP config, system prompt, + prompt). The image + the exact CLI invocation are the one remaining piece to + finalise together with xAI ("review + help implement + test"). + +The initial prompt is passed via an **env var, not a positional CLI arg**, which +structurally avoids a flag-injection vector: a prompt starting with ``--`` passed +as a positional argument could otherwise be parsed as CLI options. +""" + +from __future__ import annotations + +import asyncio +import dataclasses +import os +from typing import TYPE_CHECKING, Protocol + +from roboco.llm.providers._docker import container_running, stop_container +from roboco.llm.providers.base import AgentProvider, ProviderError, SpawnResult + +if TYPE_CHECKING: + from pathlib import Path + + from roboco.models.runtime import OrchestratorAgentConfig as AgentConfig + +# The Grok agent image (own image, like every other agent role). Built as the +# infra follow-up — bundles the OpenAI-protocol agent CLI + an entrypoint that +# reads the env contract below. Overridable for tests / staged rollout. +_DEFAULT_GROK_IMAGE = os.environ.get( + "ROBOCO_GROK_AGENT_IMAGE", "roboco-agent-grok:latest" +) + +# Default xAI endpoint when the seeded provider row carries no base_url. +_DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1" + +# In-container paths mounted by the orchestrator's `_build_mount_args`. +_MCP_CONFIG_IN_CONTAINER = "/app/mcp-config.json" +_SYSTEM_PROMPT_IN_CONTAINER = "/app/system-prompt.md" + +# Mirrors the Claude Code tool set. The grok image entrypoint applies this to +# the OpenAI-protocol CLI via the recognised `--tools` flag (not --allowed-tools). +_DEFAULT_TOOLS = "Read,Write,Edit,Bash,Grep,Glob,TodoWrite" + + +def _container_name(agent_id: str) -> str: + return f"roboco-agent-{agent_id}" + + +class _GrokHost(Protocol): + """The orchestrator surface GrokProvider reuses for container assembly. + + Typed as a Protocol so this module never imports ``AgentOrchestrator`` + (no import cycle) and is trivially mockable in tests. + """ + + async def _remove_container(self, container_name: str) -> None: ... + + def _resolve_host_paths( + self, config: AgentConfig, agent_settings_path: Path | None + ) -> dict[str, str | None]: ... + + def _build_mount_args( + self, + container_name: str, + config: AgentConfig, + hosts: dict[str, str | None], + ) -> list[str]: ... + + def _append_agent_auth_env(self, cmd: list[str], config: AgentConfig) -> None: ... + + def _append_git_context_env(self, cmd: list[str], config: AgentConfig) -> None: ... + + +class GrokProvider(AgentProvider): + """Spawn a Grok (xAI, OpenAI-protocol) agent as a gateway-wired container.""" + + def __init__(self, host: _GrokHost, image: str | None = None) -> None: + self._host = host + self._image = image or _DEFAULT_GROK_IMAGE + + async def spawn( + self, + config: AgentConfig, + initial_prompt: str | None = None, + agent_settings_path: Path | None = None, + ) -> SpawnResult: + if not config.provider_auth_token: + raise ProviderError( + "GROK spawn requires an xAI API key — set the Grok provider key " + "in Settings (PUT /api/providers/grok/key).", + agent_id=config.agent_id, + ) + if not config.mcp_config_path: + raise ProviderError( + "GROK spawn requires an MCP config (gateway access).", + agent_id=config.agent_id, + ) + + container_name = _container_name(config.agent_id) + await self._host._remove_container(container_name) + + # Reuse the orchestrator's mount/auth/git assembly so the agent gets the + # full MCP gateway + identity wiring. Blank the provider routing fields + # first: otherwise the shared builder would inject the xAI endpoint as + # ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN (wrong protocol). + mount_config = dataclasses.replace( + config, provider_base_url=None, provider_auth_token=None + ) + hosts = self._host._resolve_host_paths(config, agent_settings_path) + cmd = self._host._build_mount_args(container_name, mount_config, hosts) + self._host._append_agent_auth_env(cmd, config) + self._host._append_git_context_env(cmd, config) + self._append_grok_env(cmd, config, initial_prompt) + cmd.append(self._image) + + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + if proc.returncode != 0: + raise ProviderError( + f"Failed to start Grok container: {stderr.decode().strip()}", + agent_id=config.agent_id, + ) + return SpawnResult( + instance_id=container_name, + extra={"container_id": stdout.decode().strip(), "model": config.model}, + ) + + def _append_grok_env( + self, cmd: list[str], config: AgentConfig, initial_prompt: str | None + ) -> None: + """Append the OpenAI-protocol (xAI) env contract the grok image consumes. + + The prompt travels as an env var, never an argv positional, so a prompt + beginning with ``--`` cannot be parsed as a CLI flag. + """ + base_url = config.provider_base_url or _DEFAULT_XAI_BASE_URL + cmd.extend( + [ + # OpenAI-compatible client config (standard env the CLI reads). + "-e", + f"OPENAI_BASE_URL={base_url}", + "-e", + f"OPENAI_API_KEY={config.provider_auth_token}", + # Operational inputs for the grok image entrypoint. + "-e", + f"ROBOCO_AGENT_MODEL={config.model}", + "-e", + f"ROBOCO_MCP_CONFIG={_MCP_CONFIG_IN_CONTAINER}", + "-e", + f"ROBOCO_SYSTEM_PROMPT={_SYSTEM_PROMPT_IN_CONTAINER}", + "-e", + f"ROBOCO_AGENT_TOOLS={_DEFAULT_TOOLS}", + "-e", + f"ROBOCO_INITIAL_PROMPT={initial_prompt or ''}", + ] + ) + if config.claude_session_id: + # Reused as the generic agent session id so the transcript stays + # locatable at finalize, exactly as on the Claude Code path. + cmd.extend(["-e", f"ROBOCO_AGENT_SESSION_ID={config.claude_session_id}"]) + + async def stop(self, instance_id: str, graceful: bool = True) -> None: + await stop_container(instance_id, graceful) + + async def health_check(self, instance_id: str) -> bool: + return await container_running(instance_id) + + async def remove(self, instance_id: str) -> None: + await self._host._remove_container(instance_id) diff --git a/roboco/llm/providers/registry.py b/roboco/llm/providers/registry.py new file mode 100644 index 00000000..d240f573 --- /dev/null +++ b/roboco/llm/providers/registry.py @@ -0,0 +1,73 @@ +"""Provider registry — maps :class:`ModelProvider` values to provider instances. + +Usage:: + + registry = ProviderRegistry() + registry.register(ModelProvider.GROK, GrokProvider(...)) + provider = registry.get(ModelProvider.GROK) + result = await provider.spawn(config, initial_prompt) + +The orchestrator builds the registry once at startup and calls ``get()`` for any +provider that has a dedicated backend. Providers that are *not* registered fall +back to the orchestrator's built-in Claude Code container spawn — so adding a new +backend is additive and never destabilises the existing Anthropic / Ollama / +self-hosted paths. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from roboco.models.base import ModelProvider + +if TYPE_CHECKING: + from roboco.llm.providers.base import AgentProvider + + +class ProviderNotRegisteredError(LookupError): + """Raised when no provider is registered for a ``ModelProvider`` value.""" + + def __init__(self, provider_type: ModelProvider) -> None: + self.provider_type = provider_type + super().__init__( + f"No provider registered for {provider_type.value!r}. " + f"Registered: {[p.value for p in ModelProvider]}" + ) + + +class ProviderRegistry: + """Maps ``ModelProvider`` values to ``AgentProvider`` instances.""" + + def __init__(self) -> None: + self._providers: dict[ModelProvider, AgentProvider] = {} + + def register(self, provider_type: ModelProvider, provider: AgentProvider) -> None: + """Register *provider* for *provider_type* (replaces any existing).""" + self._providers[provider_type] = provider + + def get(self, provider_type: ModelProvider) -> AgentProvider: + """Return the provider for *provider_type* or raise.""" + provider = self._providers.get(provider_type) + if provider is None: + raise ProviderNotRegisteredError(provider_type) + return provider + + def get_or_none(self, provider_type: ModelProvider) -> AgentProvider | None: + """Return the provider for *provider_type*, or ``None`` if unregistered. + + The orchestrator uses this to decide whether a dedicated backend + exists; ``None`` means "use the built-in Claude Code spawn". + """ + return self._providers.get(provider_type) + + def is_registered(self, provider_type: ModelProvider) -> bool: + """Return True if a provider is registered for *provider_type*.""" + return provider_type in self._providers + + def registered_types(self) -> list[ModelProvider]: + """Return all registered provider types.""" + return list(self._providers.keys()) + + def unregister(self, provider_type: ModelProvider) -> None: + """Remove a provider registration (no-op if absent).""" + self._providers.pop(provider_type, None) diff --git a/roboco/models/base.py b/roboco/models/base.py index 8502e4e6..718dfc9d 100644 --- a/roboco/models/base.py +++ b/roboco/models/base.py @@ -194,6 +194,11 @@ class ModelProvider(StrEnum): `LOCAL` is the self-hosted Ollama provider: the operator configures its base URL via PUT /api/providers/self-hosted (seeded by migration 028). Agents assigned to LOCAL are routed to that server at spawn time. + `GROK` is xAI's OpenAI-compatible provider (grok-build-0.1 at + https://api.x.ai/v1). Unlike the others it does NOT speak the Anthropic + Messages API, so GROK agents run through a dedicated OpenAI-protocol + provider (roboco.llm.providers.grok), not ANTHROPIC_BASE_URL injection. + The xAI key is set via PUT /api/providers/grok/key. `OPENAI` is reserved for future use. """ @@ -201,6 +206,7 @@ class ModelProvider(StrEnum): OLLAMA_CLOUD = "ollama_cloud" OPENAI = "openai" LOCAL = "local" + GROK = "grok" class AssignmentScope(StrEnum): diff --git a/roboco/models/llm_catalog.py b/roboco/models/llm_catalog.py index 58c733f8..fd4ec9ed 100644 --- a/roboco/models/llm_catalog.py +++ b/roboco/models/llm_catalog.py @@ -72,6 +72,10 @@ MODEL_CATALOG: tuple[CatalogEntry, ...] = ( CatalogEntry("glm-5.1:cloud", ModelProvider.OLLAMA_CLOUD, "GLM 5.1"), CatalogEntry("kimi-k2.6:cloud", ModelProvider.OLLAMA_CLOUD, "Kimi K2.6"), CatalogEntry("minimax-m3:cloud", ModelProvider.OLLAMA_CLOUD, "Minimax M3"), + # --- Grok (xAI, OpenAI protocol) --- + # Routes to the GROK provider → GrokProvider spawn (api.x.ai/v1). The xAI + # key is set via PUT /api/providers/grok/key. + CatalogEntry("grok-build-0.1", ModelProvider.GROK, "Grok Build 0.1"), ) diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py index 952bc9ce..8817fe66 100644 --- a/roboco/runtime/orchestrator.py +++ b/roboco/runtime/orchestrator.py @@ -31,6 +31,7 @@ if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession + from roboco.llm.providers import AgentProvider, ProviderRegistry from roboco.services.llm import AgentRoute from roboco.services.task import TaskService import structlog @@ -624,6 +625,12 @@ class AgentOrchestrator: self._strategy_engine_task: asyncio.Task | None = None self._external_pr_poll_task: asyncio.Task | None = None self._self_heal_task: asyncio.Task | None = None + # Provider registry: maps a ModelProvider to a dedicated AgentProvider + # backend. Only providers needing a non-Claude-Code runtime are + # registered (currently GROK, which speaks the OpenAI protocol). Agents + # on unregistered providers (Anthropic / Ollama Cloud / self-hosted) use + # the built-in _spawn_container path unchanged. Built lazily. + self._provider_registry: ProviderRegistry | None = None # Tracks which providers have already received a CEO notification # during the current rate-limit episode. Cleared when the probe # succeeds and the rate limit is lifted (tracker.clear() path). @@ -1954,6 +1961,36 @@ class AgentOrchestrator: """Return the string to pass to `claude --model`.""" return _resolve_agent_cli_model(config.provider_type, config.model) + def _ensure_provider_registry(self) -> "ProviderRegistry": + """Build (once) the registry of dedicated provider backends. + + Only providers that need a runtime other than the built-in Claude Code + container are registered. Today that is GROK (xAI, OpenAI protocol). + """ + if self._provider_registry is None: + from roboco.llm.providers import GrokProvider, ProviderRegistry + from roboco.models.base import ModelProvider + + registry = ProviderRegistry() + registry.register(ModelProvider.GROK, GrokProvider(self)) + self._provider_registry = registry + return self._provider_registry + + def _provider_for(self, provider_type: str) -> "AgentProvider | None": + """Resolve a dedicated provider for a route's ``provider_type`` string. + + Returns ``None`` for providers that use the built-in Claude Code spawn + (Anthropic / Ollama Cloud / self-hosted) or any unrecognised value — the + caller then runs the existing container path unchanged. + """ + from roboco.models.base import ModelProvider + + try: + model_provider = ModelProvider(provider_type) + except ValueError: + return None + return self._ensure_provider_registry().get_or_none(model_provider) + async def _spawn_container( self, config: AgentConfig, @@ -1967,6 +2004,15 @@ class AgentOrchestrator: initial_prompt: Optional initial prompt for the agent agent_settings_path: Path to per-agent Claude settings file """ + # A dedicated provider backend (e.g. GROK / OpenAI protocol) handles its + # own spawn. Anthropic / Ollama Cloud / self-hosted have no dedicated + # provider registered and fall through to the Claude Code body below, + # byte-for-byte unchanged. + provider = self._provider_for(config.provider_type) + if provider is not None: + result = await provider.spawn(config, initial_prompt, agent_settings_path) + return result.instance_id + container_name = f"roboco-agent-{config.agent_id}" await self._remove_container(container_name) diff --git a/roboco/services/llm.py b/roboco/services/llm.py index 540a184d..4523c586 100644 --- a/roboco/services/llm.py +++ b/roboco/services/llm.py @@ -353,6 +353,27 @@ class ModelRoutingService(BaseService): # Re-fetch for the caller. return await self._get_seeded_provider(ModelProvider.OLLAMA_CLOUD) + async def set_grok_api_key(self, api_key: str) -> ProviderConfigTable: + """Set / clear the Grok (xAI) provider's API key. + + Empty string clears + disables; a real key encrypts + enables. + Operates on the single pre-seeded Grok row — no provider creation + happens here. The key is the standard xAI key used against + https://api.x.ai/v1. + """ + provider = await self._get_seeded_provider(ModelProvider.GROK) + provider_svc = ProviderService(self.session) + await provider_svc.update_provider( + require_uuid(provider.id), + ProviderUpdate( + auth_token=api_key if api_key else None, + clear_auth_token=not api_key, + enabled=bool(api_key), + ), + ) + # Re-fetch for the caller. + return await self._get_seeded_provider(ModelProvider.GROK) + async def _get_seeded_provider( self, provider_type: ModelProvider ) -> ProviderConfigTable: diff --git a/tests/unit/llm/test_providers.py b/tests/unit/llm/test_providers.py new file mode 100644 index 00000000..01232bb8 --- /dev/null +++ b/tests/unit/llm/test_providers.py @@ -0,0 +1,272 @@ +"""Tests for the LLM agent provider seam. + +Covers the ProviderRegistry, the ClaudeCodeProvider adapter, and the +GrokProvider (xAI / OpenAI protocol) — especially the safety properties an +OpenAI-protocol agent provider must hold: + + * the agent gets the MCP gateway wiring (reuses the orchestrator mount path); + * the xAI endpoint is injected as OPENAI_* and never mislabelled ANTHROPIC_*; + * the prompt travels via env, so a leading ``--`` cannot become a CLI flag. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from roboco.llm.providers import ( + ClaudeCodeProvider, + GrokProvider, + ProviderError, + ProviderNotRegisteredError, + ProviderRegistry, + SpawnResult, +) +from roboco.models.base import ModelProvider +from roboco.models.runtime import OrchestratorAgentConfig + + +def _config( + *, + provider_type: str = "grok", + provider_base_url: str | None = "https://api.x.ai/v1", + provider_auth_token: str | None = "xai-secret-key", + mcp_config_path: Path | None = Path("/host/mcp-configs/be-dev-1.json"), +) -> OrchestratorAgentConfig: + return OrchestratorAgentConfig( + agent_id="be-dev-1", + blueprint_path=Path("/app/system-prompt.md"), + model="grok-build-0.1", + mcp_config_path=mcp_config_path, + claude_session_id="sess-1", + provider_type=provider_type, + provider_base_url=provider_base_url, + provider_auth_token=provider_auth_token, + ) + + +class _FakeHost: + """Implements the orchestrator surface the providers delegate to.""" + + def __init__(self) -> None: + self.removed: list[str] = [] + self.spawn_args: tuple[object, ...] | None = None + self.mount_config: OrchestratorAgentConfig | None = None + + async def _spawn_container( + self, + config: OrchestratorAgentConfig, + initial_prompt: str | None = None, + agent_settings_path: Path | None = None, + ) -> str: + self.spawn_args = (config, initial_prompt, agent_settings_path) + return "container-id-abc123" + + async def _remove_container(self, container_name: str) -> None: + self.removed.append(container_name) + + def _resolve_host_paths( + self, config: OrchestratorAgentConfig, agent_settings_path: Path | None + ) -> dict[str, str | None]: + return { + "mcp_config": str(config.mcp_config_path) + if config.mcp_config_path + else None, + "settings": str(agent_settings_path) if agent_settings_path else None, + } + + def _build_mount_args( + self, + container_name: str, + config: OrchestratorAgentConfig, + hosts: dict[str, str | None], + ) -> list[str]: + # Record the config the mount step saw, and MIMIC the real + # _append_provider_env so a missed blanking would leak ANTHROPIC_*. + self.mount_config = config + cmd = ["docker", "run", "-d", "--name", container_name] + mcp = hosts.get("mcp_config") + if mcp: + cmd += ["-v", f"{mcp}:/app/mcp-config.json:ro"] + if config.provider_base_url: + cmd += ["-e", f"ANTHROPIC_BASE_URL={config.provider_base_url}"] + if config.provider_auth_token: + cmd += ["-e", f"ANTHROPIC_AUTH_TOKEN={config.provider_auth_token}"] + return cmd + + def _append_agent_auth_env( + self, cmd: list[str], config: OrchestratorAgentConfig + ) -> None: + cmd += ["-e", f"ROBOCO_AGENT_TOKEN=hmac-{config.agent_id}"] + + def _append_git_context_env( + self, cmd: list[str], config: OrchestratorAgentConfig + ) -> None: + cmd += ["-e", f"ROBOCO_GIT_AGENT={config.agent_id}"] + + +def _proc( + returncode: int = 0, stdout: bytes = b"cid\n", stderr: bytes = b"" +) -> MagicMock: + proc = MagicMock() + proc.returncode = returncode + proc.communicate = AsyncMock(return_value=(stdout, stderr)) + return proc + + +# --------------------------------------------------------------------------- +# ProviderRegistry +# --------------------------------------------------------------------------- + + +def test_registry_register_and_get() -> None: + registry = ProviderRegistry() + provider = GrokProvider(_FakeHost()) + registry.register(ModelProvider.GROK, provider) + assert registry.get(ModelProvider.GROK) is provider + assert registry.is_registered(ModelProvider.GROK) + assert registry.registered_types() == [ModelProvider.GROK] + + +def test_registry_get_unregistered_raises() -> None: + registry = ProviderRegistry() + with pytest.raises(ProviderNotRegisteredError): + registry.get(ModelProvider.GROK) + + +def test_registry_get_or_none_returns_none_when_absent() -> None: + registry = ProviderRegistry() + assert registry.get_or_none(ModelProvider.ANTHROPIC) is None + + +def test_registry_unregister() -> None: + registry = ProviderRegistry() + registry.register(ModelProvider.GROK, GrokProvider(_FakeHost())) + registry.unregister(ModelProvider.GROK) + assert not registry.is_registered(ModelProvider.GROK) + registry.unregister(ModelProvider.GROK) # idempotent + + +# --------------------------------------------------------------------------- +# GrokProvider +# --------------------------------------------------------------------------- + + +async def test_grok_spawn_requires_api_key() -> None: + provider = GrokProvider(_FakeHost()) + with pytest.raises(ProviderError, match="xAI API key"): + await provider.spawn(_config(provider_auth_token=None)) + + +async def test_grok_spawn_requires_mcp_config() -> None: + provider = GrokProvider(_FakeHost()) + with pytest.raises(ProviderError, match="MCP config"): + await provider.spawn(_config(mcp_config_path=None)) + + +async def test_grok_spawn_injects_openai_env_and_no_anthropic_leak() -> None: + host = _FakeHost() + provider = GrokProvider(host, image="roboco-agent-grok:test") + with patch( + "asyncio.create_subprocess_exec", AsyncMock(return_value=_proc()) + ) as exec_mock: + await provider.spawn(_config(), initial_prompt="do the work") + cmd = list(exec_mock.call_args.args) + assert "OPENAI_BASE_URL=https://api.x.ai/v1" in cmd + assert "OPENAI_API_KEY=xai-secret-key" in cmd + # The xAI endpoint must NOT be injected as an Anthropic var. + assert not any(c.startswith("ANTHROPIC_BASE_URL=") for c in cmd) + assert not any(c.startswith("ANTHROPIC_AUTH_TOKEN=") for c in cmd) + # Provider fields were blanked before the shared mount step. + assert host.mount_config is not None + assert host.mount_config.provider_base_url is None + assert host.mount_config.provider_auth_token is None + + +async def test_grok_spawn_wires_gateway_and_image_last() -> None: + host = _FakeHost() + provider = GrokProvider(host, image="roboco-agent-grok:test") + with patch( + "asyncio.create_subprocess_exec", AsyncMock(return_value=_proc()) + ) as exec_mock: + result = await provider.spawn(_config()) + cmd = list(exec_mock.call_args.args) + # Gateway + operational env the grok image entrypoint consumes. + assert "ROBOCO_MCP_CONFIG=/app/mcp-config.json" in cmd + assert "ROBOCO_SYSTEM_PROMPT=/app/system-prompt.md" in cmd + assert "ROBOCO_AGENT_TOOLS=Read,Write,Edit,Bash,Grep,Glob,TodoWrite" in cmd + # Identity wiring from the shared host helpers is present. + assert "ROBOCO_AGENT_TOKEN=hmac-be-dev-1" in cmd + # The image is the final docker-run argument. + assert cmd[-1] == "roboco-agent-grok:test" + assert host.removed == ["roboco-agent-be-dev-1"] + assert result == SpawnResult( + instance_id="roboco-agent-be-dev-1", + extra={"container_id": "cid", "model": "grok-build-0.1"}, + ) + + +async def test_grok_spawn_prompt_is_injection_safe() -> None: + host = _FakeHost() + provider = GrokProvider(host) + nasty = "--model evil --session-id pwned" + with patch( + "asyncio.create_subprocess_exec", AsyncMock(return_value=_proc()) + ) as exec_mock: + await provider.spawn(_config(), initial_prompt=nasty) + cmd = list(exec_mock.call_args.args) + # Passed only as an env value, never as a bare argv token. + assert f"ROBOCO_INITIAL_PROMPT={nasty}" in cmd + assert nasty not in cmd + + +async def test_grok_spawn_defaults_base_url_when_route_blank() -> None: + provider = GrokProvider(_FakeHost()) + with patch( + "asyncio.create_subprocess_exec", AsyncMock(return_value=_proc()) + ) as exec_mock: + await provider.spawn(_config(provider_base_url=None)) + cmd = list(exec_mock.call_args.args) + assert "OPENAI_BASE_URL=https://api.x.ai/v1" in cmd + + +async def test_grok_spawn_raises_on_docker_failure() -> None: + provider = GrokProvider(_FakeHost()) + with ( + patch( + "asyncio.create_subprocess_exec", + AsyncMock(return_value=_proc(returncode=1, stderr=b"boom")), + ), + pytest.raises(ProviderError, match="boom"), + ): + await provider.spawn(_config()) + + +# --------------------------------------------------------------------------- +# ClaudeCodeProvider +# --------------------------------------------------------------------------- + + +async def test_claude_spawn_delegates_to_host() -> None: + host = _FakeHost() + provider = ClaudeCodeProvider(host) + result = await provider.spawn(_config(provider_type="anthropic"), "prompt") + assert host.spawn_args is not None + assert result.instance_id == "roboco-agent-be-dev-1" + assert result.extra["container_id"] == "container-id-abc123" + + +async def test_claude_spawn_wraps_host_error() -> None: + host = _FakeHost() + host._spawn_container = AsyncMock(side_effect=RuntimeError("docker down")) # type: ignore[method-assign] + provider = ClaudeCodeProvider(host) + with pytest.raises(ProviderError, match="docker down"): + await provider.spawn(_config()) + + +async def test_claude_remove_delegates_to_host() -> None: + host = _FakeHost() + provider = ClaudeCodeProvider(host) + await provider.remove("roboco-agent-be-dev-1") + assert host.removed == ["roboco-agent-be-dev-1"] diff --git a/tests/unit/runtime/test_provider_routing.py b/tests/unit/runtime/test_provider_routing.py new file mode 100644 index 00000000..a6c4e02a --- /dev/null +++ b/tests/unit/runtime/test_provider_routing.py @@ -0,0 +1,41 @@ +"""The orchestrator routes only dedicated-backend providers through the registry. + +GROK gets the GrokProvider; Anthropic / Ollama Cloud / self-hosted (and any +unknown value) return None so ``_spawn_container`` runs its built-in Claude Code +path unchanged. This keeps the GROK addition purely additive. +""" + +from __future__ import annotations + +from unittest.mock import patch + +from roboco.llm.providers import GrokProvider +from roboco.runtime.orchestrator import AgentOrchestrator + + +def _make_orch() -> AgentOrchestrator: + with patch.object(AgentOrchestrator, "__init__", return_value=None): + orch = AgentOrchestrator.__new__(AgentOrchestrator) + orch._provider_registry = None + return orch + + +def test_provider_for_grok_returns_grok_provider() -> None: + assert isinstance(_make_orch()._provider_for("grok"), GrokProvider) + + +def test_provider_for_anthropic_returns_none() -> None: + assert _make_orch()._provider_for("anthropic") is None + + +def test_provider_for_ollama_cloud_returns_none() -> None: + assert _make_orch()._provider_for("ollama_cloud") is None + + +def test_provider_for_unknown_value_returns_none() -> None: + assert _make_orch()._provider_for("bogus") is None + + +def test_provider_registry_built_once() -> None: + orch = _make_orch() + assert orch._ensure_provider_registry() is orch._ensure_provider_registry()