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:
Renn F
2026-06-18 07:12:57 +02:00
parent a956083f9f
commit 085414dcf5
13 changed files with 444 additions and 2 deletions
@@ -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}
+20
View File
@@ -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(),
+17
View File
@@ -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;
+1
View File
@@ -104,6 +104,7 @@ export enum ModelProvider {
OLLAMA_CLOUD = "ollama_cloud",
OPENAI = "openai",
LOCAL = "local",
GROK = "grok",
}
export enum AssignmentScope {