mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(providers): native Grok runtime — opencode image, config gen, panel key
Complete the native Grok (xAI) path so grok-build-0.1 runs as a real RoboCo agent, not just the provider seam. - roboco-agent-grok image (docker/agent-grok.Dockerfile): FROM agent-base + opencode (the OpenAI-protocol runtime). One image serves every role; role behaviour comes from the mounted manifest / mcp-config / system prompt, exactly as on the Claude path. - Entrypoint renders opencode.json at spawn from the GrokProvider env contract + the mounted Claude Code mcp-config.json (roboco.llm.providers.opencode_config): translates RoboCo's gateway servers (roboco-flow / roboco-do / ...) into opencode's mcp block, declares the xAI OpenAI-compatible provider + model, and wires permissions + instructions. Pure, unit-tested translation. - Orchestrator registers GrokProvider with the registry-qualified image (_qualify_agent_image) so it resolves in local and registry deploys. - Compose (both files + the registry compose) gain an agent-grok-image builder service. - Panel: a Grok (xAI) API key card on the AI Providers page, plus the grok ModelProvider value. KNOWN PARITY GAP (opencode runtime): the bash-guard PAT-scrub and the transcript-based usage/cost capture are Claude Code hooks and do not transfer to opencode. bash permission is operator-tunable (ROBOCO_GROK_BASH_PERMISSION) so a deployment can fail closed until a security/usage-parity opencode plugin lands. That plugin and live E2E validation are the remaining work to finalize with xAI.
This commit is contained in:
+1
-1
@@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
### 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.
|
||||
- **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` + a Settings panel card to store the xAI key (Fernet-encrypted, reusing the existing provider-key machinery). Ships the full native runtime: a first-class `roboco-agent-grok` image (built `FROM` agent-base, adds opencode; wired into both compose files, the registry compose, and the release workflow) whose entrypoint renders an `opencode.json` at spawn — translating RoboCo's MCP gateway servers into opencode's config and declaring the xAI provider — then runs opencode. KNOWN PARITY GAP: RoboCo's bash-guard (PAT-scrub) and transcript-based usage/cost capture are Claude Code hooks that do not transfer to the opencode runtime; the `bash` permission is operator-tunable so a deployment can fail closed until a security/usage-parity opencode plugin lands. That plugin and live end-to-end validation are the remaining work to finalize with xAI.
|
||||
|
||||
## [0.6.0] - 2026-06-17
|
||||
|
||||
|
||||
@@ -160,6 +160,11 @@ services:
|
||||
entrypoint: ["/bin/sh", "-c", "echo 'agent-pr-reviewer image present'"]
|
||||
restart: "no"
|
||||
|
||||
agent-grok-image:
|
||||
image: ${ROBOCO_REGISTRY:-ghcr.io/rennf93}/roboco-agent-grok:${ROBOCO_VERSION:-latest}
|
||||
entrypoint: ["/bin/sh", "-c", "echo 'agent-grok image present'"]
|
||||
restart: "no"
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Orchestrator — API server + agent spawner
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -229,6 +229,19 @@ services:
|
||||
depends_on:
|
||||
- agent-base-image
|
||||
|
||||
# ==========================================================================
|
||||
# Agent Grok Image Builder (xAI grok-build-0.1 via opencode, OpenAI protocol)
|
||||
# ==========================================================================
|
||||
agent-grok-image:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/agent-grok.Dockerfile
|
||||
image: roboco-agent-grok
|
||||
entrypoint: ["/bin/sh", "-c", 'echo "Agent Grok image built"']
|
||||
restart: "no"
|
||||
depends_on:
|
||||
- agent-base-image
|
||||
|
||||
# ==========================================================================
|
||||
# Orchestrator - API Server + Agent Spawner
|
||||
# ==========================================================================
|
||||
|
||||
@@ -229,6 +229,19 @@ services:
|
||||
depends_on:
|
||||
- agent-base-image
|
||||
|
||||
# ==========================================================================
|
||||
# Agent Grok Image Builder (xAI grok-build-0.1 via opencode, OpenAI protocol)
|
||||
# ==========================================================================
|
||||
agent-grok-image:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/agent-grok.Dockerfile
|
||||
image: roboco-agent-grok
|
||||
entrypoint: ["/bin/sh", "-c", 'echo "Agent Grok image built"']
|
||||
restart: "no"
|
||||
depends_on:
|
||||
- agent-base-image
|
||||
|
||||
# ==========================================================================
|
||||
# Orchestrator - API Server + Agent Spawner
|
||||
# ==========================================================================
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Grok (xAI) Agent Image
|
||||
# =============================================================================
|
||||
# Runs grok-build-0.1 through the opencode CLI (OpenAI protocol) instead of
|
||||
# Claude Code, while reusing the base image's roboco venv + uv + the RoboCo MCP
|
||||
# gateway servers. The entrypoint renders opencode.json from the spawn env +
|
||||
# mounted mcp-config.json (see roboco.llm.providers.opencode_config) and runs
|
||||
# opencode. One runtime image serves every role — role behaviour comes from the
|
||||
# mounted system prompt / manifest / mcp-config, exactly as on the Claude path.
|
||||
# =============================================================================
|
||||
|
||||
FROM roboco-agent-base
|
||||
|
||||
USER root
|
||||
|
||||
# opencode — the OpenAI-protocol agent runtime. The @ai-sdk/openai-compatible
|
||||
# package backs the custom xAI provider declared in the generated opencode.json;
|
||||
# opencode also resolves it at runtime, but pre-installing keeps first spawn off
|
||||
# the network.
|
||||
RUN npm install -g opencode-ai @ai-sdk/openai-compatible \
|
||||
&& npm cache clean --force \
|
||||
&& rm -rf /root/.npm /tmp/*
|
||||
|
||||
# Entrypoint: render opencode.json, then run opencode (overrides base's `claude`).
|
||||
COPY docker/scripts/grok-agent-entrypoint.sh /app/scripts/grok-agent-entrypoint.sh
|
||||
RUN chmod 0755 /app/scripts/grok-agent-entrypoint.sh
|
||||
|
||||
USER agent
|
||||
|
||||
LABEL role="grok-runtime"
|
||||
LABEL description="Grok (xAI) agent runtime — grok-build-0.1 via opencode (OpenAI protocol)"
|
||||
|
||||
ENTRYPOINT ["/app/scripts/grok-agent-entrypoint.sh"]
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Entrypoint for the roboco-agent-grok image.
|
||||
#
|
||||
# Renders opencode.json from the RoboCo spawn env (OPENAI_* + ROBOCO_*, set by
|
||||
# GrokProvider) plus the mounted Claude Code mcp-config.json, then runs opencode
|
||||
# non-interactively. opencode speaks the OpenAI protocol, so grok-build-0.1 runs
|
||||
# natively against api.x.ai/v1 with no shim, while still reaching the RoboCo MCP
|
||||
# gateway (roboco-flow / roboco-do / ...) translated into opencode's mcp config.
|
||||
set -euo pipefail
|
||||
|
||||
# Generate opencode.json (provider + model + MCP gateway + permissions +
|
||||
# instructions). Writes to opencode's global config dir by default.
|
||||
python -m roboco.llm.providers.opencode_config
|
||||
|
||||
# Run the agent. The prompt comes from an env var (never an untrusted argv
|
||||
# positional); `--` separates it from flags so a prompt starting with `--`
|
||||
# cannot be parsed as CLI options. The model also comes from the rendered
|
||||
# config; --model is passed explicitly as belt-and-suspenders.
|
||||
exec opencode run \
|
||||
--model "xai/${ROBOCO_AGENT_MODEL:-grok-build-0.1}" \
|
||||
-- "${ROBOCO_INITIAL_PROMPT:-}"
|
||||
@@ -4,8 +4,10 @@ import { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import {
|
||||
useApplyMode,
|
||||
useCatalog,
|
||||
useGrokKey,
|
||||
useOllamaKey,
|
||||
useRoutingMode,
|
||||
useSetGrokKey,
|
||||
useSetOllamaKey,
|
||||
useSelfHostedModels,
|
||||
} from "@/hooks/use-providers";
|
||||
@@ -124,6 +126,33 @@ export function AIRoutingCard() {
|
||||
}
|
||||
};
|
||||
|
||||
// --- Grok (xAI) API key ---
|
||||
const { data: grokKeyStatus } = useGrokKey();
|
||||
const setGrokKeyMut = useSetGrokKey();
|
||||
const hasGrokKey = !!grokKeyStatus?.has_key;
|
||||
const [grokKey, setGrokKey] = useState("");
|
||||
const [clearGrokKey, setClearGrokKey] = useState(false);
|
||||
|
||||
const saveGrokKey = async () => {
|
||||
try {
|
||||
if (clearGrokKey) {
|
||||
await setGrokKeyMut.mutateAsync("");
|
||||
toast.success("Grok key cleared");
|
||||
} else {
|
||||
if (!grokKey.trim()) {
|
||||
toast.error("Enter a key first");
|
||||
return;
|
||||
}
|
||||
await setGrokKeyMut.mutateAsync(grokKey);
|
||||
toast.success("Grok key saved");
|
||||
}
|
||||
setGrokKey("");
|
||||
setClearGrokKey(false);
|
||||
} catch (e) {
|
||||
toast.error("Save failed: " + errMsg(e));
|
||||
}
|
||||
};
|
||||
|
||||
// --- Mix mode state: agent_slug → model_name ---
|
||||
const initialMix = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
@@ -300,6 +329,56 @@ export function AIRoutingCard() {
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* -------- Grok (xAI) key -------- */}
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">Grok (xAI) API key</Label>
|
||||
{hasGrokKey ? (
|
||||
<Badge className="bg-emerald-500/10 text-emerald-600 border-0">
|
||||
<KeyRound className="h-3 w-3" /> key set
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-amber-500/10 text-amber-600 border-0">
|
||||
<Key className="h-3 w-3" /> not set
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
value={grokKey}
|
||||
onChange={(e) => setGrokKey(e.target.value)}
|
||||
placeholder={
|
||||
hasGrokKey ? "•••••••••••• (leave blank to keep)" : "xai-…"
|
||||
}
|
||||
disabled={clearGrokKey}
|
||||
/>
|
||||
<Button onClick={saveGrokKey} disabled={setGrokKeyMut.isPending}>
|
||||
{setGrokKeyMut.isPending ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
{hasGrokKey ? (
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<Checkbox
|
||||
checked={clearGrokKey}
|
||||
onCheckedChange={(checked) => {
|
||||
const next = checked === true;
|
||||
setClearGrokKey(next);
|
||||
if (next) setGrokKey("");
|
||||
}}
|
||||
/>
|
||||
Clear the stored key
|
||||
</label>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used for grok-build-0.1 at api.x.ai/v1. Stored Fernet-encrypted
|
||||
server-side; never returned by the API.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* -------- Self-Hosted LLM -------- */}
|
||||
<SelfHostedSection
|
||||
testResult={selfHostedTestResult}
|
||||
|
||||
@@ -9,6 +9,7 @@ export const providerKeys = {
|
||||
all: ["providers"] as const,
|
||||
catalog: () => [...providerKeys.all, "catalog"] as const,
|
||||
ollamaKey: () => [...providerKeys.all, "ollama-key"] as const,
|
||||
grokKey: () => [...providerKeys.all, "grok-key"] as const,
|
||||
mode: () => [...providerKeys.all, "mode"] as const,
|
||||
selfHostedConfig: () => [...providerKeys.all, "self-hosted-config"] as const,
|
||||
selfHostedModels: () => [...providerKeys.all, "self-hosted-models"] as const,
|
||||
@@ -43,6 +44,25 @@ export function useSetOllamaKey() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useGrokKey() {
|
||||
return useQuery({
|
||||
queryKey: providerKeys.grokKey(),
|
||||
queryFn: () => providersApi.getGrokKey(),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetGrokKey() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (apiKey: string) => providersApi.setGrokKey(apiKey),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: providerKeys.grokKey() });
|
||||
qc.invalidateQueries({ queryKey: providerKeys.mode() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRoutingMode() {
|
||||
return useQuery({
|
||||
queryKey: providerKeys.mode(),
|
||||
|
||||
@@ -16,6 +16,11 @@ export interface OllamaKeyStatus {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface GrokKeyStatus {
|
||||
has_key: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ModelAssignment {
|
||||
id: string;
|
||||
scope: AssignmentScope;
|
||||
@@ -86,6 +91,18 @@ export const providersApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
getGrokKey: async (): Promise<GrokKeyStatus> => {
|
||||
const { data } = await api.get<GrokKeyStatus>("/providers/grok-key");
|
||||
return data;
|
||||
},
|
||||
|
||||
setGrokKey: async (apiKey: string): Promise<GrokKeyStatus> => {
|
||||
const { data } = await api.put<GrokKeyStatus>("/providers/grok-key", {
|
||||
api_key: apiKey,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
getMode: async (): Promise<ModeSnapshot> => {
|
||||
const { data } = await api.get<ModeSnapshot>("/providers");
|
||||
return data;
|
||||
|
||||
@@ -104,6 +104,7 @@ export enum ModelProvider {
|
||||
OLLAMA_CLOUD = "ollama_cloud",
|
||||
OPENAI = "openai",
|
||||
LOCAL = "local",
|
||||
GROK = "grok",
|
||||
}
|
||||
|
||||
export enum AssignmentScope {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Generate an ``opencode.json`` for a Grok (xAI) agent at container start.
|
||||
|
||||
The ``roboco-agent-grok`` image's entrypoint runs ``python -m
|
||||
roboco.llm.providers.opencode_config`` to turn the env contract ``GrokProvider``
|
||||
sets (``OPENAI_*`` + ``ROBOCO_*``) plus the mounted Claude Code
|
||||
``mcp-config.json`` into the ``opencode.json`` that opencode reads. Keeping this
|
||||
as importable Python (not a shell heredoc) makes the translation unit-testable.
|
||||
|
||||
Config shape per opencode docs (https://opencode.ai/docs/config):
|
||||
* ``provider.<id>`` — ``@ai-sdk/openai-compatible`` with ``options.baseURL`` /
|
||||
``options.apiKey``; ``model`` selects ``<id>/<model>``.
|
||||
* ``mcp.<name>`` — ``{type:"local", command:[...], environment:{...}}``; this
|
||||
is where RoboCo's gateway servers (roboco-flow / roboco-do / ...) are wired,
|
||||
translated from Claude Code's ``mcpServers`` (``command`` + ``args`` + ``env``).
|
||||
* ``permission.{bash,edit}`` and ``instructions`` (system prompt + briefing).
|
||||
|
||||
KNOWN PARITY GAP (tracked for the opencode-plugin follow-up with xAI): RoboCo's
|
||||
bash-guard (PAT-scrub) and transcript-based usage/cost capture are Claude Code
|
||||
hooks; they do not transfer to the opencode runtime. ``bash`` permission is
|
||||
operator-tunable (``ROBOCO_GROK_BASH_PERMISSION``) so a deployment can fail
|
||||
closed (``deny``/``ask``) until a security-parity opencode plugin lands.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_OPENCODE_SCHEMA = "https://opencode.ai/config.json"
|
||||
_PROVIDER_ID = "xai"
|
||||
_OPENAI_COMPAT_NPM = "@ai-sdk/openai-compatible"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class XaiTarget:
|
||||
"""The xAI endpoint a Grok agent talks to."""
|
||||
|
||||
base_url: str
|
||||
api_key: str
|
||||
model: str
|
||||
|
||||
|
||||
def translate_mcp_servers(mcp_config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Translate Claude Code ``mcpServers`` into opencode's ``mcp`` block.
|
||||
|
||||
``{"command": "uv", "args": [...], "env": {...}}`` becomes
|
||||
``{"type": "local", "command": ["uv", ...], "environment": {...},
|
||||
"enabled": True}``.
|
||||
"""
|
||||
servers = mcp_config.get("mcpServers", {})
|
||||
out: dict[str, Any] = {}
|
||||
for name, spec in servers.items():
|
||||
command = spec.get("command")
|
||||
args = list(spec.get("args", []))
|
||||
cmd_list = [command, *args] if command else args
|
||||
entry: dict[str, Any] = {
|
||||
"type": "local",
|
||||
"command": cmd_list,
|
||||
"enabled": True,
|
||||
}
|
||||
env = spec.get("env")
|
||||
if env:
|
||||
entry["environment"] = env
|
||||
out[name] = entry
|
||||
return out
|
||||
|
||||
|
||||
def build_opencode_config(
|
||||
mcp_config: dict[str, Any],
|
||||
target: XaiTarget,
|
||||
*,
|
||||
instruction_paths: list[str],
|
||||
bash_permission: str = "allow",
|
||||
edit_permission: str = "allow",
|
||||
) -> dict[str, Any]:
|
||||
"""Build the full ``opencode.json`` dict for a Grok agent."""
|
||||
return {
|
||||
"$schema": _OPENCODE_SCHEMA,
|
||||
"provider": {
|
||||
_PROVIDER_ID: {
|
||||
"npm": _OPENAI_COMPAT_NPM,
|
||||
"name": "xAI",
|
||||
"options": {"baseURL": target.base_url, "apiKey": target.api_key},
|
||||
"models": {target.model: {"name": target.model}},
|
||||
}
|
||||
},
|
||||
"model": f"{_PROVIDER_ID}/{target.model}",
|
||||
"mcp": translate_mcp_servers(mcp_config),
|
||||
"permission": {"bash": bash_permission, "edit": edit_permission},
|
||||
"instructions": instruction_paths,
|
||||
}
|
||||
|
||||
|
||||
def _load_mcp_config(path: str) -> dict[str, Any]:
|
||||
"""Load the mounted mcp-config.json, tolerating a missing/invalid file."""
|
||||
try:
|
||||
with Path(path).open() as fh:
|
||||
data: dict[str, Any] = json.load(fh)
|
||||
return data
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Entrypoint: read env + mounted mcp-config.json, write opencode.json."""
|
||||
target = XaiTarget(
|
||||
base_url=os.environ.get("OPENAI_BASE_URL", "https://api.x.ai/v1"),
|
||||
api_key=os.environ.get("OPENAI_API_KEY", ""),
|
||||
model=os.environ.get("ROBOCO_AGENT_MODEL", "grok-build-0.1"),
|
||||
)
|
||||
mcp_path = os.environ.get("ROBOCO_MCP_CONFIG", "/app/mcp-config.json")
|
||||
system_prompt = os.environ.get("ROBOCO_SYSTEM_PROMPT", "/app/system-prompt.md")
|
||||
# Default to opencode's global config location so it is found regardless of
|
||||
# the agent's working directory (cwd is the per-agent workspace at spawn).
|
||||
out_path = os.environ.get(
|
||||
"ROBOCO_OPENCODE_CONFIG",
|
||||
str(Path.home() / ".config" / "opencode" / "opencode.json"),
|
||||
)
|
||||
bash_perm = os.environ.get("ROBOCO_GROK_BASH_PERMISSION", "allow")
|
||||
|
||||
# Instructions = system prompt + the SessionStart briefing when mounted.
|
||||
candidates = [system_prompt, "/app/briefing.md"]
|
||||
instructions = [p for p in candidates if p and Path(p).exists()]
|
||||
|
||||
config = build_opencode_config(
|
||||
_load_mcp_config(mcp_path),
|
||||
target,
|
||||
instruction_paths=instructions,
|
||||
bash_permission=bash_perm,
|
||||
)
|
||||
out = Path(out_path)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
with out.open("w") as fh:
|
||||
json.dump(config, fh, indent=2)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1972,7 +1972,13 @@ class AgentOrchestrator:
|
||||
from roboco.models.base import ModelProvider
|
||||
|
||||
registry = ProviderRegistry()
|
||||
registry.register(ModelProvider.GROK, GrokProvider(self))
|
||||
# Qualify the grok image with the registry namespace + tag so it
|
||||
# resolves in both local-build and registry deploys (parity with
|
||||
# get_agent_image for the Claude path).
|
||||
registry.register(
|
||||
ModelProvider.GROK,
|
||||
GrokProvider(self, image=_qualify_agent_image("roboco-agent-grok")),
|
||||
)
|
||||
self._provider_registry = registry
|
||||
return self._provider_registry
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Tests for the Grok opencode.json generator (RoboCo MCP -> opencode config)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from roboco.llm.providers.opencode_config import (
|
||||
XaiTarget,
|
||||
build_opencode_config,
|
||||
translate_mcp_servers,
|
||||
)
|
||||
|
||||
_TARGET = XaiTarget(
|
||||
base_url="https://api.x.ai/v1", api_key="xai-key", model="grok-build-0.1"
|
||||
)
|
||||
|
||||
_MCP = {
|
||||
"mcpServers": {
|
||||
"roboco-flow": {
|
||||
"command": "uv",
|
||||
"args": ["run", "--no-sync", "python", "-m", "roboco.mcp.flow_server"],
|
||||
"env": {
|
||||
"ROBOCO_AGENT_ID": "uuid-1",
|
||||
"UV_PROJECT_ENVIRONMENT": "/app/.venv",
|
||||
},
|
||||
},
|
||||
"roboco-do": {
|
||||
"command": "uv",
|
||||
"args": ["run", "--no-sync", "python", "-m", "roboco.mcp.do_server"],
|
||||
"env": {"ROBOCO_AGENT_ID": "uuid-1"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_translate_mcp_servers_shape() -> None:
|
||||
out = translate_mcp_servers(_MCP)
|
||||
flow = out["roboco-flow"]
|
||||
assert flow["type"] == "local"
|
||||
assert flow["enabled"] is True
|
||||
# command + args collapse into a single command array (opencode shape).
|
||||
assert flow["command"] == [
|
||||
"uv",
|
||||
"run",
|
||||
"--no-sync",
|
||||
"python",
|
||||
"-m",
|
||||
"roboco.mcp.flow_server",
|
||||
]
|
||||
# env -> environment (opencode key).
|
||||
assert flow["environment"]["ROBOCO_AGENT_ID"] == "uuid-1"
|
||||
assert "env" not in flow
|
||||
assert set(out) == {"roboco-flow", "roboco-do"}
|
||||
|
||||
|
||||
def test_translate_mcp_servers_empty() -> None:
|
||||
assert translate_mcp_servers({}) == {}
|
||||
assert translate_mcp_servers({"mcpServers": {}}) == {}
|
||||
|
||||
|
||||
def test_translate_mcp_servers_omits_environment_when_no_env() -> None:
|
||||
out = translate_mcp_servers(
|
||||
{"mcpServers": {"x": {"command": "uv", "args": ["run"]}}}
|
||||
)
|
||||
assert "environment" not in out["x"]
|
||||
assert out["x"]["command"] == ["uv", "run"]
|
||||
|
||||
|
||||
def test_build_opencode_config_provider_and_model() -> None:
|
||||
cfg = build_opencode_config(
|
||||
_MCP,
|
||||
_TARGET,
|
||||
instruction_paths=["/app/system-prompt.md"],
|
||||
)
|
||||
provider = cfg["provider"]["xai"]
|
||||
assert provider["npm"] == "@ai-sdk/openai-compatible"
|
||||
assert provider["options"]["baseURL"] == "https://api.x.ai/v1"
|
||||
assert provider["options"]["apiKey"] == "xai-key"
|
||||
assert "grok-build-0.1" in provider["models"]
|
||||
# Top-level model selector is "<provider>/<model>".
|
||||
assert cfg["model"] == "xai/grok-build-0.1"
|
||||
# Gateway servers carried through.
|
||||
assert "roboco-flow" in cfg["mcp"]
|
||||
assert cfg["instructions"] == ["/app/system-prompt.md"]
|
||||
|
||||
|
||||
def test_build_opencode_config_bash_permission_is_tunable() -> None:
|
||||
cfg = build_opencode_config(
|
||||
{},
|
||||
_TARGET,
|
||||
instruction_paths=[],
|
||||
bash_permission="deny",
|
||||
)
|
||||
assert cfg["permission"]["bash"] == "deny"
|
||||
assert cfg["permission"]["edit"] == "allow"
|
||||
Reference in New Issue
Block a user