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:
Renzo F
2026-07-29 01:48:55 +02:00
committed by GitHub
co-authored by Renn F
parent eb470dfb33
commit 6374bbbed0
43 changed files with 3907 additions and 110 deletions
@@ -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&apos;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
+1
View File
@@ -35,6 +35,7 @@ export type RoutingMode =
| "grok"
| "codex"
| "gemini"
| "kimi"
| "ollama"
| "self_hosted"
| "mix"
+1
View File
@@ -107,6 +107,7 @@ export enum ModelProvider {
LOCAL = "local",
GROK = "grok",
GEMINI = "gemini",
KIMI = "kimi",
}
export enum AssignmentScope {