feat(fleet): Kimi as a microVM backend — the URL settled by measurement
The base URL took three measurements to find, and the first two were wrong in instructive ways. `api.moonshot.ai/anthropic/v1/messages` EXISTS and speaks the protocol — it answers with Moonshot's own structured error rather than a 404. It also rejects an `sk-kimi-` key, because it belongs to the platform.moonshot.ai account namespace. Two endpoints that both "work" for different accounts is precisely the shape that makes a guessed URL look like a broken key, and it is why this was refused rather than guessed for as long as it was. The Kimi CODE service is the one an `sk-kimi-` key belongs to: `POST https://api.kimi.com/coding/v1/messages` returns a real Anthropic Messages body — `msg_` id, `content` blocks, a `thinking` block with a signature. So `ANTHROPIC_BASE_URL=https://api.kimi.com/coding`, WITHOUT the `/v1`: Claude Code appends `/v1/messages` itself, and `/v1/v1/messages` would 404 in a way that reads as a broken image rather than a bad URL. Two more measured, each otherwise a silent failure at the first turn: `Authorization: Bearer` is accepted (so ANTHROPIC_AUTH_TOKEN is the right injection channel), and a `claude-*` model id is ACCEPTED AND ANSWERED — Kimi maps it onto `kimi-for-coding` exactly as z.ai does, so no ANTHROPIC_MODEL override is needed. Claude Code rather than Moonshot's own `kimi` CLI, deliberately. The mission harness is Claude-Code-shaped throughout: `--agents` JSON roles, the verifier's tool allowlist, the `Stop` hook behind the completion gate, the per-subagent transcripts counted as delegation evidence. `kimi` has none of those flags — its equivalents are TOML files and markdown agent dirs — so using it would mean a second executor with its own untested failure modes. TWO STALE MAPS, caught by the rootfs harness refusing to bless the image: both `fc-build-rootfs.sh` and the node's `required_cli` expected backend `kimi` to contain Moonshot's `kimi` binary. That assumption predates the measurement, and it failed a rootfs that was correct. Both now say `claude` for glm and kimi alike — the binary is the same in all three images; only the endpoint differs. Egress for `kimi` is `api.kimi.com` alone: not moonshot.ai (wrong namespace), not z.ai, not Anthropic. Asserted both ways, like the other two. The image and rootfs are built on tank and the rootfs passes all four checks (boots, git, writable /mission, `claude --version`). 534 tests, clippy clean. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d3a53e7bf1
commit
742724e53c
@@ -66,6 +66,11 @@ fn provider_hosts(backend: Option<&str>) -> &'static [&'static str] {
|
|||||||
&["api.anthropic.com", ".anthropic.com"]
|
&["api.anthropic.com", ".anthropic.com"]
|
||||||
}
|
}
|
||||||
Some("glm") => &["api.z.ai"],
|
Some("glm") => &["api.z.ai"],
|
||||||
|
// The Kimi CODE service, which is where an `sk-kimi-` key is valid —
|
||||||
|
// NOT `api.moonshot.ai`, whose Anthropic endpoint exists but belongs to
|
||||||
|
// a different account namespace and rejects that key. Only the host the
|
||||||
|
// `agent-kimi` image bakes in.
|
||||||
|
Some("kimi") => &["api.kimi.com"],
|
||||||
// Fail closed: a backend nobody taught this function about reaches the
|
// Fail closed: a backend nobody taught this function about reaches the
|
||||||
// forge and no model API. It cannot silently borrow another provider's
|
// forge and no model API. It cannot silently borrow another provider's
|
||||||
// door, which is the failure this split exists to prevent.
|
// door, which is the failure this split exists to prevent.
|
||||||
@@ -373,6 +378,12 @@ mod tests {
|
|||||||
assert!(l.iter().any(|h| h == "git.redclaw.dev"), "{l:?}");
|
assert!(l.iter().any(|h| h == "git.redclaw.dev"), "{l:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let kimi = allow_list_for(Some("kimi"));
|
||||||
|
assert!(kimi.iter().any(|h| h == "api.kimi.com"), "{kimi:?}");
|
||||||
|
for other in ["api.z.ai", "api.anthropic.com"] {
|
||||||
|
assert!(!kimi.iter().any(|h| h == other), "{kimi:?}");
|
||||||
|
}
|
||||||
|
|
||||||
// An unknown backend gets no model API at all rather than borrowing
|
// An unknown backend gets no model API at all rather than borrowing
|
||||||
// somebody's: it cannot run anyway, and failing at a closed door beats
|
// somebody's: it cannot run anyway, and failing at a closed door beats
|
||||||
// reaching the wrong endpoint with a credential.
|
// reaching the wrong endpoint with a credential.
|
||||||
|
|||||||
@@ -214,10 +214,15 @@ pub fn available_backends() -> Vec<String> {
|
|||||||
fn required_cli(backend: Option<&str>) -> Option<(&'static str, &'static str)> {
|
fn required_cli(backend: Option<&str>) -> Option<(&'static str, &'static str)> {
|
||||||
match backend {
|
match backend {
|
||||||
Some("claude") => Some(("claude", "claude --version")),
|
Some("claude") => Some(("claude", "claude --version")),
|
||||||
Some("kimi") => Some(("kimi", "kimi --version")),
|
// GLM and Kimi are both Claude Code pointed at another provider's
|
||||||
// GLM is Claude Code pointed at z.ai's anthropic-compatible endpoint;
|
// Anthropic-compatible endpoint (z.ai, and api.kimi.com/coding), so the
|
||||||
// the binary in the image is still `claude`.
|
// binary in all three images is `claude`.
|
||||||
Some("glm") => Some(("claude", "claude --version")),
|
//
|
||||||
|
// `kimi` used to expect Moonshot's own `kimi` CLI here. That was written
|
||||||
|
// before the endpoint was measured, and it failed the selftest of a
|
||||||
|
// rootfs that was in fact correct — the image runs Claude Code because
|
||||||
|
// the whole mission harness is Claude-Code-shaped.
|
||||||
|
Some("glm") | Some("kimi") => Some(("claude", "claude --version")),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -949,7 +954,7 @@ mod tests {
|
|||||||
/// from the wrong image passes the selftest by saying nothing.
|
/// from the wrong image passes the selftest by saying nothing.
|
||||||
#[test]
|
#[test]
|
||||||
fn every_per_cli_backend_declares_the_cli_it_must_contain() {
|
fn every_per_cli_backend_declares_the_cli_it_must_contain() {
|
||||||
for (backend, want) in [("claude", "claude"), ("kimi", "kimi"), ("glm", "claude")] {
|
for (backend, want) in [("claude", "claude"), ("kimi", "claude"), ("glm", "claude")] {
|
||||||
let (cli, probe) = required_cli(Some(backend))
|
let (cli, probe) = required_cli(Some(backend))
|
||||||
.unwrap_or_else(|| panic!("backend {backend} requires no CLI"));
|
.unwrap_or_else(|| panic!("backend {backend} requires no CLI"));
|
||||||
assert_eq!(cli, want, "backend {backend}");
|
assert_eq!(cli, want, "backend {backend}");
|
||||||
|
|||||||
@@ -184,11 +184,20 @@ fn microvm_credential_for(backend: Option<&str>) -> Result<Credential, String> {
|
|||||||
source: "ZAI_API_KEY",
|
source: "ZAI_API_KEY",
|
||||||
target: "ANTHROPIC_AUTH_TOKEN",
|
target: "ANTHROPIC_AUTH_TOKEN",
|
||||||
}),
|
}),
|
||||||
// Kimi is deliberately still refused. `KIMI_API_KEY` is set on the
|
Some("kimi") => Ok(Credential {
|
||||||
// server and Moonshot serves an Anthropic-compatible API, but I have not
|
source: "KIMI_API_KEY",
|
||||||
// verified its base URL against the running service — and this function
|
target: "ANTHROPIC_AUTH_TOKEN",
|
||||||
// is exactly where guessing a URL costs a credential. It becomes one
|
}),
|
||||||
// arm here the day someone measures it, alongside an `agent-kimi` image.
|
// Kimi, on the same split as GLM: endpoint in the image
|
||||||
|
// (`https://api.kimi.com/coding`), credential in the turn.
|
||||||
|
//
|
||||||
|
// Which HOST took a measurement to find. `api.moonshot.ai/anthropic`
|
||||||
|
// exists and speaks the protocol, and rejects an `sk-kimi-` key — it is
|
||||||
|
// the platform.moonshot.ai account namespace. The Kimi CODE service at
|
||||||
|
// `api.kimi.com/coding` is where such a key is valid, and it answers
|
||||||
|
// `/v1/messages` with a real Anthropic body. Two endpoints that both
|
||||||
|
// "work" for different accounts is exactly the shape that makes a
|
||||||
|
// guessed URL look like a broken key.
|
||||||
Some(other) => Err(format!(
|
Some(other) => Err(format!(
|
||||||
"backend {other:?} has no defined microVM credential contract yet — \
|
"backend {other:?} has no defined microVM credential contract yet — \
|
||||||
refusing to launch rather than forward one provider's credential to \
|
refusing to launch rather than forward one provider's credential to \
|
||||||
@@ -1160,6 +1169,19 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kimi shares the target var with GLM and NOTHING else: same protocol,
|
||||||
|
// different endpoint baked into a different image. What must not happen
|
||||||
|
// is one provider's key travelling under the other's name.
|
||||||
|
let kimi = microvm_provider_env_from(Some("kimi"), |k| Some(format!("value-of-{k}")))
|
||||||
|
.expect("kimi has a settled contract");
|
||||||
|
let kimi: std::collections::HashMap<_, _> = kimi.into_iter().collect();
|
||||||
|
assert_eq!(
|
||||||
|
kimi.get("ANTHROPIC_AUTH_TOKEN").map(String::as_str),
|
||||||
|
Some("value-of-KIMI_API_KEY"),
|
||||||
|
"{kimi:?}"
|
||||||
|
);
|
||||||
|
assert!(!kimi.contains_key("CLAUDE_CODE_OAUTH_TOKEN"), "{kimi:?}");
|
||||||
|
|
||||||
// And the reverse: a claude VM carries no z.ai key.
|
// And the reverse: a claude VM carries no z.ai key.
|
||||||
let env = microvm_provider_env_from(Some("claude"), |k| Some(format!("value-of-{k}")))
|
let env = microvm_provider_env_from(Some("claude"), |k| Some(format!("value-of-{k}")))
|
||||||
.expect("claude");
|
.expect("claude");
|
||||||
@@ -1183,10 +1205,8 @@ mod tests {
|
|||||||
/// than handed another provider's credential to send at its endpoint.
|
/// than handed another provider's credential to send at its endpoint.
|
||||||
#[test]
|
#[test]
|
||||||
fn an_undefined_backend_is_refused_rather_than_given_the_anthropic_token() {
|
fn an_undefined_backend_is_refused_rather_than_given_the_anthropic_token() {
|
||||||
// `kimi` stays here on purpose: the key is set on the server, but the
|
// `kimi` has since moved out of this list — its URL was measured.
|
||||||
// base URL has not been verified against the running service, and this
|
for b in [Some("something-new"), Some("agent-terminal")] {
|
||||||
// is the function where guessing a URL costs a credential.
|
|
||||||
for b in [Some("kimi"), Some("something-new"), Some("agent-terminal")] {
|
|
||||||
let r = microvm_provider_env_from(b, |_| Some("set".into()));
|
let r = microvm_provider_env_from(b, |_| Some("set".into()));
|
||||||
assert!(r.is_err(), "backend {b:?} should be refused: {r:?}");
|
assert!(r.is_err(), "backend {b:?} should be refused: {r:?}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Plan A6, third of three: Claude Code pointed at Kimi.
|
||||||
|
#
|
||||||
|
# Same shape as `agent-glm` — same CLI, same pinned version, a different
|
||||||
|
# endpoint — for the same reason: an independent verifier is only independent if
|
||||||
|
# it runs somewhere else, and a third provider costs an env contract rather than
|
||||||
|
# a third agent harness.
|
||||||
|
#
|
||||||
|
# WHY CLAUDE CODE AND NOT `kimi`. Moonshot ship their own CLI
|
||||||
|
# (`@moonshot-ai/kimi-code`), and it would be the more native choice — but the
|
||||||
|
# whole mission harness is Claude-Code-shaped: `--agents` JSON roles, the
|
||||||
|
# `verifier` tool allowlist, the `Stop` hook that backs the completion gate, and
|
||||||
|
# the per-subagent transcripts we count as delegation evidence. `kimi` has none
|
||||||
|
# of those flags; its equivalents are TOML files and markdown agent dirs. Running
|
||||||
|
# it would mean a second executor with its own untested failure modes, so this
|
||||||
|
# image uses the Anthropic-compatible mode instead and the harness is unchanged.
|
||||||
|
#
|
||||||
|
# THE URL IS MEASURED, and the first answer was the wrong one. `api.moonshot.ai/
|
||||||
|
# anthropic/v1/messages` exists and speaks the protocol, but it belongs to the
|
||||||
|
# platform.moonshot.ai account namespace and rejects an `sk-kimi-` key. The Kimi
|
||||||
|
# CODE service is a different host: `POST https://api.kimi.com/coding/v1/messages`
|
||||||
|
# returns a real Anthropic Messages body — `msg_` id, `content` blocks, a
|
||||||
|
# `thinking` block with a signature.
|
||||||
|
#
|
||||||
|
# So the base URL is `https://api.kimi.com/coding` and NOT `.../coding/v1`:
|
||||||
|
# Claude Code appends `/v1/messages` itself, and the doubled `/v1/v1/messages`
|
||||||
|
# is a 404 that would look like a broken image rather than a bad URL.
|
||||||
|
#
|
||||||
|
# Two more things measured rather than assumed, because each would otherwise be
|
||||||
|
# a silent failure at the first turn:
|
||||||
|
# - `Authorization: Bearer` is accepted (so ANTHROPIC_AUTH_TOKEN is the right
|
||||||
|
# injection channel; `x-api-key` works too, but that is the API-key path a
|
||||||
|
# microVM deliberately does not use).
|
||||||
|
# - a `claude-*` model id is ACCEPTED and answered, so Kimi maps it onto its
|
||||||
|
# own model exactly as z.ai does. No ANTHROPIC_MODEL override is needed.
|
||||||
|
FROM clawmates/agent-toolchain:dev
|
||||||
|
|
||||||
|
ARG CLAUDE_CODE_VERSION=2.1.223
|
||||||
|
RUN npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
|
||||||
|
&& npm cache clean --force \
|
||||||
|
&& rm -rf /root/.npm \
|
||||||
|
&& claude --version
|
||||||
|
|
||||||
|
# The endpoint is baked in; the credential is injected per turn by
|
||||||
|
# `mission_runtime::microvm_provider_env` from the server's `KIMI_API_KEY`. A VM
|
||||||
|
# built from this image therefore cannot be handed an Anthropic subscription
|
||||||
|
# token or a z.ai key — the wrong credential has nowhere to go.
|
||||||
|
ENV HOME=/root \
|
||||||
|
CLAWMATES_AGENT_CLI=claude \
|
||||||
|
ANTHROPIC_BASE_URL=https://api.kimi.com/coding
|
||||||
|
RUN mkdir -p /root/.claude
|
||||||
|
|
||||||
|
# No ANTHROPIC_MODEL: measured above, the service maps the `claude-*` id Claude
|
||||||
|
# Code sends onto its own model (`kimi-for-coding`, K2.7 Coding, 262k context).
|
||||||
@@ -49,8 +49,12 @@ AGENT_BIN=${FC_AGENT_BIN:-'$HOME/clawmates/target/x86_64-unknown-linux-musl/rele
|
|||||||
# building an image is the CLI inside, so the builder asks.
|
# building an image is the CLI inside, so the builder asks.
|
||||||
case "${FC_CLI-unset}" in
|
case "${FC_CLI-unset}" in
|
||||||
unset) case "$NAME" in
|
unset) case "$NAME" in
|
||||||
claude|glm) FC_CLI="claude --version" ;;
|
# `glm` and `kimi` are both Claude Code pointed at another
|
||||||
kimi) FC_CLI="kimi --version" ;;
|
# provider's Anthropic-compatible endpoint, so the binary in the
|
||||||
|
# image is `claude` for all three. Moonshot ship their own `kimi`
|
||||||
|
# CLI, and this map used to expect it — a leftover from before the
|
||||||
|
# endpoint was measured, which failed a perfectly good rootfs.
|
||||||
|
claude|glm|kimi) FC_CLI="claude --version" ;;
|
||||||
*) FC_CLI="" ;;
|
*) FC_CLI="" ;;
|
||||||
esac ;;
|
esac ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
Reference in New Issue
Block a user