mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(kimi): Kimi K3 provider on the official kimi-code CLI (#713)
* feat(kimi): Kimi K3 provider on the official kimi-code CLI (Wave 1) ModelProvider.KIMI routes through KimiCliProvider driving Moonshot's kimi CLI on a Kimi subscription (OAuth device-code, no metered key). One-shot delivery roles only (V1), interactive ban wired in both guard lists. Auth: one shared RW auth mount; containers symlink credentials/ and oauth/ (the CLI's cross-process refresh-lock dir) into a container-local KIMI_CODE_HOME so every container and the host redeem the SAME rotating refresh chain - live-verified that per-copy chains cross-invalidate after the reuse-grace window. No orchestrator refresh daemon; an expires_at preflight exits 78. Config renderer mirrors the login-managed provider/model blocks field-for-field (live-captured; the model value is the CLI-side name, never the raw API id), plus per-role deny rules and the bash-guard as a PreToolUse hook via a wrapper script (an env key on a hooks entry makes the CLI silently drop ALL hooks - live-verified). Usage capture sums wire.jsonl usage.record 4-bucket events; sniff classifies rate-limit/auth from structured error text only, mapped to the shared 75/78 park contract. Image installs the CLI latest-at-build (no version pin, by policy) with the resolved version stamped as provenance, binary split to /usr/local away from mutable state. Migrations 090 (enum) + 091 (provider seed); catalog, pricing, routing mode, and orchestrator park/usage wiring mirror the codex integration. * feat(kimi): surface sweep + fleet-wide pin drop (Wave 2) Compose x3 gain the agent-kimi-image service and the orchestrator's read-write ~/.kimi-code mount + kimi-usage dir; .env.example documents the Kimi block. Panel mirrors ModelProvider.KIMI and adds the kimi routing mode (catalog filter, mode button, mix-picker group, badge) with tests; provider routes gain the kimi remediation entry. CLAUDE.md and docs/map document the runtime. Per the no-pins policy, agent-grok/ gemini/codex Dockerfiles drop their version pins for latest-at-build with resolved-version provenance stamps (grok resolves 0.2.112 vs the old 0.2.56 pin - verified by real builds of all four images). --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -52,6 +52,11 @@ const {
|
||||
provider_type: "gemini",
|
||||
display_name: "Gemini 2.5 Pro",
|
||||
},
|
||||
{
|
||||
model_name: "kimi-code/k3",
|
||||
provider_type: "kimi",
|
||||
display_name: "Kimi K3",
|
||||
},
|
||||
]),
|
||||
getOllamaKey: vi.fn(async () => ({ has_key: false, enabled: true })),
|
||||
setOllamaKey: vi.fn(async () => ({ has_key: true, enabled: true })),
|
||||
@@ -893,6 +898,64 @@ describe("AIRoutingCard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kimi mode button", () => {
|
||||
it("renders the Kimi button and applies mode='kimi' on confirm", async () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
render(withQueryClient(<AIRoutingCard />));
|
||||
await screen.findByText("Grok (xAI) API key");
|
||||
|
||||
fireEvent.click(screen.getByText("Kimi"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(applyMode).toHaveBeenCalledWith({ mode: "kimi" }),
|
||||
);
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("is not gated on a key (no key card exists for Kimi)", async () => {
|
||||
render(withQueryClient(<AIRoutingCard />));
|
||||
await screen.findByText("Grok (xAI) API key");
|
||||
|
||||
expect(screen.getByText("Kimi").closest("button")).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mix picker Kimi group visibility", () => {
|
||||
it("shows the Kimi provider group for a delivery role's per-agent select", async () => {
|
||||
render(withQueryClient(<AIRoutingCard />));
|
||||
await screen.findByText("Per-agent override (mix mode)");
|
||||
|
||||
const beDevRow = mixRowFor("be-dev-1");
|
||||
expect(
|
||||
await within(beDevRow).findByText("Kimi (Moonshot)"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("excludes Kimi from the Intake/Secretary/PR Review group", async () => {
|
||||
render(withQueryClient(<AIRoutingCard />));
|
||||
await screen.findByText("Per-agent override (mix mode)");
|
||||
|
||||
// Wait for the catalog query to resolve (an unrelated row's groups)
|
||||
// before asserting absence on this group's rows below.
|
||||
await within(mixRowFor("be-dev-1")).findByText("Kimi (Moonshot)");
|
||||
|
||||
const secretaryRow = mixRowFor("secretary-1");
|
||||
expect(
|
||||
within(secretaryRow).queryByText("Kimi (Moonshot)"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const intakeRow = mixRowFor("intake-1");
|
||||
expect(
|
||||
within(intakeRow).queryByText("Kimi (Moonshot)"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const prReviewerRow = mixRowFor("pr-reviewer-1");
|
||||
expect(
|
||||
within(prReviewerRow).queryByText("Kimi (Moonshot)"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mix picker Codex/Gemini group visibility", () => {
|
||||
it("shows Codex and Gemini provider groups for a delivery role's per-agent select", async () => {
|
||||
render(withQueryClient(<AIRoutingCard />));
|
||||
@@ -913,7 +976,7 @@ describe("AIRoutingCard", () => {
|
||||
await screen.findByText("Per-agent override (mix mode)");
|
||||
|
||||
expect(
|
||||
screen.getByText(/Codex and Gemini are delivery-roles-only/i),
|
||||
screen.getByText(/Codex, Gemini, and Kimi are delivery-roles-only/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Wait for the catalog query to resolve (an unrelated row's groups)
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
Gem,
|
||||
Key,
|
||||
KeyRound,
|
||||
Moon,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
@@ -117,10 +118,11 @@ const AGENT_GROUP_DEFS: {
|
||||
},
|
||||
];
|
||||
|
||||
// Codex/Gemini are V1 delivery-roles-only — no interactive Intake/Secretary
|
||||
// support (see roboco.llm.providers.codex / .gemini). This group's per-agent
|
||||
// picker excludes both providers below instead of offering a route that
|
||||
// would silently misroute the persistent Intake/Secretary session at spawn.
|
||||
// Codex/Gemini/Kimi are V1 delivery-roles-only — no interactive Intake/
|
||||
// Secretary support (see roboco.llm.providers.codex / .gemini / .kimi). This
|
||||
// group's per-agent picker excludes all three providers below instead of
|
||||
// offering a route that would silently misroute the persistent Intake/
|
||||
// Secretary session at spawn.
|
||||
const INTERACTIVE_ONLY_GROUP_TITLE = "Intake / Secretary / PR Review";
|
||||
|
||||
// Stable within-group ordering (PM/lead first, devs, QA, doc, reviewer last)
|
||||
@@ -296,6 +298,10 @@ export function AIRoutingCard() {
|
||||
(c: { provider_type: ModelProvider }) =>
|
||||
c.provider_type === ModelProvider.GEMINI,
|
||||
);
|
||||
const catalogKimiOnly = catalog.filter(
|
||||
(c: { provider_type: ModelProvider }) =>
|
||||
c.provider_type === ModelProvider.KIMI,
|
||||
);
|
||||
const catalogAnthropicOnly = catalog.filter(
|
||||
(c: { provider_type: ModelProvider }) =>
|
||||
c.provider_type === ModelProvider.ANTHROPIC,
|
||||
@@ -382,6 +388,26 @@ export function AIRoutingCard() {
|
||||
}
|
||||
};
|
||||
|
||||
const flipToKimi = async () => {
|
||||
if (
|
||||
!confirm(
|
||||
"Switch every agent to Kimi? Per-agent pins and complexity " +
|
||||
"overrides are kept; other role/global assignments are replaced. " +
|
||||
"Intake and Secretary stay on Anthropic (Kimi has no interactive " +
|
||||
"chat support).",
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await applyMode.mutateAsync({ mode: "kimi" });
|
||||
toast.success(
|
||||
"Role/global routing now on Kimi — pins/overrides kept, Intake & Secretary stay on Anthropic",
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error("Switch failed: " + errMsg(e));
|
||||
}
|
||||
};
|
||||
|
||||
const flipToOllama = async () => {
|
||||
if (!hasOllamaKey) {
|
||||
toast.error("Save an Ollama API key first");
|
||||
@@ -720,6 +746,23 @@ export function AIRoutingCard() {
|
||||
</SelectGroup>
|
||||
)}
|
||||
|
||||
{/* Kimi (Moonshot) models — excluded for the interactive-only group */}
|
||||
{!restrictInteractiveOnly && catalogKimiOnly.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>
|
||||
<ProviderBadge variant="kimi" />
|
||||
Kimi (Moonshot)
|
||||
</SelectLabel>
|
||||
{catalogKimiOnly.map(
|
||||
(c: { model_name: string; display_name: string }) => (
|
||||
<SelectItem key={c.model_name} value={c.model_name}>
|
||||
{c.display_name}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectGroup>
|
||||
)}
|
||||
|
||||
{/* Ollama Cloud models */}
|
||||
{catalogOllamaOnly.length > 0 && (
|
||||
<SelectGroup>
|
||||
@@ -773,9 +816,9 @@ export function AIRoutingCard() {
|
||||
<CardDescription>
|
||||
Decide which model backs each agent. Anthropic uses the mounted
|
||||
<code className="px-1"> ~/.claude </code> auth; Grok (xAI) and Ollama
|
||||
Cloud use the API keys you save below; Codex and Gemini authenticate
|
||||
via their own mounted CLI subscriptions (no key needed) — V1:
|
||||
delivery roles only, not Intake/Secretary; Self-Hosted connects to
|
||||
Cloud use the API keys you save below; Codex, Gemini, and Kimi
|
||||
authenticate via their own mounted CLI subscriptions (no key needed) —
|
||||
V1: delivery roles only, not Intake/Secretary; Self-Hosted connects to
|
||||
any OpenAI-compatible endpoint you run locally.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -915,10 +958,10 @@ export function AIRoutingCard() {
|
||||
|
||||
{/* -------- Mode toggle -------- */}
|
||||
<section className="space-y-3">
|
||||
<HelpTip label="Anthropic / Grok / Codex / Gemini / Ollama / Self-Hosted replace role/global routing with that provider; per-agent pins in the table below survive the switch. Mix keeps whatever's picked in the table.">
|
||||
<HelpTip label="Anthropic / Grok / Codex / Gemini / Kimi / Ollama / Self-Hosted replace role/global routing with that provider; per-agent pins in the table below survive the switch. Mix keeps whatever's picked in the table.">
|
||||
<Label className="text-sm font-medium">Routing mode</Label>
|
||||
</HelpTip>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-2">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-9 gap-2">
|
||||
<ModeButton
|
||||
icon={<ShieldCheck className="h-4 w-4" />}
|
||||
label="Anthropic"
|
||||
@@ -957,6 +1000,15 @@ export function AIRoutingCard() {
|
||||
disabled={applyMode.isPending}
|
||||
labelHint="Gemini authenticates via a mounted ~/.gemini OAuth login (no API key) — always available once the CLI is logged in on the host. V1: delivery roles only, not offered for Intake/Secretary."
|
||||
/>
|
||||
<ModeButton
|
||||
icon={<Moon className="h-4 w-4" />}
|
||||
label="Kimi"
|
||||
description="Every agent uses Kimi (kimi-code/k3)."
|
||||
active={currentMode === "kimi"}
|
||||
onClick={flipToKimi}
|
||||
disabled={applyMode.isPending}
|
||||
labelHint="Kimi authenticates via a shared, symlinked-in ~/.kimi-code subscription credential (Moonshot, no API key) — always available once the CLI is logged in on the host. V1: delivery roles only, not offered for Intake/Secretary."
|
||||
/>
|
||||
<ModeButton
|
||||
icon={<Sparkles className="h-4 w-4" />}
|
||||
label="Ollama"
|
||||
@@ -1042,6 +1094,14 @@ export function AIRoutingCard() {
|
||||
roles only — not available for Intake/Secretary.
|
||||
</p>
|
||||
) : null}
|
||||
{currentMode === "kimi" || currentMode === "mix" ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Kimi agents run on Moonshot's official kimi (kimi-code) CLI
|
||||
(subscription auth, shared ~/.kimi-code credential); the same
|
||||
guards apply. V1: delivery roles only — not available for
|
||||
Intake/Secretary.
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* -------- Self-Hosted model picker (when self_hosted mode active) -------- */}
|
||||
@@ -1237,8 +1297,9 @@ export function AIRoutingCard() {
|
||||
</HelpTip>
|
||||
{restrictInteractiveOnly ? (
|
||||
<p className="mb-2 text-[11px] text-muted-foreground">
|
||||
Codex and Gemini are delivery-roles-only (V1) — not
|
||||
offered here (no interactive Intake/Secretary support).
|
||||
Codex, Gemini, and Kimi are delivery-roles-only (V1) —
|
||||
not offered here (no interactive Intake/Secretary
|
||||
support).
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid grid-cols-1 gap-x-8 gap-y-3 sm:grid-cols-2">
|
||||
@@ -1431,6 +1492,7 @@ function ProviderBadge({
|
||||
| "grok"
|
||||
| "openai"
|
||||
| "gemini"
|
||||
| "kimi"
|
||||
| "ollama"
|
||||
| "self-hosted";
|
||||
}) {
|
||||
@@ -1441,6 +1503,7 @@ function ProviderBadge({
|
||||
grok: "bg-teal-500/20 text-teal-700 dark:text-teal-400",
|
||||
openai: "bg-emerald-500/20 text-emerald-700 dark:text-emerald-400",
|
||||
gemini: "bg-sky-500/20 text-sky-700 dark:text-sky-400",
|
||||
kimi: "bg-amber-500/20 text-amber-700 dark:text-amber-400",
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
anthropic: "A",
|
||||
@@ -1449,6 +1512,7 @@ function ProviderBadge({
|
||||
grok: "G",
|
||||
openai: "C",
|
||||
gemini: "Ge",
|
||||
kimi: "K",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
|
||||
@@ -35,6 +35,7 @@ export type RoutingMode =
|
||||
| "grok"
|
||||
| "codex"
|
||||
| "gemini"
|
||||
| "kimi"
|
||||
| "ollama"
|
||||
| "self_hosted"
|
||||
| "mix"
|
||||
|
||||
@@ -107,6 +107,7 @@ export enum ModelProvider {
|
||||
LOCAL = "local",
|
||||
GROK = "grok",
|
||||
GEMINI = "gemini",
|
||||
KIMI = "kimi",
|
||||
}
|
||||
|
||||
export enum AssignmentScope {
|
||||
|
||||
Reference in New Issue
Block a user