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
@@ -0,0 +1,333 @@
"""kimi_cli_config — config.toml (managed blocks + per-role permission rules
+ hooks) + mcp.json passthrough + AGENTS.md + the auth preflight."""
from __future__ import annotations
import json
import tomllib
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING
from roboco.llm.providers import kimi_cli_config as kc
if TYPE_CHECKING:
from pathlib import Path
import pytest
_SAMPLE_MCP = {
"mcpServers": {
"roboco-flow": {
"command": "uv",
"args": ["run", "--no-sync", "python", "-m", "roboco.mcp.flow_server"],
"env": {"ROBOCO_AGENT_ID": "be-dev-1", "ROBOCO_AGENT_TOKEN": "tok-123"},
},
"roboco-do": {"command": "uv", "args": ["run", "x"]},
}
}
# ---------------------------------------------------------------------------
# permission_rules_for_role — deny-only, role-scoped
# ---------------------------------------------------------------------------
def test_fleet_wide_denies_present_for_every_role() -> None:
for role in ("developer", "qa", "pr_reviewer", "main_pm", "unknown-role-xyz"):
rules = kc.permission_rules_for_role(role)
patterns = {r["pattern"] for r in rules}
for fleet_wide in kc._FLEET_WIDE_DENY:
assert fleet_wide in patterns
assert all(r["decision"] == "deny" for r in rules)
def test_bash_capable_role_keeps_bash_and_denies_git_destructive_pm() -> None:
rules = kc.permission_rules_for_role("developer")
patterns = {r["pattern"] for r in rules}
assert "Bash" not in patterns # bash-capable: no blanket deny
assert "Bash(git push*)" in patterns
assert "Bash(rm -rf*)" in patterns
assert "Bash(uv run*)" in patterns
# Developer writes code — no edit-tool deny.
assert "Write" not in patterns
assert "Edit" not in patterns
def test_non_bash_role_gets_blanket_bash_deny_and_no_command_scoped_rules() -> None:
rules = kc.permission_rules_for_role("pr_reviewer")
patterns = {r["pattern"] for r in rules}
assert "Bash" in patterns
assert "Bash(git push*)" not in patterns # blanket deny — nothing left to scope
# Read-only reviewer doesn't write code either.
assert "Write" in patterns
assert "Edit" in patterns
def test_main_pm_keeps_bash_but_denies_write_edit() -> None:
rules = kc.permission_rules_for_role("main_pm")
patterns = {r["pattern"] for r in rules}
assert "Bash" not in patterns # PM keeps its shell
assert "Bash(git push*)" in patterns
assert "Write" in patterns # PM doesn't write code
assert "Edit" in patterns
def test_unknown_role_gets_every_deny_category() -> None:
rules = kc.permission_rules_for_role("unknown-role-xyz")
patterns = {r["pattern"] for r in rules}
assert "Write" in patterns
assert "Edit" in patterns
assert "Bash" in patterns
# ---------------------------------------------------------------------------
# kimi_hooks_config
# ---------------------------------------------------------------------------
def test_kimi_hooks_config_wires_bash_guard_wrapper_no_env_field() -> None:
# A [[hooks]] entry with an `env` key gets the WHOLE hooks section
# silently dropped by the CLI (live-verified) — env delivery must ride
# the wrapper script's own export, never a rendered `env` field.
hooks = kc.kimi_hooks_config("/app/scripts/kimi-bash-guard-wrapper.sh")
assert len(hooks) == 1
hook = hooks[0]
assert hook["event"] == "PreToolUse"
assert hook["matcher"] == "Bash"
assert hook["command"] == "/app/scripts/kimi-bash-guard-wrapper.sh"
assert "env" not in hook
def test_kimi_hooks_config_default_points_at_wrapper() -> None:
hooks = kc.kimi_hooks_config()
assert hooks[0]["command"] == kc.KIMI_BASH_GUARD_WRAPPER
assert hooks[0]["command"].endswith("kimi-bash-guard-wrapper.sh")
def test_kimi_hooks_config_entries_only_carry_legal_keys() -> None:
# Pins the whole defect class: any future field addition to a rendered
# hook entry that isn't one of these four gets silently dropped by kimi.
legal_keys = {"event", "matcher", "command", "timeout"}
for hook in kc.kimi_hooks_config():
assert set(hook.keys()) <= legal_keys
# ---------------------------------------------------------------------------
# render_config_toml — valid TOML, managed blocks + telemetry/upgrade + rules
# ---------------------------------------------------------------------------
def test_render_config_toml_is_valid_toml() -> None:
parsed = tomllib.loads(kc.render_config_toml("developer"))
assert parsed["telemetry"] is False
assert parsed["upgrade"]["auto_install"] is False
def test_render_config_toml_managed_provider_block() -> None:
parsed = tomllib.loads(kc.render_config_toml("developer"))
provider = parsed["providers"]["managed:kimi-code"]
assert provider["type"] == "kimi"
assert provider["base_url"] == "https://api.kimi.com/coding/v1"
assert provider["oauth"]["storage"] == "file"
assert provider["oauth"]["key"] == "oauth/kimi-code"
def test_render_config_toml_carries_all_four_model_aliases() -> None:
parsed = tomllib.loads(kc.render_config_toml("developer"))
models = parsed["models"]
for alias in (
"kimi-code/k3",
"kimi-code/k3-256k",
"kimi-code/kimi-for-coding",
"kimi-code/kimi-for-coding-highspeed",
):
assert alias in models
assert models[alias]["provider"] == "managed:kimi-code"
assert models[alias]["max_context_size"] > 0
# The `model` value is the CLI-side managed name the wire sees —
# exactly the alias's last segment, never a raw API id like
# "kimi-k3" (a live-capture drift that would break every run).
assert models[alias]["model"] == alias.removeprefix("kimi-code/")
assert "thinking" in models[alias]["capabilities"]
# Only the K3 family exposes reasoning effort knobs.
assert models["kimi-code/k3"]["default_effort"] == "high"
assert "default_effort" not in models["kimi-code/kimi-for-coding"]
def test_render_config_toml_services_share_the_managed_oauth() -> None:
parsed = tomllib.loads(kc.render_config_toml("developer"))
for service in ("moonshot_search", "moonshot_fetch"):
assert parsed["services"][service]["oauth"]["key"] == "oauth/kimi-code"
def test_render_config_toml_permission_rules_vary_by_role() -> None:
# developer keeps its shell -> gets the full command-scoped git/destructive/
# raw-PM deny list underneath it; pr_reviewer's blanket Bash deny needs no
# command-scoped rules at all, so it ends up with FEWER total rules despite
# also denying Write/Edit on top of the fleet-wide set.
dev_rules = tomllib.loads(kc.render_config_toml("developer"))["permission"]["rules"]
reviewer_rules = tomllib.loads(kc.render_config_toml("pr_reviewer"))["permission"][
"rules"
]
assert len(dev_rules) > len(reviewer_rules)
def test_render_config_toml_hooks_present() -> None:
parsed = tomllib.loads(kc.render_config_toml("developer"))
assert parsed["hooks"][0]["event"] == "PreToolUse"
# ---------------------------------------------------------------------------
# render_mcp_json — near-passthrough of the mounted mcp-config.json
# ---------------------------------------------------------------------------
def test_render_mcp_json_injects_env_and_omits_empty_env() -> None:
rendered = json.loads(kc.render_mcp_json(_SAMPLE_MCP))
flow = rendered["mcpServers"]["roboco-flow"]
assert flow["command"] == "uv"
assert flow["args"][:2] == ["run", "--no-sync"]
assert flow["env"]["ROBOCO_AGENT_TOKEN"] == "tok-123"
assert "env" not in rendered["mcpServers"]["roboco-do"]
def test_render_mcp_json_empty_servers() -> None:
assert json.loads(kc.render_mcp_json({})) == {"mcpServers": {}}
# ---------------------------------------------------------------------------
# write_agents_md
# ---------------------------------------------------------------------------
def test_write_agents_md_installs_the_blueprint(tmp_path: Path) -> None:
src = tmp_path / "system-prompt.md"
src.write_text("You are a RoboCo backend developer.", encoding="utf-8")
dest = tmp_path / ".kimi-code" / "AGENTS.md"
assert kc.write_agents_md(source=src, dest=dest) is True
assert dest.read_text(encoding="utf-8") == "You are a RoboCo backend developer."
def test_write_agents_md_noops_when_source_absent(tmp_path: Path) -> None:
dest = tmp_path / ".kimi-code" / "AGENTS.md"
assert kc.write_agents_md(source=tmp_path / "absent.md", dest=dest) is False
assert not dest.exists()
# ---------------------------------------------------------------------------
# Auth preflight — a plain expires_at JSON field, no JWT decode
# ---------------------------------------------------------------------------
def _write_creds(path: Path, *, expires_at: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(
{
"access_token": "at",
"refresh_token": "rt",
"expires_at": expires_at,
"expires_in": 900,
"scope": "chat",
"token_type": "Bearer",
}
),
encoding="utf-8",
)
def test_is_valid_true_for_future_unix_timestamp(tmp_path: Path) -> None:
creds = tmp_path / "credentials" / "kimi-code.json"
future = datetime.now(UTC) + timedelta(minutes=10)
_write_creds(creds, expires_at=future.timestamp())
assert kc.is_valid(creds) is True
def test_is_valid_false_for_past_unix_timestamp(tmp_path: Path) -> None:
creds = tmp_path / "credentials" / "kimi-code.json"
past = datetime.now(UTC) - timedelta(minutes=10)
_write_creds(creds, expires_at=past.timestamp())
assert kc.is_valid(creds) is False
def test_is_valid_accepts_iso8601_string(tmp_path: Path) -> None:
creds = tmp_path / "credentials" / "kimi-code.json"
future = datetime.now(UTC) + timedelta(minutes=10)
_write_creds(creds, expires_at=future.isoformat())
assert kc.is_valid(creds) is True
def test_is_valid_false_for_missing_file(tmp_path: Path) -> None:
assert kc.is_valid(tmp_path / "credentials" / "kimi-code.json") is False
def test_is_valid_false_for_unparseable_expires_at(tmp_path: Path) -> None:
creds = tmp_path / "credentials" / "kimi-code.json"
_write_creds(creds, expires_at="not-a-timestamp")
assert kc.is_valid(creds) is False
def test_seconds_until_expiry_respects_skew(tmp_path: Path) -> None:
creds = tmp_path / "credentials" / "kimi-code.json"
soon = datetime.now(UTC) + timedelta(seconds=30)
_write_creds(creds, expires_at=soon.timestamp())
assert kc.is_valid(creds, skew_seconds=60) is False
assert kc.is_valid(creds, skew_seconds=0) is True
# ---------------------------------------------------------------------------
# main() — render mode + --check mode
# ---------------------------------------------------------------------------
def test_main_writes_config_mcp_and_agents_md(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
mcp_path = tmp_path / "mcp-config.json"
mcp_path.write_text(json.dumps(_SAMPLE_MCP), encoding="utf-8")
config_path = tmp_path / ".kimi-code" / "config.toml"
mcp_out_path = tmp_path / ".kimi-code" / "mcp.json"
agents_md_path = tmp_path / ".kimi-code" / "AGENTS.md"
system_prompt = tmp_path / "system-prompt.md"
system_prompt.write_text("blueprint", encoding="utf-8")
monkeypatch.setattr(kc, "KIMI_CONFIG_PATH", config_path)
monkeypatch.setattr(kc, "KIMI_MCP_PATH", mcp_out_path)
monkeypatch.setattr(kc, "KIMI_AGENTS_MD_PATH", agents_md_path)
monkeypatch.setattr(kc, "SYSTEM_PROMPT_PATH", system_prompt)
monkeypatch.setenv("ROBOCO_AGENT_ID", "be-dev-1")
monkeypatch.setenv("ROBOCO_MCP_CONFIG", str(mcp_path))
assert kc.main([]) == 0
parsed = tomllib.loads(config_path.read_text(encoding="utf-8"))
assert parsed["providers"]["managed:kimi-code"]["type"] == "kimi"
rendered_mcp = json.loads(mcp_out_path.read_text(encoding="utf-8"))
assert rendered_mcp["mcpServers"]["roboco-flow"]["env"]["ROBOCO_AGENT_TOKEN"] == (
"tok-123"
)
assert agents_md_path.read_text(encoding="utf-8") == "blueprint"
def test_main_check_flag_runs_preflight_without_rendering(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
creds = tmp_path / "credentials" / "kimi-code.json"
future = datetime.now(UTC) + timedelta(minutes=10)
_write_creds(creds, expires_at=future.timestamp())
config_path = tmp_path / ".kimi-code" / "config.toml"
monkeypatch.setattr(kc, "KIMI_CREDENTIALS_PATH", creds)
monkeypatch.setattr(kc, "KIMI_CONFIG_PATH", config_path)
assert kc.main(["--check"]) == 0
assert not config_path.exists() # --check never renders
def test_main_check_flag_fails_on_missing_credential(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
kc, "KIMI_CREDENTIALS_PATH", tmp_path / "credentials" / "kimi-code.json"
)
assert kc.main(["--check"]) == 1
@@ -0,0 +1,186 @@
"""kimi_cli_sniff — classify a Kimi run from ONLY its machine-relevant text.
The structural guarantee under test: the model's own on-topic prose (which
can legitimately contain the words "quota-limited" or a "429"/"401" substring
inside a commit hash / id) must NEVER reach the classifier, because
extraction only pulls a structured ``error`` field off error-bearing JSONL
events plus raw stderr — never ``role: assistant`` / ``role: tool`` content.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from roboco.llm.providers import kimi_cli_sniff as sniff
if TYPE_CHECKING:
from pathlib import Path
import pytest
def _write_jsonl(path: Path, lines: list[str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def _error_event(message: str) -> str:
return json.dumps({"type": "error", "error": {"message": message}})
# ---------------------------------------------------------------------------
# extract_error_text — structural isolation
# ---------------------------------------------------------------------------
def test_extract_error_text_pulls_only_structured_error_field(tmp_path: Path) -> None:
log = tmp_path / "run.jsonl"
_write_jsonl(
log,
[
json.dumps(
{
"role": "assistant",
"content": "the quota-limited rollout ships this sprint",
}
),
_error_event("real error text"),
],
)
assert sniff.extract_error_text(log) == "real error text"
def test_extract_error_text_accepts_bare_string_error() -> None:
assert sniff._error_text_from_event({"error": "bare string error"}) == (
"bare string error"
)
def test_extract_error_text_empty_for_missing_or_error_less_log(
tmp_path: Path,
) -> None:
assert sniff.extract_error_text(tmp_path / "nope.jsonl") == ""
log = tmp_path / "run.jsonl"
_write_jsonl(log, [json.dumps({"role": "assistant", "content": "hi"})])
assert sniff.extract_error_text(log) == ""
# ---------------------------------------------------------------------------
# The false-positive class this module exists to kill
# ---------------------------------------------------------------------------
def test_benign_transcript_never_false_parks(tmp_path: Path) -> None:
"""A transcript whose ONLY content is benign on-topic prose — mentioning
"quota-limited" work and a commit hash containing "429"/"401" — must
classify as "" (no park), because none of it lives in a structured error
field the extractor even looks at."""
log = tmp_path / "run.jsonl"
_write_jsonl(
log,
[
json.dumps(
{
"role": "assistant",
"content": (
"Fixed the quota-limited rollout gate. Committed as "
"abc4291f, also touched item 40199."
),
}
),
json.dumps({"role": "tool", "tool_call_id": "1", "content": "ok"}),
],
)
err_log = tmp_path / "run.err"
err_log.write_text("", encoding="utf-8")
assert sniff.classify(log, err_log) == ""
def test_word_boundary_prevents_429_substring_false_positive() -> None:
assert not sniff.is_rate_limited("commit abc14293 deployed to prod")
assert not sniff.is_rate_limited("fix4297abc landed")
def test_word_boundary_prevents_401_substring_false_positive() -> None:
assert not sniff.is_auth_failure("item 40199 was resolved")
assert not sniff.is_auth_failure("ticket 14012 closed")
# ---------------------------------------------------------------------------
# True positives — the live-verified error text shapes from the spike
# ---------------------------------------------------------------------------
def test_status_code_429_classifies_rate_limit(tmp_path: Path) -> None:
log = tmp_path / "run.jsonl"
_write_jsonl(log, [_error_event("request failed with status code: 429")])
assert sniff.classify(log) == "rate_limit"
def test_engine_overloaded_classifies_rate_limit(tmp_path: Path) -> None:
log = tmp_path / "run.jsonl"
_write_jsonl(log, [_error_event("the engine is currently overloaded")])
assert sniff.classify(log) == "rate_limit"
def test_usage_limit_for_period_classifies_rate_limit(tmp_path: Path) -> None:
log = tmp_path / "run.jsonl"
_write_jsonl(log, [_error_event("usage limit for this period exceeded")])
assert sniff.classify(log) == "rate_limit"
def test_usage_limit_for_billing_cycle_classifies_rate_limit(tmp_path: Path) -> None:
log = tmp_path / "run.jsonl"
_write_jsonl(log, [_error_event("usage limit for this billing cycle reached")])
assert sniff.classify(log) == "rate_limit"
def test_api_key_invalid_classifies_auth(tmp_path: Path) -> None:
log = tmp_path / "run.jsonl"
_write_jsonl(log, [_error_event("API Key appears to be invalid")])
assert sniff.classify(log) == "auth"
def test_membership_benefits_classifies_auth(tmp_path: Path) -> None:
log = tmp_path / "run.jsonl"
_write_jsonl(
log,
[_error_event("We're unable to verify your membership benefits at this time.")],
)
assert sniff.classify(log) == "auth"
def test_classify_reads_stderr_too(tmp_path: Path) -> None:
log = tmp_path / "run.jsonl"
_write_jsonl(log, [json.dumps({"role": "assistant", "content": "ok"})])
err_log = tmp_path / "run.err"
err_log.write_text("fatal: status code: 429\n", encoding="utf-8")
assert sniff.classify(log, err_log) == "rate_limit"
def test_classify_missing_files_returns_empty(tmp_path: Path) -> None:
assert sniff.classify(tmp_path / "nope.jsonl", tmp_path / "nope.err") == ""
def test_rate_limit_checked_before_auth_when_both_present(tmp_path: Path) -> None:
log = tmp_path / "run.jsonl"
_write_jsonl(
log,
[_error_event("status code: 429, and API Key appears to be invalid too")],
)
assert sniff.classify(log) == "rate_limit"
def test_main_cli_prints_classification(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
log = tmp_path / "run.jsonl"
_write_jsonl(log, [_error_event("status code: 429")])
assert sniff.main([str(log)]) == 0
assert capsys.readouterr().out.strip() == "rate_limit"
def test_main_cli_no_args_prints_empty(capsys: pytest.CaptureFixture[str]) -> None:
assert sniff.main([]) == 0
assert capsys.readouterr().out.strip() == ""
@@ -0,0 +1,264 @@
"""kimi_cli_usage — resolve the session dir from a run's stdout meta line (or
the newest matching session dir), then sum the real 4-bucket
``usage.record``/``usageScope=="turn"`` events in that session's wire.jsonl.
"""
from __future__ import annotations
import json
import time
from typing import TYPE_CHECKING
from roboco.llm.providers import kimi_cli_usage as ku
if TYPE_CHECKING:
from pathlib import Path
import pytest
def _write_jsonl(path: Path, lines: list[str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def _usage_record(
*,
input_other: int,
output: int,
cache_read: int = 0,
cache_creation: int = 0,
scope: str = "turn",
) -> str:
return json.dumps(
{
"type": "usage.record",
"model": "kimi-code/k3",
"usageScope": scope,
"usage": {
"inputOther": input_other,
"output": output,
"inputCacheRead": cache_read,
"inputCacheCreation": cache_creation,
},
}
)
def _resume_hint(session_id: str) -> str:
return json.dumps(
{"role": "meta", "type": "session.resume_hint", "session_id": session_id}
)
# ---------------------------------------------------------------------------
# session_id_from_run_log
# ---------------------------------------------------------------------------
def test_session_id_from_run_log_finds_terminal_meta_line(tmp_path: Path) -> None:
log = tmp_path / "run.jsonl"
_write_jsonl(
log,
[
json.dumps({"role": "assistant", "content": "working"}),
_resume_hint("session_abc123"),
],
)
assert ku.session_id_from_run_log(log) == "session_abc123"
def test_session_id_from_run_log_keeps_the_last_match(tmp_path: Path) -> None:
log = tmp_path / "run.jsonl"
_write_jsonl(log, [_resume_hint("session_first"), _resume_hint("session_second")])
assert ku.session_id_from_run_log(log) == "session_second"
def test_session_id_from_run_log_none_when_absent(tmp_path: Path) -> None:
log = tmp_path / "run.jsonl"
_write_jsonl(log, [json.dumps({"role": "assistant", "content": "hi"})])
assert ku.session_id_from_run_log(log) is None
assert ku.session_id_from_run_log(tmp_path / "nope.jsonl") is None
# ---------------------------------------------------------------------------
# resolve_session_dir — primary (known id) + fallback (newest under cwd basename)
# ---------------------------------------------------------------------------
def test_resolve_session_dir_finds_known_session_id(tmp_path: Path) -> None:
home = tmp_path / ".kimi-code"
session_dir = home / "sessions" / "wd_myrepo_ab12cd34ef56" / "session_abc123"
session_dir.mkdir(parents=True)
resolved = ku.resolve_session_dir(
session_id="session_abc123",
workdir="/data/workspaces/myrepo",
kimi_code_home=home,
)
assert resolved == session_dir
def test_resolve_session_dir_falls_back_to_newest(tmp_path: Path) -> None:
home = tmp_path / ".kimi-code"
wd_dir = home / "sessions" / "wd_myrepo_ab12cd34ef56"
old_session = wd_dir / "session_old"
new_session = wd_dir / "session_new"
old_session.mkdir(parents=True)
time.sleep(0.01)
new_session.mkdir(parents=True)
resolved = ku.resolve_session_dir(
session_id=None, workdir="/data/workspaces/myrepo", kimi_code_home=home
)
assert resolved == new_session
def test_resolve_session_dir_none_when_sessions_root_absent(tmp_path: Path) -> None:
home = tmp_path / ".kimi-code"
assert (
ku.resolve_session_dir(
session_id=None, workdir="/x/myrepo", kimi_code_home=home
)
is None
)
def test_resolve_session_dir_falls_back_when_id_not_found(tmp_path: Path) -> None:
home = tmp_path / ".kimi-code"
wd_dir = home / "sessions" / "wd_myrepo_ab12cd34ef56"
only_session = wd_dir / "session_other"
only_session.mkdir(parents=True)
resolved = ku.resolve_session_dir(
session_id="session_missing",
workdir="/data/workspaces/myrepo",
kimi_code_home=home,
)
assert resolved == only_session
# ---------------------------------------------------------------------------
# aggregate_usage_from_wire
# ---------------------------------------------------------------------------
def test_aggregate_sums_turn_scoped_usage_records(tmp_path: Path) -> None:
wire = tmp_path / "wire.jsonl"
_write_jsonl(
wire,
[
_usage_record(input_other=100, output=50, cache_read=10),
json.dumps({"type": "llm.request", "model": "kimi-code/k3"}),
_usage_record(input_other=200, output=80, cache_read=20, cache_creation=5),
],
)
agg = ku.aggregate_usage_from_wire(wire)
assert agg["inputOther"] == 300 # noqa: PLR2004
assert agg["output"] == 130 # noqa: PLR2004
assert agg["inputCacheRead"] == 30 # noqa: PLR2004
assert agg["inputCacheCreation"] == 5 # noqa: PLR2004
assert agg["turns"] == 2 # noqa: PLR2004
def test_aggregate_ignores_non_turn_scope_and_bad_lines(tmp_path: Path) -> None:
wire = tmp_path / "wire.jsonl"
_write_jsonl(
wire,
[
"not json",
_usage_record(input_other=5, output=1, scope="session"),
_usage_record(input_other=10, output=5),
],
)
agg = ku.aggregate_usage_from_wire(wire)
assert agg["inputOther"] == 10 # noqa: PLR2004
assert agg["turns"] == 1
def test_aggregate_zero_for_missing_log(tmp_path: Path) -> None:
agg = ku.aggregate_usage_from_wire(tmp_path / "nope.jsonl")
assert agg["turns"] == 0
assert all(v == 0 for k, v in agg.items() if k != "turns")
# ---------------------------------------------------------------------------
# capture_run_usage / main
# ---------------------------------------------------------------------------
def test_capture_run_usage_writes_usage_json(tmp_path: Path) -> None:
home = tmp_path / ".kimi-code"
session_dir = home / "sessions" / "wd_myrepo_hash1" / "session_abc"
(session_dir / "agents" / "main").mkdir(parents=True)
wire = session_dir / "agents" / "main" / "wire.jsonl"
_write_jsonl(wire, [_usage_record(input_other=100, output=50, cache_read=10)])
run_log = tmp_path / "run.jsonl"
_write_jsonl(run_log, [_resume_hint("session_abc")])
out = tmp_path / "usage.json"
tokens = ku.capture_run_usage(
run_log=run_log,
workdir="/data/workspaces/myrepo",
model="kimi-code/k3",
out_path=out,
kimi_code_home=home,
)
assert tokens == (100, 50, 10, 0)
data = json.loads(out.read_text())
assert data["model"] == "kimi-code/k3"
assert data["tokens_input"] == 100 # noqa: PLR2004
assert data["tokens_output"] == 50 # noqa: PLR2004
assert data["tokens_cache_read"] == 10 # noqa: PLR2004
assert data["turns"] == 1
assert data["cost_usd"] > 0.0
def test_capture_run_usage_zero_when_no_session_found(tmp_path: Path) -> None:
home = tmp_path / ".kimi-code"
run_log = tmp_path / "run.jsonl"
run_log.write_text("", encoding="utf-8")
out = tmp_path / "usage.json"
tokens = ku.capture_run_usage(
run_log=run_log,
workdir="/data/workspaces/myrepo",
model="kimi-code/k3",
out_path=out,
kimi_code_home=home,
)
assert tokens == (0, 0, 0, 0)
data = json.loads(out.read_text())
assert data["tokens_input"] == 0
assert data["turns"] == 0
def test_main_writes_usage_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
home = tmp_path / ".kimi-code"
session_dir = home / "sessions" / "wd_myrepo_hash1" / "session_abc"
(session_dir / "agents" / "main").mkdir(parents=True)
wire = session_dir / "agents" / "main" / "wire.jsonl"
_write_jsonl(wire, [_usage_record(input_other=200, output=100)])
run_log = tmp_path / "run.jsonl"
_write_jsonl(run_log, [_resume_hint("session_abc")])
out = tmp_path / "usage.json"
monkeypatch.setattr(ku, "USAGE_OUT_PATH", out)
monkeypatch.setattr(ku, "KIMI_CODE_HOME", home)
monkeypatch.setenv("ROBOCO_KIMI_RUN_LOG", str(run_log))
monkeypatch.setenv("ROBOCO_KIMI_WORKDIR", "/data/workspaces/myrepo")
monkeypatch.setenv("ROBOCO_AGENT_MODEL", "kimi-code/k3")
assert ku.main() == 0
data = json.loads(out.read_text())
assert data["tokens_input"] == 200 # noqa: PLR2004
assert data["tokens_output"] == 100 # noqa: PLR2004
def test_main_warns_when_run_log_env_missing(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
monkeypatch.delenv("ROBOCO_KIMI_RUN_LOG", raising=False)
with caplog.at_level("WARNING", logger="roboco.llm.providers.kimi_cli_usage"):
assert ku.main() == 0
assert any("ROBOCO_KIMI_RUN_LOG" in r.message for r in caplog.records)
+164
View File
@@ -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
# ---------------------------------------------------------------------------