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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user