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:
@@ -21,6 +21,7 @@ from roboco.llm.providers import (
|
||||
ClaudeCodeProvider,
|
||||
CodexCliProvider,
|
||||
GrokCliProvider,
|
||||
KimiCliProvider,
|
||||
ProviderError,
|
||||
ProviderNotRegisteredError,
|
||||
ProviderRegistry,
|
||||
@@ -48,6 +49,14 @@ def _isolate_codex_auth(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path
|
||||
return codex_dir
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_kimi_auth(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Point KIMI_AUTH_HOST_PATH at a fresh tmp dir (parity with codex above)."""
|
||||
kimi_dir = tmp_path / "kimi-auth"
|
||||
monkeypatch.setattr("roboco.llm.providers.kimi.KIMI_AUTH_HOST_PATH", str(kimi_dir))
|
||||
return kimi_dir
|
||||
|
||||
|
||||
def _config(
|
||||
*,
|
||||
agent_id: str = "be-dev-1",
|
||||
@@ -99,6 +108,9 @@ class _FakeHost:
|
||||
def _ensure_codex_usage_dir(self, agent_id: str) -> None:
|
||||
self.data_dirs_ensured.append(agent_id)
|
||||
|
||||
def _ensure_kimi_usage_dir(self, agent_id: str) -> None:
|
||||
self.data_dirs_ensured.append(agent_id)
|
||||
|
||||
def _resolve_host_paths(
|
||||
self, config: OrchestratorAgentConfig, agent_settings_path: Path | None
|
||||
) -> dict[str, str | None]:
|
||||
@@ -109,6 +121,7 @@ class _FakeHost:
|
||||
"settings": str(agent_settings_path) if agent_settings_path else None,
|
||||
"grok_usage": f"/host/data/grok-usage/{config.agent_id}",
|
||||
"codex_usage": f"/host/data/codex-usage/{config.agent_id}",
|
||||
"kimi_usage": f"/host/data/kimi-usage/{config.agent_id}",
|
||||
}
|
||||
|
||||
def _build_mount_args(
|
||||
@@ -477,6 +490,157 @@ async def test_codex_spawn_raises_on_docker_failure() -> None:
|
||||
await provider.spawn(_codex_config())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# KimiCliProvider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _kimi_config(
|
||||
*,
|
||||
agent_id: str = "be-dev-1",
|
||||
provider_base_url: str | None = "https://api.x.ai/v1",
|
||||
provider_auth_token: str | None = "should-not-leak",
|
||||
mcp_config_path: Path | None = Path("/host/mcp-configs/be-dev-1.json"),
|
||||
) -> OrchestratorAgentConfig:
|
||||
return OrchestratorAgentConfig(
|
||||
agent_id=agent_id,
|
||||
blueprint_path=Path("/app/system-prompt.md"),
|
||||
model="kimi-code/k3",
|
||||
mcp_config_path=mcp_config_path,
|
||||
claude_session_id="sess-1",
|
||||
provider_type="kimi",
|
||||
provider_base_url=provider_base_url,
|
||||
provider_auth_token=provider_auth_token,
|
||||
)
|
||||
|
||||
|
||||
async def test_kimi_spawn_requires_mcp_config() -> None:
|
||||
provider = KimiCliProvider(_FakeHost())
|
||||
with pytest.raises(ProviderError, match="MCP config"):
|
||||
await provider.spawn(_kimi_config(mcp_config_path=None))
|
||||
|
||||
|
||||
async def test_kimi_spawn_does_not_require_api_key() -> None:
|
||||
# Subscription auth (mounted ~/.kimi-code) — a missing provider key is fine.
|
||||
host = _FakeHost()
|
||||
provider = KimiCliProvider(host)
|
||||
with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())):
|
||||
result = await provider.spawn(_kimi_config(provider_auth_token=None))
|
||||
assert result.instance_id == "roboco-agent-be-dev-1"
|
||||
|
||||
|
||||
async def test_kimi_spawn_no_leaked_key_and_no_anthropic_leak() -> None:
|
||||
host = _FakeHost()
|
||||
provider = KimiCliProvider(host, image="roboco-agent-kimi:test")
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
|
||||
) as exec_mock:
|
||||
await provider.spawn(_kimi_config(), initial_prompt="do the work")
|
||||
cmd = list(exec_mock.call_args.args)
|
||||
assert not any(c.startswith("MOONSHOT_API_KEY=") for c in cmd)
|
||||
# The provider endpoint must NOT be injected as an Anthropic var.
|
||||
assert not any(c.startswith("ANTHROPIC_BASE_URL=") for c in cmd)
|
||||
assert not any(c.startswith("ANTHROPIC_AUTH_TOKEN=") for c in cmd)
|
||||
assert host.mount_config is not None
|
||||
assert host.mount_config.provider_base_url is None
|
||||
assert host.mount_config.provider_auth_token is None
|
||||
|
||||
|
||||
async def test_kimi_spawn_wires_gateway_env_and_image_last() -> None:
|
||||
host = _FakeHost()
|
||||
provider = KimiCliProvider(host, image="roboco-agent-kimi:test")
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
|
||||
) as exec_mock:
|
||||
result = await provider.spawn(_kimi_config())
|
||||
cmd = list(exec_mock.call_args.args)
|
||||
assert "ROBOCO_MCP_CONFIG=/app/mcp-config.json" in cmd
|
||||
assert "ROBOCO_AGENT_ID=be-dev-1" in cmd
|
||||
assert "ROBOCO_AGENT_MODEL=kimi-code/k3" in cmd
|
||||
# Usage capture: per-agent data dir mounted + the entrypoint's usage file.
|
||||
assert host.data_dirs_ensured == ["be-dev-1"]
|
||||
assert "/host/data/kimi-usage/be-dev-1:/home/agent/.kimi-usage" in cmd
|
||||
assert "ROBOCO_KIMI_USAGE_FILE=/home/agent/.kimi-usage/usage.json" in cmd
|
||||
assert "ROBOCO_AGENT_TOKEN=hmac-be-dev-1" in cmd
|
||||
assert cmd[-1] == "roboco-agent-kimi:test"
|
||||
assert host.removed == ["roboco-agent-be-dev-1"]
|
||||
assert host.remove_stop_reasons == ["pre_spawn_stale_clear"]
|
||||
assert result == SpawnResult(
|
||||
instance_id="roboco-agent-be-dev-1",
|
||||
extra={"container_id": "cid", "model": "kimi-code/k3"},
|
||||
)
|
||||
|
||||
|
||||
async def test_kimi_spawn_mounts_auth_when_present(_isolate_kimi_auth: Path) -> None:
|
||||
creds_dir = _isolate_kimi_auth / "credentials"
|
||||
creds_dir.mkdir(parents=True, exist_ok=True)
|
||||
(creds_dir / "kimi-code.json").write_text("{}", encoding="utf-8")
|
||||
host = _FakeHost()
|
||||
provider = KimiCliProvider(host)
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
|
||||
) as exec_mock:
|
||||
await provider.spawn(_kimi_config())
|
||||
cmd = list(exec_mock.call_args.args)
|
||||
# Mount the host ~/.kimi-code DIRECTORY read-write (rotation-with-grace,
|
||||
# not truly reusable — every container must share ONE chain with the
|
||||
# host, not a private copy) — the entrypoint symlinks credentials/ and
|
||||
# oauth/ forward into a container-local, writable ~/.kimi-code.
|
||||
expected = f"{_isolate_kimi_auth}:/home/agent/.kimi-code-auth"
|
||||
assert expected in cmd
|
||||
|
||||
|
||||
async def test_kimi_spawn_omits_auth_mount_when_absent() -> None:
|
||||
host = _FakeHost()
|
||||
provider = KimiCliProvider(host)
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
|
||||
) as exec_mock:
|
||||
await provider.spawn(_kimi_config())
|
||||
cmd = list(exec_mock.call_args.args)
|
||||
assert not any("/home/agent/.kimi-code-auth" in c for c in cmd)
|
||||
|
||||
|
||||
async def test_kimi_spawn_warns_when_auth_absent(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
caplog.set_level("WARNING", logger="roboco.llm.providers.kimi")
|
||||
host = _FakeHost()
|
||||
provider = KimiCliProvider(host)
|
||||
with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())):
|
||||
await provider.spawn(_kimi_config())
|
||||
warnings = [r for r in caplog.records if r.levelname == "WARNING"]
|
||||
assert warnings, "expected a spawn-time WARNING for the missing host credential"
|
||||
msg = warnings[0].getMessage()
|
||||
assert "kimi-code.json" in msg
|
||||
assert "kimi login" in msg
|
||||
|
||||
|
||||
async def test_kimi_spawn_prompt_is_injection_safe() -> None:
|
||||
host = _FakeHost()
|
||||
provider = KimiCliProvider(host)
|
||||
nasty = "--model evil --session-id pwned"
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
|
||||
) as exec_mock:
|
||||
await provider.spawn(_kimi_config(), initial_prompt=nasty)
|
||||
cmd = list(exec_mock.call_args.args)
|
||||
assert f"ROBOCO_INITIAL_PROMPT={nasty}" in cmd
|
||||
assert nasty not in cmd
|
||||
|
||||
|
||||
async def test_kimi_spawn_raises_on_docker_failure() -> None:
|
||||
provider = KimiCliProvider(_FakeHost())
|
||||
with (
|
||||
patch(
|
||||
"asyncio.create_subprocess_exec",
|
||||
AsyncMock(return_value=_proc(returncode=1, stderr=b"boom")),
|
||||
),
|
||||
pytest.raises(ProviderError, match="boom"),
|
||||
):
|
||||
await provider.spawn(_kimi_config())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ClaudeCodeProvider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user