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
|
### 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
|
## [0.6.0] - 2026-06-17
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,11 @@ services:
|
|||||||
entrypoint: ["/bin/sh", "-c", "echo 'agent-pr-reviewer image present'"]
|
entrypoint: ["/bin/sh", "-c", "echo 'agent-pr-reviewer image present'"]
|
||||||
restart: "no"
|
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
|
# Orchestrator — API server + agent spawner
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -229,6 +229,19 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- agent-base-image
|
- 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
|
# Orchestrator - API Server + Agent Spawner
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
|
|||||||
@@ -229,6 +229,19 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- agent-base-image
|
- 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
|
# 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 {
|
import {
|
||||||
useApplyMode,
|
useApplyMode,
|
||||||
useCatalog,
|
useCatalog,
|
||||||
|
useGrokKey,
|
||||||
useOllamaKey,
|
useOllamaKey,
|
||||||
useRoutingMode,
|
useRoutingMode,
|
||||||
|
useSetGrokKey,
|
||||||
useSetOllamaKey,
|
useSetOllamaKey,
|
||||||
useSelfHostedModels,
|
useSelfHostedModels,
|
||||||
} from "@/hooks/use-providers";
|
} 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 ---
|
// --- Mix mode state: agent_slug → model_name ---
|
||||||
const initialMix = useMemo(() => {
|
const initialMix = useMemo(() => {
|
||||||
const map: Record<string, string> = {};
|
const map: Record<string, string> = {};
|
||||||
@@ -300,6 +329,56 @@ export function AIRoutingCard() {
|
|||||||
|
|
||||||
<Separator />
|
<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 -------- */}
|
{/* -------- Self-Hosted LLM -------- */}
|
||||||
<SelfHostedSection
|
<SelfHostedSection
|
||||||
testResult={selfHostedTestResult}
|
testResult={selfHostedTestResult}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const providerKeys = {
|
|||||||
all: ["providers"] as const,
|
all: ["providers"] as const,
|
||||||
catalog: () => [...providerKeys.all, "catalog"] as const,
|
catalog: () => [...providerKeys.all, "catalog"] as const,
|
||||||
ollamaKey: () => [...providerKeys.all, "ollama-key"] as const,
|
ollamaKey: () => [...providerKeys.all, "ollama-key"] as const,
|
||||||
|
grokKey: () => [...providerKeys.all, "grok-key"] as const,
|
||||||
mode: () => [...providerKeys.all, "mode"] as const,
|
mode: () => [...providerKeys.all, "mode"] as const,
|
||||||
selfHostedConfig: () => [...providerKeys.all, "self-hosted-config"] as const,
|
selfHostedConfig: () => [...providerKeys.all, "self-hosted-config"] as const,
|
||||||
selfHostedModels: () => [...providerKeys.all, "self-hosted-models"] 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() {
|
export function useRoutingMode() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: providerKeys.mode(),
|
queryKey: providerKeys.mode(),
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export interface OllamaKeyStatus {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GrokKeyStatus {
|
||||||
|
has_key: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ModelAssignment {
|
export interface ModelAssignment {
|
||||||
id: string;
|
id: string;
|
||||||
scope: AssignmentScope;
|
scope: AssignmentScope;
|
||||||
@@ -86,6 +91,18 @@ export const providersApi = {
|
|||||||
return data;
|
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> => {
|
getMode: async (): Promise<ModeSnapshot> => {
|
||||||
const { data } = await api.get<ModeSnapshot>("/providers");
|
const { data } = await api.get<ModeSnapshot>("/providers");
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ export enum ModelProvider {
|
|||||||
OLLAMA_CLOUD = "ollama_cloud",
|
OLLAMA_CLOUD = "ollama_cloud",
|
||||||
OPENAI = "openai",
|
OPENAI = "openai",
|
||||||
LOCAL = "local",
|
LOCAL = "local",
|
||||||
|
GROK = "grok",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AssignmentScope {
|
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
|
from roboco.models.base import ModelProvider
|
||||||
|
|
||||||
registry = ProviderRegistry()
|
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
|
self._provider_registry = registry
|
||||||
return self._provider_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