mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(providers): pluggable agent providers + Grok (xAI) backend
Add a roboco/llm/providers/ seam — an AgentProvider lifecycle ABC and a ProviderRegistry keyed by ModelProvider — so the orchestrator can drive agent backends other than Claude Code. The first non-Claude backend 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. It reuses the orchestrator's existing mount/auth assembly, so it inherits 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 run the existing _spawn_container path unchanged. Includes: - ModelProvider.GROK (migration 038) + a seeded Grok provider row (migration 039) + a grok-build-0.1 catalog entry - GET/PUT /api/providers/grok-key to store the xAI key (Fernet-encrypted, reusing the existing provider-key machinery) - ClaudeCodeProvider reference adapter over the current spawn - unit tests for the registry, GrokProvider (gateway wiring, no ANTHROPIC_* leak, prompt-injection safety, failure paths) and routing The dedicated roboco-agent-grok image and the exact OpenAI-protocol CLI invocation are the remaining piece to finalise with xAI.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)'"))
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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."""
|
||||
...
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user