From 89acc55df8c240eed34456b3fc3cdc97f75ae388 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Mon, 22 Jun 2026 12:46:43 -0700 Subject: [PATCH 1/8] feat(inference): prepare Kimi K2.7 curated selection --- docs/inference/inference-options.mdx | 4 +-- .../model-specific-setup/README.md | 2 +- .../openclaw/kimi-k2.6-managed-inference.json | 4 +-- .../kimi-inference-compat/index.js | 6 ++-- src/lib/inference/config.test.ts | 24 ++++++++++++-- src/lib/inference/config.ts | 4 +-- src/lib/inference/health.test.ts | 31 +++++++++++++++++++ src/lib/inference/health.ts | 31 ++++++++++++------- src/lib/inference/model-prompts.test.ts | 10 ++++++ src/lib/inference/onboard-probes.test.ts | 21 +++++++++++++ src/lib/inference/onboard-probes.ts | 10 +++++- test/generate-openclaw-config.test.ts | 21 +++++++++++++ test/kimi-inference-compat-plugin.test.ts | 27 ++++++++++++++++ test/onboard-selection.test.ts | 1 + 14 files changed, 171 insertions(+), 25 deletions(-) diff --git a/docs/inference/inference-options.mdx b/docs/inference/inference-options.mdx index f07adf448a1..51e71750c25 100644 --- a/docs/inference/inference-options.mdx +++ b/docs/inference/inference-options.mdx @@ -64,13 +64,13 @@ The managed install/start vLLM entry appears by default on DGX Spark and DGX Sta | Option | Description | Curated models | |--------|-------------|----------------| -| NVIDIA Endpoints | Routes to models hosted on [build.nvidia.com](https://build.nvidia.com). You can also enter any model ID from the catalog. Set `NVIDIA_INFERENCE_API_KEY`. | Nemotron 3 Super 120B, Nemotron 3 Ultra 550B, GLM-5.1, MiniMax M2.7, GPT-OSS 120B, DeepSeek V4 Pro | +| NVIDIA Endpoints | Routes to models hosted on [build.nvidia.com](https://build.nvidia.com). You can also enter any model ID from the catalog. Set `NVIDIA_INFERENCE_API_KEY`. | Nemotron 3 Super 120B, Nemotron 3 Ultra 550B, GLM-5.1, MiniMax M2.7, Kimi K2.7 Code, GPT-OSS 120B, DeepSeek V4 Pro | | OpenAI | Routes to the OpenAI API. Set `OPENAI_API_KEY`. | `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.4-pro-2026-03-05` | | Other OpenAI-compatible endpoint | Routes to any server that implements `/v1/chat/completions`. NemoClaw uses `/v1/chat/completions` at runtime by default; set `NEMOCLAW_PREFERRED_API=openai-responses` to allow `/v1/responses` for proxies that implement it, such as some llama.cpp builds. The wizard prompts for a base URL and model name. Works with OpenRouter, LocalAI, llama.cpp, or any compatible proxy. When you enable Telegram messaging, onboarding also runs a bounded sandbox-side smoke check through `https://inference.local/v1/chat/completions`. Set `COMPATIBLE_API_KEY`. | You provide the model name. | | Anthropic | Routes to the Anthropic Messages API. Set `ANTHROPIC_API_KEY`. | `claude-sonnet-4-6`, `claude-haiku-4-5`, `claude-opus-4-6` | | Other Anthropic-compatible endpoint | Routes to any server that implements the Anthropic Messages API (`/v1/messages`). The wizard prompts for a base URL and model name. Set `COMPATIBLE_ANTHROPIC_API_KEY`. | You provide the model name. | | Google Gemini | Routes to Google's OpenAI-compatible chat-completions endpoint. NemoClaw skips the Responses-API probe because Gemini does not support `/v1/responses`. Set `GEMINI_API_KEY`. | `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite-preview`, `gemini-3-flash-preview`, `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite` | -| Hermes Provider | Routes Hermes Agent through the host OpenShell provider registered by NemoClaw when onboarding Hermes Agent. | Curated Hermes Provider models such as `moonshotai/kimi-k2.6`, `openai/gpt-5.4-mini`, and `z-ai/glm-5.1`. | +| Hermes Provider | Routes Hermes Agent through the host OpenShell provider registered by NemoClaw when onboarding Hermes Agent. | Curated Hermes Provider models such as `moonshotai/kimi-k2.7-code`, `openai/gpt-5.4-mini`, and `z-ai/glm-5.1`. | | Local Ollama | Routes to a local Ollama instance on `localhost:11434`. NemoClaw detects installed models, offers starter models if none are present, pulls and warms the selected model, and validates it. | Selected during onboarding. For more information, refer to [Use a Local Inference Server](use-local-inference). | | Model Router | Starts a host-side router on port `4000`, registers it as an OpenAI-compatible provider, and keeps the sandbox pointed at `inference.local`. Set `NEMOCLAW_PROVIDER=routed` for non-interactive setup. | The router pool defines the model names. | diff --git a/nemoclaw-blueprint/model-specific-setup/README.md b/nemoclaw-blueprint/model-specific-setup/README.md index f893e23014d..73bd8dadd77 100644 --- a/nemoclaw-blueprint/model-specific-setup/README.md +++ b/nemoclaw-blueprint/model-specific-setup/README.md @@ -20,7 +20,7 @@ Manifests follow `schema.json`: - `match`: model/provider route predicates. - `effects`: declarative, agent-scoped effects. -The first OpenClaw entry is `openclaw/kimi-k2.6-managed-inference.json`. It preserves the Kimi K2.6 managed `inference.local` compatibility behavior from PR #3046. +The first OpenClaw entry is `openclaw/kimi-k2.6-managed-inference.json`. It preserves the Kimi K2.6 managed `inference.local` compatibility behavior from PR #3046 and also covers later Kimi Code variants that need the same OpenClaw route handling. ## Contributor Guidance diff --git a/nemoclaw-blueprint/model-specific-setup/openclaw/kimi-k2.6-managed-inference.json b/nemoclaw-blueprint/model-specific-setup/openclaw/kimi-k2.6-managed-inference.json index 34b5b3a0231..2117b9ceba5 100644 --- a/nemoclaw-blueprint/model-specific-setup/openclaw/kimi-k2.6-managed-inference.json +++ b/nemoclaw-blueprint/model-specific-setup/openclaw/kimi-k2.6-managed-inference.json @@ -2,9 +2,9 @@ "$schema": "../schema.json", "id": "kimi-k2.6-managed-inference", "agent": "openclaw", - "description": "Preserves OpenClaw request, tool-call, and reasoning-output compatibility for moonshotai/kimi-k2.6 through NemoClaw managed inference.local chat completions.", + "description": "Preserves OpenClaw request, tool-call, and reasoning-output compatibility for Kimi K2.6 and Kimi K2.7 Code through NemoClaw managed inference.local chat completions.", "match": { - "modelIds": ["moonshotai/kimi-k2.6"], + "modelIds": ["moonshotai/kimi-k2.6", "moonshotai/kimi-k2.7-code"], "providerKey": "inference", "inferenceApi": "openai-completions", "baseUrl": "https://inference.local/v1" diff --git a/nemoclaw-blueprint/openclaw-plugins/kimi-inference-compat/index.js b/nemoclaw-blueprint/openclaw-plugins/kimi-inference-compat/index.js index 57cd2c2d333..3b25c875efb 100644 --- a/nemoclaw-blueprint/openclaw-plugins/kimi-inference-compat/index.js +++ b/nemoclaw-blueprint/openclaw-plugins/kimi-inference-compat/index.js @@ -9,12 +9,12 @@ function normalizeBaseUrl(value) { return String(value || "").trim().replace(/\/+$/, ""); } -const KIMI_K26_MODEL_ID = "moonshotai/kimi-k2.6"; -const MANAGED_KIMI_K26_MODEL_REF = `inference/${KIMI_K26_MODEL_ID}`; +const KIMI_MODEL_IDS = new Set(["moonshotai/kimi-k2.6", "moonshotai/kimi-k2.7-code"]); function isKimiModelId(value) { const modelId = normalize(value); - return modelId === KIMI_K26_MODEL_ID || modelId === MANAGED_KIMI_K26_MODEL_REF; + const unqualifiedModelId = modelId.startsWith("inference/") ? modelId.slice(10) : modelId; + return KIMI_MODEL_IDS.has(unqualifiedModelId); } function isManagedKimi(ctx) { diff --git a/src/lib/inference/config.test.ts b/src/lib/inference/config.test.ts index d42e67a8b73..c1bc328c7d2 100644 --- a/src/lib/inference/config.test.ts +++ b/src/lib/inference/config.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest"; // Import from compiled dist/ for correct coverage attribution. import { CLOUD_MODEL_OPTIONS, + DEFAULT_CLOUD_MODEL, DEFAULT_HERMES_PROVIDER_MODEL, DEFAULT_OLLAMA_MODEL, DEFAULT_ROUTE_CREDENTIAL_ENV, @@ -31,16 +32,17 @@ describe("inference selection config", () => { "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", "z-ai/glm-5.1", "minimaxai/minimax-m2.7", - "moonshotai/kimi-k2.6", + "moonshotai/kimi-k2.7-code", "openai/gpt-oss-120b", "deepseek-ai/deepseek-v4-pro", ]); + expect(DEFAULT_CLOUD_MODEL).toBe("nvidia/nemotron-3-super-120b-a12b"); }); it("aligns Hermes Provider defaults with the Hermes Agent Nous catalog", () => { - expect(DEFAULT_HERMES_PROVIDER_MODEL).toBe("moonshotai/kimi-k2.6"); + expect(DEFAULT_HERMES_PROVIDER_MODEL).toBe("moonshotai/kimi-k2.7-code"); expect(HERMES_PROVIDER_MODEL_OPTIONS.slice(0, 10)).toEqual([ - "moonshotai/kimi-k2.6", + "moonshotai/kimi-k2.7-code", "xiaomi/mimo-v2.5-pro", "xiaomi/mimo-v2.5", "tencent/hy3-preview", @@ -272,6 +274,22 @@ describe("getSandboxInferenceConfig", () => { }); }); + it("leaves Kimi K2.7 Code compat to the model-specific setup registry", () => { + expect( + getSandboxInferenceConfig( + "moonshotai/kimi-k2.7-code", + "nvidia-prod", + "openai-completions", + ), + ).toEqual({ + providerKey: MANAGED_PROVIDER_ID, + primaryModelRef: `${MANAGED_PROVIDER_ID}/moonshotai/kimi-k2.7-code`, + inferenceBaseUrl: INFERENCE_ROUTE_URL, + inferenceApi: "openai-completions", + inferenceCompat: null, + }); + }); + it("maps OpenAI-compatible endpoints to the managed inference provider", () => { expect( getSandboxInferenceConfig("deepseek-ai/DeepSeek-V4-Flash", "compatible-endpoint"), diff --git a/src/lib/inference/config.ts b/src/lib/inference/config.ts index afb00c320be..a78cc00c0e4 100644 --- a/src/lib/inference/config.ts +++ b/src/lib/inference/config.ts @@ -20,7 +20,7 @@ export const DEFAULT_CLOUD_MODEL = "nvidia/nemotron-3-super-120b-a12b"; // separately (cloud context-window registry). export const DEFAULT_CONTEXT_WINDOW = 131072; export const HERMES_PROVIDER_MODEL_OPTIONS = [ - "moonshotai/kimi-k2.6", + "moonshotai/kimi-k2.7-code", "xiaomi/mimo-v2.5-pro", "xiaomi/mimo-v2.5", "tencent/hy3-preview", @@ -58,7 +58,7 @@ export const CLOUD_MODEL_OPTIONS = [ { id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", label: "Nemotron 3 Nano Omni 30B" }, { id: "z-ai/glm-5.1", label: "GLM-5" }, { id: "minimaxai/minimax-m2.7", label: "MiniMax M2.7" }, - { id: "moonshotai/kimi-k2.6", label: "Kimi K2.6" }, + { id: "moonshotai/kimi-k2.7-code", label: "Kimi K2.7 Code" }, { id: "openai/gpt-oss-120b", label: "GPT-OSS 120B" }, { id: "deepseek-ai/deepseek-v4-pro", label: "DeepSeek V4 Pro" }, ]; diff --git a/src/lib/inference/health.test.ts b/src/lib/inference/health.test.ts index 6e3c6f8569a..48a4eb82b8d 100644 --- a/src/lib/inference/health.test.ts +++ b/src/lib/inference/health.test.ts @@ -247,6 +247,37 @@ describe("inference health", () => { }); }); + it("uses Kimi K2.7 Code chat completions without K2.6 thinking suppression", () => { + let capturedArgv: string[] = []; + const result = probeRemoteProviderHealth("nvidia-prod", { + model: "moonshotai/kimi-k2.7-code", + getCredentialImpl: (envName) => + envName === "NVIDIA_INFERENCE_API_KEY" ? "nvapi-test" : null, + runCurlProbeImpl: (argv) => { + capturedArgv = argv; + return { + ok: true, + httpStatus: 200, + curlStatus: 0, + body: '{"choices":[{"message":{"content":"OK"}}]}', + stderr: "", + message: "HTTP 200", + }; + }, + }); + + expect(result?.ok).toBe(true); + expect(result?.probed).toBe(true); + expect(result?.detail).toContain("Kimi K2.7 Code chat-completions route"); + expect(capturedArgv.at(-1)).toBe(`${BUILD_ENDPOINT_URL}/chat/completions`); + const payload = JSON.parse(capturedArgv[capturedArgv.indexOf("-d") + 1]); + expect(payload).toEqual({ + model: "moonshotai/kimi-k2.7-code", + messages: [{ role: "user", content: "Reply with exactly: OK" }], + max_tokens: 8, + }); + }); + it("does not fall back to provider-level NVIDIA /models for Kimi without a credential", () => { let called = false; const result = probeRemoteProviderHealth("nvidia-prod", { diff --git a/src/lib/inference/health.ts b/src/lib/inference/health.ts index 8ca7518cc12..866b8c92500 100644 --- a/src/lib/inference/health.ts +++ b/src/lib/inference/health.ts @@ -51,7 +51,10 @@ export interface ProviderHealthProbeOptions { const COMPATIBLE_PROVIDERS = new Set(["compatible-endpoint", "compatible-anthropic-endpoint"]); const NVIDIA_MANAGED_PROVIDERS = new Set(["nvidia-prod", "nvidia-nim"]); const NVIDIA_HEALTH_CREDENTIAL_ENV = "NVIDIA_INFERENCE_API_KEY"; -const KIMI_K26_MODEL = "moonshotai/kimi-k2.6"; +const KIMI_NVIDIA_CHAT_COMPLETIONS_MODELS = new Set([ + "moonshotai/kimi-k2.6", + "moonshotai/kimi-k2.7-code", +]); const KIMI_STATUS_CONNECT_TIMEOUT_SECONDS = "3"; const KIMI_STATUS_MAX_TIME_SECONDS = "5"; const KIMI_HEALTH_CURL_CONFIG_PREFIX = "nemoclaw-kimi-health-curl"; @@ -62,8 +65,13 @@ function normalizeModel(model: string | null | undefined): string | null { return trimmed || null; } -function isKimiK26Model(model: string | null | undefined): model is string { - return normalizeModel(model)?.toLowerCase() === KIMI_K26_MODEL; +function isNvidiaKimiChatCompletionsModel(model: string | null | undefined): model is string { + const normalized = normalizeModel(model); + return normalized ? KIMI_NVIDIA_CHAT_COMPLETIONS_MODELS.has(normalized.toLowerCase()) : false; +} + +function kimiHealthLabel(model: string): string { + return model.toLowerCase() === "moonshotai/kimi-k2.7-code" ? "Kimi K2.7 Code" : "Kimi K2.6"; } function resolveProbeCredential(envName: string, options: ProviderHealthProbeOptions): string { @@ -177,11 +185,12 @@ function buildRemoteProbeDetail( function buildKimiChatCompletionsDetail( providerLabel: string, + model: string, endpoint: string, healthy: boolean, result: CurlProbeResult, ): string { - const route = `${providerLabel} Kimi K2.6 chat-completions route`; + const route = `${providerLabel} ${kimiHealthLabel(model)} chat-completions route`; if (healthy) { return `${route} is healthy at ${endpoint}.`; } @@ -191,7 +200,7 @@ function buildKimiChatCompletionsDetail( ); } -function probeNvidiaKimiK26Health( +function probeNvidiaKimiHealth( provider: string, model: string, options: ProviderHealthProbeOptions, @@ -210,7 +219,7 @@ function probeNvidiaKimiK26Health( providerLabel, endpoint, detail: - `Could not resolve ${NVIDIA_HEALTH_CREDENTIAL_ENV} for Kimi K2.6 health; ` + + `Could not resolve ${NVIDIA_HEALTH_CREDENTIAL_ENV} for ${kimiHealthLabel(model)} health; ` + `skipping model-specific chat-completions probe. (${reason})`, }; } @@ -222,7 +231,7 @@ function probeNvidiaKimiK26Health( providerLabel, endpoint, detail: - `Kimi K2.6 health requires ${NVIDIA_HEALTH_CREDENTIAL_ENV}; ` + + `${kimiHealthLabel(model)} health requires ${NVIDIA_HEALTH_CREDENTIAL_ENV}; ` + "skipping model-specific chat-completions probe instead of using provider-level /models reachability.", }; } @@ -239,7 +248,7 @@ function probeNvidiaKimiK26Health( providerLabel, endpoint, detail: - `Could not prepare ${NVIDIA_HEALTH_CREDENTIAL_ENV} for Kimi K2.6 health; ` + + `Could not prepare ${NVIDIA_HEALTH_CREDENTIAL_ENV} for ${kimiHealthLabel(model)} health; ` + `skipping model-specific chat-completions probe. (${reason})`, }; } @@ -261,7 +270,7 @@ function probeNvidiaKimiK26Health( probed: true, providerLabel, endpoint, - detail: buildKimiChatCompletionsDetail(providerLabel, endpoint, healthy, result), + detail: buildKimiChatCompletionsDetail(providerLabel, model, endpoint, healthy, result), ...(healthy ? {} : { failureLabel: result.curlStatus === 0 ? "unhealthy" : "unreachable" }), }; } @@ -292,8 +301,8 @@ export function probeRemoteProviderHealth( }; } - if (NVIDIA_MANAGED_PROVIDERS.has(provider) && isKimiK26Model(model)) { - return probeNvidiaKimiK26Health(provider, model, options); + if (NVIDIA_MANAGED_PROVIDERS.has(provider) && isNvidiaKimiChatCompletionsModel(model)) { + return probeNvidiaKimiHealth(provider, model, options); } const endpoint = getRemoteProviderHealthEndpoint(provider); diff --git a/src/lib/inference/model-prompts.test.ts b/src/lib/inference/model-prompts.test.ts index 0d574b4ae71..3c6682c8953 100644 --- a/src/lib/inference/model-prompts.test.ts +++ b/src/lib/inference/model-prompts.test.ts @@ -43,6 +43,16 @@ describe("model prompt helpers", () => { expect(result).toBe("deepseek-ai/deepseek-v4-pro"); }); + it("returns Kimi K2.7 Code from the default cloud model menu", async () => { + const promptFn = promptSequence(["6"]); + const result = await promptCloudModel({ + promptFn, + writeLine: vi.fn(), + }); + + expect(result).toBe("moonshotai/kimi-k2.7-code"); + }); + it("validates manual cloud model ids against the saved NVIDIA key", async () => { const promptFn = promptSequence(["9", "bad-model", "nemotron-custom"]); const errorLine = vi.fn(); diff --git a/src/lib/inference/onboard-probes.test.ts b/src/lib/inference/onboard-probes.test.ts index 7230f5fef3f..c7f0e00dd71 100644 --- a/src/lib/inference/onboard-probes.test.ts +++ b/src/lib/inference/onboard-probes.test.ts @@ -380,6 +380,27 @@ describe("OpenAI-compatible inference probes", () => { expect(args).toContain(JSON.stringify(getChatCompletionsProbePayload("moonshotai/kimi-k2.6"))); }); + it("uses the Kimi validation budget for Kimi K2.7 Code without K2.6 thinking suppression", () => { + expect(getChatCompletionsProbePayload("moonshotai/kimi-k2.7-code")).toEqual({ + model: "moonshotai/kimi-k2.7-code", + messages: [{ role: "user", content: "Reply with exactly: OK" }], + max_tokens: 8, + }); + + const args = getChatCompletionsProbeCurlArgs({ + authHeader: ["-H", "Authorization: Bearer nvapi-test"], + model: "moonshotai/kimi-k2.7-code", + url: "https://integrate.api.nvidia.com/v1/chat/completions", + isWsl: false, + }); + + expect(args).toContain("--max-time"); + expect(args[args.indexOf("--max-time") + 1]).toBe("60"); + expect(args).toContain( + JSON.stringify(getChatCompletionsProbePayload("moonshotai/kimi-k2.7-code")), + ); + }); + it("uses an extended streaming validation budget for DeepSeek V4 Pro", () => { expect(getDeepSeekV4ProValidationProbeCurlArgs({ isWsl: false })).toEqual([ "--connect-timeout", diff --git a/src/lib/inference/onboard-probes.ts b/src/lib/inference/onboard-probes.ts index a7084360cf4..6f234feab0b 100644 --- a/src/lib/inference/onboard-probes.ts +++ b/src/lib/inference/onboard-probes.ts @@ -33,6 +33,10 @@ const EXTENDED_NVIDIA_ENDPOINT_VALIDATION_MODELS = new Set([ "qwen/qwen3.5-397b-a17b", "deepseek-ai/deepseek-v4-flash", ]); +const KIMI_NVIDIA_CHAT_COMPLETIONS_MODELS = new Set([ + "moonshotai/kimi-k2.6", + "moonshotai/kimi-k2.7-code", +]); const CURL_TIMEOUT_STATUS = 28; const NODE_SPAWN_TIMEOUT_STATUS = -110; @@ -492,13 +496,17 @@ function isKimiK26Model(model) { return String(model || "").toLowerCase() === "moonshotai/kimi-k2.6"; } +function isKimiNvidiaChatCompletionsModel(model) { + return KIMI_NVIDIA_CHAT_COMPLETIONS_MODELS.has(String(model || "").toLowerCase()); +} + function needsExtendedNvidiaEndpointValidationBudget(model) { return EXTENDED_NVIDIA_ENDPOINT_VALIDATION_MODELS.has(String(model || "").toLowerCase()); } function getChatCompletionsProbeTimingArgs(model, opts) { if (isDeepSeekV4ProModel(model)) return getDeepSeekV4ProValidationProbeCurlArgs(opts); - if (isKimiK26Model(model)) return getKimiK26ValidationProbeCurlArgs(opts); + if (isKimiNvidiaChatCompletionsModel(model)) return getKimiK26ValidationProbeCurlArgs(opts); if (needsExtendedNvidiaEndpointValidationBudget(model)) { return getExtendedNvidiaEndpointValidationProbeCurlArgs(opts); } diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index d17603ddb09..443e4345f06 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -1275,6 +1275,27 @@ describe("generate-openclaw-config.mts: config generation", () => { }); }); + it("adds Kimi K2.7 Code compat through the existing managed inference setup", () => { + const config = runConfigScript({ + NEMOCLAW_MODEL: "moonshotai/kimi-k2.7-code", + NEMOCLAW_PROVIDER_KEY: "inference", + NEMOCLAW_PRIMARY_MODEL_REF: "inference/moonshotai/kimi-k2.7-code", + NEMOCLAW_INFERENCE_BASE_URL: "https://inference.local/v1", + NEMOCLAW_INFERENCE_API: "openai-completions", + NEMOCLAW_INFERENCE_COMPAT_B64: Buffer.from("null").toString("base64"), + }); + + expect(config.models.providers.inference.models[0].compat).toEqual({ + supportsStore: false, + requiresStringContent: true, + maxTokensField: "max_tokens", + requiresToolResultName: true, + }); + expect(config.plugins.entries["nemoclaw-kimi-inference-compat"]).toEqual({ + enabled: true, + }); + }); + it("rejects inference compat blobs that decode to non-object JSON", () => { expectBuildConfigError( { NEMOCLAW_INFERENCE_COMPAT_B64: Buffer.from('"not-an-object"').toString("base64") }, diff --git a/test/kimi-inference-compat-plugin.test.ts b/test/kimi-inference-compat-plugin.test.ts index 0dc3a6741d2..4f38560ca84 100644 --- a/test/kimi-inference-compat-plugin.test.ts +++ b/test/kimi-inference-compat-plugin.test.ts @@ -448,6 +448,33 @@ describe("nemoclaw Kimi inference compat plugin", () => { expect(JSON.stringify(result)).not.toContain("hostname; date; uptime"); }); + it("matches Kimi K2.7 Code managed inference refs", async () => { + const message = toolMessage("hostname; date; uptime"); + const provider = makeProvider(); + const wrapper = provider.wrapStreamFn({ + ...managedKimiCtx(() => ({ + async result() { + return message; + }, + })), + modelId: "inference/moonshotai/kimi-k2.7-code", + model: { + id: "moonshotai/kimi-k2.7-code", + name: "inference/moonshotai/kimi-k2.7-code", + api: "openai-completions", + baseUrl: "https://inference.local/v1", + }, + }); + + expect(wrapper).toEqual(expect.any(Function)); + + const stream = wrapper({}, {}, {}); + const result = await stream.result(); + + expect(result.content.map(toolCommand)).toEqual(["hostname", "date", "uptime"]); + expect(JSON.stringify(result)).not.toContain("hostname; date; uptime"); + }); + it("rewrites object tool-call deltas at their content index without retaining compound commands", () => { const event = { type: "toolcall_delta", diff --git a/test/onboard-selection.test.ts b/test/onboard-selection.test.ts index 61e90ff57f2..4919dd29689 100644 --- a/test/onboard-selection.test.ts +++ b/test/onboard-selection.test.ts @@ -607,6 +607,7 @@ const { setupNim } = require(${onboardPath}); assert.equal(payload.result.model, "deepseek-ai/deepseek-v4-pro"); assert.equal(payload.result.preferredInferenceApi, "openai-completions"); assert.match(payload.messages[1], /Choose model \[1\]/); + assert.ok(payload.lines.some((line: string) => line.includes("Kimi K2.7 Code"))); assert.ok(payload.lines.some((line: string) => line.includes("DeepSeek V4 Pro"))); assert.ok( payload.lines.some((line: string) => line.includes("Chat Completions API available")), From f047a6e78e1713cfaa04f6e49ec3c71e6041b877 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Mon, 22 Jun 2026 19:48:31 -0700 Subject: [PATCH 2/8] test(inference): keep Kimi K2.7 checks within budgets --- src/lib/inference/config.test.ts | 6 +-- ...openclaw-config-inference-registry.test.ts | 47 +++++++++++++++++++ test/generate-openclaw-config.test.ts | 21 --------- test/onboard-selection.test.ts | 1 - 4 files changed, 48 insertions(+), 27 deletions(-) create mode 100644 test/generate-openclaw-config-inference-registry.test.ts diff --git a/src/lib/inference/config.test.ts b/src/lib/inference/config.test.ts index c1bc328c7d2..08a822bbca2 100644 --- a/src/lib/inference/config.test.ts +++ b/src/lib/inference/config.test.ts @@ -276,11 +276,7 @@ describe("getSandboxInferenceConfig", () => { it("leaves Kimi K2.7 Code compat to the model-specific setup registry", () => { expect( - getSandboxInferenceConfig( - "moonshotai/kimi-k2.7-code", - "nvidia-prod", - "openai-completions", - ), + getSandboxInferenceConfig("moonshotai/kimi-k2.7-code", "nvidia-prod", "openai-completions"), ).toEqual({ providerKey: MANAGED_PROVIDER_ID, primaryModelRef: `${MANAGED_PROVIDER_ID}/moonshotai/kimi-k2.7-code`, diff --git a/test/generate-openclaw-config-inference-registry.test.ts b/test/generate-openclaw-config-inference-registry.test.ts new file mode 100644 index 00000000000..fbd728775ff --- /dev/null +++ b/test/generate-openclaw-config-inference-registry.test.ts @@ -0,0 +1,47 @@ +// @ts-nocheck +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Focused managed-inference registry coverage split out of +// generate-openclaw-config.test.ts to keep that legacy file under its +// test-file-size budget. + +import { describe, expect, it } from "vitest"; + +import { buildConfig } from "../scripts/generate-openclaw-config.mts"; + +const BASE_ENV: Record = { + NEMOCLAW_PROVIDER_KEY: "inference", + NEMOCLAW_INFERENCE_BASE_URL: "https://inference.local/v1", + NEMOCLAW_INFERENCE_API: "openai-completions", + NEMOCLAW_INFERENCE_COMPAT_B64: Buffer.from("null").toString("base64"), + NEMOCLAW_PROXY_HOST: "10.200.0.1", + NEMOCLAW_PROXY_PORT: "3128", + NEMOCLAW_CONTEXT_WINDOW: "131072", + NEMOCLAW_MAX_TOKENS: "4096", + NEMOCLAW_REASONING: "false", + NEMOCLAW_AGENT_TIMEOUT: "600", + HOME: "/tmp", +}; + +function buildInferenceConfig(model: string): any { + return buildConfig({ + ...BASE_ENV, + NEMOCLAW_MODEL: model, + NEMOCLAW_PRIMARY_MODEL_REF: `inference/${model}`, + } as any); +} + +describe("generate-openclaw-config.mts: managed inference registry", () => { + it("adds Kimi K2.7 Code compat through the existing managed inference setup", () => { + const config = buildInferenceConfig("moonshotai/kimi-k2.7-code"); + + expect(config.models.providers.inference.models[0].compat).toEqual({ + supportsStore: false, + requiresStringContent: true, + maxTokensField: "max_tokens", + requiresToolResultName: true, + }); + expect(config.plugins.entries["nemoclaw-kimi-inference-compat"]).toEqual({ enabled: true }); + }); +}); diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index 443e4345f06..d17603ddb09 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -1275,27 +1275,6 @@ describe("generate-openclaw-config.mts: config generation", () => { }); }); - it("adds Kimi K2.7 Code compat through the existing managed inference setup", () => { - const config = runConfigScript({ - NEMOCLAW_MODEL: "moonshotai/kimi-k2.7-code", - NEMOCLAW_PROVIDER_KEY: "inference", - NEMOCLAW_PRIMARY_MODEL_REF: "inference/moonshotai/kimi-k2.7-code", - NEMOCLAW_INFERENCE_BASE_URL: "https://inference.local/v1", - NEMOCLAW_INFERENCE_API: "openai-completions", - NEMOCLAW_INFERENCE_COMPAT_B64: Buffer.from("null").toString("base64"), - }); - - expect(config.models.providers.inference.models[0].compat).toEqual({ - supportsStore: false, - requiresStringContent: true, - maxTokensField: "max_tokens", - requiresToolResultName: true, - }); - expect(config.plugins.entries["nemoclaw-kimi-inference-compat"]).toEqual({ - enabled: true, - }); - }); - it("rejects inference compat blobs that decode to non-object JSON", () => { expectBuildConfigError( { NEMOCLAW_INFERENCE_COMPAT_B64: Buffer.from('"not-an-object"').toString("base64") }, diff --git a/test/onboard-selection.test.ts b/test/onboard-selection.test.ts index 4919dd29689..6ac5b04d06b 100644 --- a/test/onboard-selection.test.ts +++ b/test/onboard-selection.test.ts @@ -389,7 +389,6 @@ runner.runCapture = (command) => { registry.updateSandbox = (_name, update) => updates.push(update); const { setupNim } = require(${onboardPath}); - (async () => { const originalLog = console.log; const lines = []; From 4a0eb57f3ba3548d7c8e7d64ba9492886ae5055f Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Mon, 22 Jun 2026 20:20:20 -0700 Subject: [PATCH 3/8] fix(inference): gate Kimi K2.7 curated rollout --- docs/inference/inference-options.mdx | 6 +- .../model-specific-setup/README.md | 2 +- .../openclaw/kimi-k2.6-managed-inference.json | 2 +- .../references/inference-options.md | 4 +- src/lib/inference/config.test.ts | 8 +- src/lib/inference/config.ts | 8 +- src/lib/inference/health.test.ts | 31 ----- src/lib/inference/kimi-health.test.ts | 41 ++++++ src/lib/inference/kimi-onboard-probes.test.ts | 32 +++++ src/lib/inference/model-prompts.test.ts | 62 +++++++++ src/lib/inference/model-prompts.ts | 118 +++++++++++------- src/lib/inference/onboard-probes.test.ts | 21 ---- test/kimi-inference-compat-plugin.test.ts | 15 ++- 13 files changed, 242 insertions(+), 108 deletions(-) create mode 100644 src/lib/inference/kimi-health.test.ts create mode 100644 src/lib/inference/kimi-onboard-probes.test.ts diff --git a/docs/inference/inference-options.mdx b/docs/inference/inference-options.mdx index 51e71750c25..ea475a5e4aa 100644 --- a/docs/inference/inference-options.mdx +++ b/docs/inference/inference-options.mdx @@ -64,13 +64,13 @@ The managed install/start vLLM entry appears by default on DGX Spark and DGX Sta | Option | Description | Curated models | |--------|-------------|----------------| -| NVIDIA Endpoints | Routes to models hosted on [build.nvidia.com](https://build.nvidia.com). You can also enter any model ID from the catalog. Set `NVIDIA_INFERENCE_API_KEY`. | Nemotron 3 Super 120B, Nemotron 3 Ultra 550B, GLM-5.1, MiniMax M2.7, Kimi K2.7 Code, GPT-OSS 120B, DeepSeek V4 Pro | +| NVIDIA Endpoints | Routes to models hosted on [build.nvidia.com](https://build.nvidia.com). You can also enter any model ID from the catalog. Set `NVIDIA_INFERENCE_API_KEY`. | Nemotron 3 Super 120B, Nemotron 3 Ultra 550B, GLM-5.1, MiniMax M2.7, Kimi K2.7 Code when the authenticated `/v1/models` catalog lists it, GPT-OSS 120B, DeepSeek V4 Pro | | OpenAI | Routes to the OpenAI API. Set `OPENAI_API_KEY`. | `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.4-pro-2026-03-05` | | Other OpenAI-compatible endpoint | Routes to any server that implements `/v1/chat/completions`. NemoClaw uses `/v1/chat/completions` at runtime by default; set `NEMOCLAW_PREFERRED_API=openai-responses` to allow `/v1/responses` for proxies that implement it, such as some llama.cpp builds. The wizard prompts for a base URL and model name. Works with OpenRouter, LocalAI, llama.cpp, or any compatible proxy. When you enable Telegram messaging, onboarding also runs a bounded sandbox-side smoke check through `https://inference.local/v1/chat/completions`. Set `COMPATIBLE_API_KEY`. | You provide the model name. | | Anthropic | Routes to the Anthropic Messages API. Set `ANTHROPIC_API_KEY`. | `claude-sonnet-4-6`, `claude-haiku-4-5`, `claude-opus-4-6` | | Other Anthropic-compatible endpoint | Routes to any server that implements the Anthropic Messages API (`/v1/messages`). The wizard prompts for a base URL and model name. Set `COMPATIBLE_ANTHROPIC_API_KEY`. | You provide the model name. | | Google Gemini | Routes to Google's OpenAI-compatible chat-completions endpoint. NemoClaw skips the Responses-API probe because Gemini does not support `/v1/responses`. Set `GEMINI_API_KEY`. | `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite-preview`, `gemini-3-flash-preview`, `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite` | -| Hermes Provider | Routes Hermes Agent through the host OpenShell provider registered by NemoClaw when onboarding Hermes Agent. | Curated Hermes Provider models such as `moonshotai/kimi-k2.7-code`, `openai/gpt-5.4-mini`, and `z-ai/glm-5.1`. | +| Hermes Provider | Routes Hermes Agent through the host OpenShell provider registered by NemoClaw when onboarding Hermes Agent. | Curated Hermes Provider models such as `moonshotai/kimi-k2.6`, `openai/gpt-5.4-mini`, and `z-ai/glm-5.1`. | | Local Ollama | Routes to a local Ollama instance on `localhost:11434`. NemoClaw detects installed models, offers starter models if none are present, pulls and warms the selected model, and validates it. | Selected during onboarding. For more information, refer to [Use a Local Inference Server](use-local-inference). | | Model Router | Starts a host-side router on port `4000`, registers it as an OpenAI-compatible provider, and keeps the sandbox pointed at `inference.local`. Set `NEMOCLAW_PROVIDER=routed` for non-interactive setup. | The router pool defines the model names. | @@ -192,7 +192,7 @@ Other provider credentials, such as `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMI | Google Gemini | Validates through Gemini's OpenAI-compatible chat-completions path only; NemoClaw skips the `/v1/responses` probe because Gemini does not support the Responses API. | | Other OpenAI-compatible endpoint | Tries `/v1/responses` first with a tool-calling probe; falls back to `/v1/chat/completions`. Selected runtime API defaults to `/v1/chat/completions`; set `NEMOCLAW_PREFERRED_API=openai-responses` to allow `/v1/responses` at runtime when validation succeeds. | | Anthropic-compatible | Tries `/v1/messages`. | -| NVIDIA Endpoints (manual model entry) | Validates the model name against the catalog API. | +| NVIDIA Endpoints (catalog-gated curated models and manual model entry) | Validates the model name against the catalog API before accepting models whose availability is still rolling out. | | Compatible endpoints | Sends a real inference request because many proxies do not expose a `/models` endpoint. For OpenAI-compatible endpoints, the probe tries `/v1/responses` first then falls back to `/v1/chat/completions`; the selected runtime API defaults to `/v1/chat/completions`. Set `NEMOCLAW_PREFERRED_API=openai-responses` to allow `/v1/responses` at runtime when validation succeeds. | | Local NVIDIA NIM | Validates through `/v1/chat/completions` only; NemoClaw skips the `/v1/responses` probe (same as NVIDIA Endpoints). | diff --git a/nemoclaw-blueprint/model-specific-setup/README.md b/nemoclaw-blueprint/model-specific-setup/README.md index 73bd8dadd77..ebf13d27031 100644 --- a/nemoclaw-blueprint/model-specific-setup/README.md +++ b/nemoclaw-blueprint/model-specific-setup/README.md @@ -20,7 +20,7 @@ Manifests follow `schema.json`: - `match`: model/provider route predicates. - `effects`: declarative, agent-scoped effects. -The first OpenClaw entry is `openclaw/kimi-k2.6-managed-inference.json`. It preserves the Kimi K2.6 managed `inference.local` compatibility behavior from PR #3046 and also covers later Kimi Code variants that need the same OpenClaw route handling. +The first OpenClaw entry is `openclaw/kimi-k2.6-managed-inference.json`. It preserves the Kimi K2.6 managed `inference.local` compatibility behavior from PR #3046 and also covers later Kimi Code variants that need the same OpenClaw route handling. The Kimi K2.7 Code entry is a compatibility-prep path only: keep its curated NVIDIA Endpoints exposure catalog-gated until the authenticated NVIDIA `/v1/models` catalog and chat-completions route both list it, and remove the shared compatibility entry when Kimi returns discrete OpenClaw-safe tool calls without reasoning fields on the managed `inference.local` route. ## Contributor Guidance diff --git a/nemoclaw-blueprint/model-specific-setup/openclaw/kimi-k2.6-managed-inference.json b/nemoclaw-blueprint/model-specific-setup/openclaw/kimi-k2.6-managed-inference.json index 2117b9ceba5..1fa952946e3 100644 --- a/nemoclaw-blueprint/model-specific-setup/openclaw/kimi-k2.6-managed-inference.json +++ b/nemoclaw-blueprint/model-specific-setup/openclaw/kimi-k2.6-managed-inference.json @@ -2,7 +2,7 @@ "$schema": "../schema.json", "id": "kimi-k2.6-managed-inference", "agent": "openclaw", - "description": "Preserves OpenClaw request, tool-call, and reasoning-output compatibility for Kimi K2.6 and Kimi K2.7 Code through NemoClaw managed inference.local chat completions.", + "description": "Preserves OpenClaw request, tool-call, and reasoning-output compatibility for Kimi K2.6 and catalog-gated Kimi K2.7 Code through NemoClaw managed inference.local chat completions; remove the K2.7 match when it emits discrete OpenClaw-safe tool calls without reasoning fields.", "match": { "modelIds": ["moonshotai/kimi-k2.6", "moonshotai/kimi-k2.7-code"], "providerKey": "inference", diff --git a/skills/nemoclaw-user-configure-inference/references/inference-options.md b/skills/nemoclaw-user-configure-inference/references/inference-options.md index 513f64a1bf5..659d6ef075c 100644 --- a/skills/nemoclaw-user-configure-inference/references/inference-options.md +++ b/skills/nemoclaw-user-configure-inference/references/inference-options.md @@ -53,7 +53,7 @@ The managed install/start vLLM entry appears by default on DGX Spark and DGX Sta | Option | Description | Curated models | |--------|-------------|----------------| -| NVIDIA Endpoints | Routes to models hosted on [build.nvidia.com](https://build.nvidia.com). You can also enter any model ID from the catalog. Set `NVIDIA_API_KEY`. | Nemotron 3 Super 120B, Nemotron 3 Ultra 550B, GLM-5.1, MiniMax M2.7, GPT-OSS 120B, DeepSeek V4 Pro | +| NVIDIA Endpoints | Routes to models hosted on [build.nvidia.com](https://build.nvidia.com). You can also enter any model ID from the catalog. Set `NVIDIA_API_KEY`. | Nemotron 3 Super 120B, Nemotron 3 Ultra 550B, GLM-5.1, MiniMax M2.7, Kimi K2.7 Code when the authenticated `/v1/models` catalog lists it, GPT-OSS 120B, DeepSeek V4 Pro | | OpenAI | Routes to the OpenAI API. Set `OPENAI_API_KEY`. | `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.4-pro-2026-03-05` | | Other OpenAI-compatible endpoint | Routes to any server that implements `/v1/chat/completions`. NemoClaw uses `/v1/chat/completions` at runtime by default; set `NEMOCLAW_PREFERRED_API=openai-responses` to allow `/v1/responses` for proxies that implement it, such as some llama.cpp builds. The wizard prompts for a base URL and model name. Works with OpenRouter, LocalAI, llama.cpp, or any compatible proxy. When you enable Telegram messaging, onboarding also runs a bounded sandbox-side smoke check through `https://inference.local/v1/chat/completions`. Set `COMPATIBLE_API_KEY`. | You provide the model name. | | Anthropic | Routes to the Anthropic Messages API. Set `ANTHROPIC_API_KEY`. | `claude-sonnet-4-6`, `claude-haiku-4-5`, `claude-opus-4-6` | @@ -181,7 +181,7 @@ Other provider credentials, such as `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMI | Google Gemini | Validates through Gemini's OpenAI-compatible chat-completions path only; NemoClaw skips the `/v1/responses` probe because Gemini does not support the Responses API. | | Other OpenAI-compatible endpoint | Tries `/v1/responses` first with a tool-calling probe; falls back to `/v1/chat/completions`. Selected runtime API defaults to `/v1/chat/completions`; set `NEMOCLAW_PREFERRED_API=openai-responses` to allow `/v1/responses` at runtime when validation succeeds. | | Anthropic-compatible | Tries `/v1/messages`. | -| NVIDIA Endpoints (manual model entry) | Validates the model name against the catalog API. | +| NVIDIA Endpoints (catalog-gated curated models and manual model entry) | Validates the model name against the catalog API before accepting models whose availability is still rolling out. | | Compatible endpoints | Sends a real inference request because many proxies do not expose a `/models` endpoint. For OpenAI-compatible endpoints, the probe tries `/v1/responses` first then falls back to `/v1/chat/completions`; the selected runtime API defaults to `/v1/chat/completions`. Set `NEMOCLAW_PREFERRED_API=openai-responses` to allow `/v1/responses` at runtime when validation succeeds. | | Local NVIDIA NIM | Validates through `/v1/chat/completions` only; NemoClaw skips the `/v1/responses` probe (same as NVIDIA Endpoints). | diff --git a/src/lib/inference/config.test.ts b/src/lib/inference/config.test.ts index 08a822bbca2..5ff48643d68 100644 --- a/src/lib/inference/config.test.ts +++ b/src/lib/inference/config.test.ts @@ -36,13 +36,17 @@ describe("inference selection config", () => { "openai/gpt-oss-120b", "deepseek-ai/deepseek-v4-pro", ]); + expect(CLOUD_MODEL_OPTIONS[5]).toMatchObject({ + id: "moonshotai/kimi-k2.7-code", + requiresCatalogValidation: true, + }); expect(DEFAULT_CLOUD_MODEL).toBe("nvidia/nemotron-3-super-120b-a12b"); }); it("aligns Hermes Provider defaults with the Hermes Agent Nous catalog", () => { - expect(DEFAULT_HERMES_PROVIDER_MODEL).toBe("moonshotai/kimi-k2.7-code"); + expect(DEFAULT_HERMES_PROVIDER_MODEL).toBe("moonshotai/kimi-k2.6"); expect(HERMES_PROVIDER_MODEL_OPTIONS.slice(0, 10)).toEqual([ - "moonshotai/kimi-k2.7-code", + "moonshotai/kimi-k2.6", "xiaomi/mimo-v2.5-pro", "xiaomi/mimo-v2.5", "tencent/hy3-preview", diff --git a/src/lib/inference/config.ts b/src/lib/inference/config.ts index a78cc00c0e4..75f0ebc7ee1 100644 --- a/src/lib/inference/config.ts +++ b/src/lib/inference/config.ts @@ -20,7 +20,7 @@ export const DEFAULT_CLOUD_MODEL = "nvidia/nemotron-3-super-120b-a12b"; // separately (cloud context-window registry). export const DEFAULT_CONTEXT_WINDOW = 131072; export const HERMES_PROVIDER_MODEL_OPTIONS = [ - "moonshotai/kimi-k2.7-code", + "moonshotai/kimi-k2.6", "xiaomi/mimo-v2.5-pro", "xiaomi/mimo-v2.5", "tencent/hy3-preview", @@ -58,7 +58,11 @@ export const CLOUD_MODEL_OPTIONS = [ { id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", label: "Nemotron 3 Nano Omni 30B" }, { id: "z-ai/glm-5.1", label: "GLM-5" }, { id: "minimaxai/minimax-m2.7", label: "MiniMax M2.7" }, - { id: "moonshotai/kimi-k2.7-code", label: "Kimi K2.7 Code" }, + { + id: "moonshotai/kimi-k2.7-code", + label: "Kimi K2.7 Code", + requiresCatalogValidation: true, + }, { id: "openai/gpt-oss-120b", label: "GPT-OSS 120B" }, { id: "deepseek-ai/deepseek-v4-pro", label: "DeepSeek V4 Pro" }, ]; diff --git a/src/lib/inference/health.test.ts b/src/lib/inference/health.test.ts index 48a4eb82b8d..6e3c6f8569a 100644 --- a/src/lib/inference/health.test.ts +++ b/src/lib/inference/health.test.ts @@ -247,37 +247,6 @@ describe("inference health", () => { }); }); - it("uses Kimi K2.7 Code chat completions without K2.6 thinking suppression", () => { - let capturedArgv: string[] = []; - const result = probeRemoteProviderHealth("nvidia-prod", { - model: "moonshotai/kimi-k2.7-code", - getCredentialImpl: (envName) => - envName === "NVIDIA_INFERENCE_API_KEY" ? "nvapi-test" : null, - runCurlProbeImpl: (argv) => { - capturedArgv = argv; - return { - ok: true, - httpStatus: 200, - curlStatus: 0, - body: '{"choices":[{"message":{"content":"OK"}}]}', - stderr: "", - message: "HTTP 200", - }; - }, - }); - - expect(result?.ok).toBe(true); - expect(result?.probed).toBe(true); - expect(result?.detail).toContain("Kimi K2.7 Code chat-completions route"); - expect(capturedArgv.at(-1)).toBe(`${BUILD_ENDPOINT_URL}/chat/completions`); - const payload = JSON.parse(capturedArgv[capturedArgv.indexOf("-d") + 1]); - expect(payload).toEqual({ - model: "moonshotai/kimi-k2.7-code", - messages: [{ role: "user", content: "Reply with exactly: OK" }], - max_tokens: 8, - }); - }); - it("does not fall back to provider-level NVIDIA /models for Kimi without a credential", () => { let called = false; const result = probeRemoteProviderHealth("nvidia-prod", { diff --git a/src/lib/inference/kimi-health.test.ts b/src/lib/inference/kimi-health.test.ts new file mode 100644 index 00000000000..3aebae5ce71 --- /dev/null +++ b/src/lib/inference/kimi-health.test.ts @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +// Import from compiled dist/ for correct coverage attribution. +import { probeRemoteProviderHealth } from "../../../dist/lib/inference/health"; +import { BUILD_ENDPOINT_URL } from "../../../dist/lib/inference/provider-models"; + +describe("Kimi NVIDIA Endpoints health probes", () => { + it("uses Kimi K2.7 Code chat completions without K2.6 thinking suppression", () => { + let capturedArgv: string[] = []; + const result = probeRemoteProviderHealth("nvidia-prod", { + model: "moonshotai/kimi-k2.7-code", + getCredentialImpl: (envName) => + envName === "NVIDIA_INFERENCE_API_KEY" ? "nvapi-test" : null, + runCurlProbeImpl: (argv) => { + capturedArgv = argv; + return { + ok: true, + httpStatus: 200, + curlStatus: 0, + body: '{"choices":[{"message":{"content":"OK"}}]}', + stderr: "", + message: "HTTP 200", + }; + }, + }); + + expect(result?.ok).toBe(true); + expect(result?.probed).toBe(true); + expect(result?.detail).toContain("Kimi K2.7 Code chat-completions route"); + expect(capturedArgv.at(-1)).toBe(`${BUILD_ENDPOINT_URL}/chat/completions`); + const payload = JSON.parse(capturedArgv[capturedArgv.indexOf("-d") + 1]); + expect(payload).toEqual({ + model: "moonshotai/kimi-k2.7-code", + messages: [{ role: "user", content: "Reply with exactly: OK" }], + max_tokens: 8, + }); + }); +}); diff --git a/src/lib/inference/kimi-onboard-probes.test.ts b/src/lib/inference/kimi-onboard-probes.test.ts new file mode 100644 index 00000000000..19ea2212b8b --- /dev/null +++ b/src/lib/inference/kimi-onboard-probes.test.ts @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +const { + getChatCompletionsProbeCurlArgs, + getChatCompletionsProbePayload, +} = require("../../../dist/lib/inference/onboard-probes"); + +describe("Kimi NVIDIA Endpoints onboarding probes", () => { + it("uses the Kimi validation budget for Kimi K2.7 Code without K2.6 thinking suppression", () => { + expect(getChatCompletionsProbePayload("moonshotai/kimi-k2.7-code")).toEqual({ + model: "moonshotai/kimi-k2.7-code", + messages: [{ role: "user", content: "Reply with exactly: OK" }], + max_tokens: 8, + }); + + const args = getChatCompletionsProbeCurlArgs({ + authHeader: ["-H", "Authorization: Bearer nvapi-test"], + model: "moonshotai/kimi-k2.7-code", + url: "https://integrate.api.nvidia.com/v1/chat/completions", + isWsl: false, + }); + + expect(args).toContain("--max-time"); + expect(args[args.indexOf("--max-time") + 1]).toBe("60"); + expect(args).toContain( + JSON.stringify(getChatCompletionsProbePayload("moonshotai/kimi-k2.7-code")), + ); + }); +}); diff --git a/src/lib/inference/model-prompts.test.ts b/src/lib/inference/model-prompts.test.ts index 3c6682c8953..8cca57ac64f 100644 --- a/src/lib/inference/model-prompts.test.ts +++ b/src/lib/inference/model-prompts.test.ts @@ -45,12 +45,74 @@ describe("model prompt helpers", () => { it("returns Kimi K2.7 Code from the default cloud model menu", async () => { const promptFn = promptSequence(["6"]); + const validateNvidiaEndpointModelFn = vi.fn(() => ({ ok: true })); const result = await promptCloudModel({ promptFn, writeLine: vi.fn(), + getCredentialFn: () => "nvapi-test", + validateNvidiaEndpointModelFn, }); expect(result).toBe("moonshotai/kimi-k2.7-code"); + expect(validateNvidiaEndpointModelFn).toHaveBeenCalledWith( + "moonshotai/kimi-k2.7-code", + "nvapi-test", + ); + }); + + it("rejects catalog-gated curated cloud models when NVIDIA /models omits them", async () => { + const promptFn = promptSequence(["2", "1"]); + const errorLine = vi.fn(); + const result = await promptCloudModel({ + promptFn, + errorLine, + writeLine: vi.fn(), + getCredentialFn: () => "nvapi-test", + cloudModelOptions: [ + { id: "nemotron", label: "Nemotron" }, + { + id: "moonshotai/kimi-k2.7-code", + label: "Kimi K2.7 Code", + requiresCatalogValidation: true, + }, + ], + validateNvidiaEndpointModelFn: (model) => ({ + ok: model !== "moonshotai/kimi-k2.7-code", + message: `Model '${model}' is not available from NVIDIA Endpoints. Checked https://integrate.api.nvidia.com/v1/models.`, + }), + }); + + expect(result).toBe("nemotron"); + expect(errorLine).toHaveBeenCalledWith( + " Model 'moonshotai/kimi-k2.7-code' is not available from NVIDIA Endpoints. Checked https://integrate.api.nvidia.com/v1/models.", + ); + }); + + it("does not keep defaulting to a rejected catalog-gated cloud model", async () => { + const promptFn = promptSequence(["", ""]); + const result = await promptCloudModel({ + promptFn, + errorLine: vi.fn(), + writeLine: vi.fn(), + defaultModelId: "moonshotai/kimi-k2.7-code", + getCredentialFn: () => "nvapi-test", + cloudModelOptions: [ + { id: "nemotron", label: "Nemotron" }, + { + id: "moonshotai/kimi-k2.7-code", + label: "Kimi K2.7 Code", + requiresCatalogValidation: true, + }, + ], + validateNvidiaEndpointModelFn: () => ({ + ok: false, + message: "Model 'moonshotai/kimi-k2.7-code' is not available from NVIDIA Endpoints.", + }), + }); + + expect(result).toBe("nemotron"); + expect(promptFn).toHaveBeenNthCalledWith(1, " Choose model [2]: "); + expect(promptFn).toHaveBeenNthCalledWith(2, " Choose model [1]: "); }); it("validates manual cloud model ids against the saved NVIDIA key", async () => { diff --git a/src/lib/inference/model-prompts.ts b/src/lib/inference/model-prompts.ts index ad173656c62..f6bda63617a 100644 --- a/src/lib/inference/model-prompts.ts +++ b/src/lib/inference/model-prompts.ts @@ -14,6 +14,11 @@ const { getCredential, prompt } = require("../credentials/store"); export type { BackToSelection }; export { BACK_TO_SELECTION }; export type ModelPromptResult = string | BackToSelection; +export type CloudModelOption = { + id: string; + label: string; + requiresCatalogValidation?: boolean; +}; export const REMOTE_MODEL_OPTIONS: Record = { openai: ["gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano", "gpt-5.4-pro-2026-03-05"], @@ -43,7 +48,7 @@ export interface ModelPromptOptions { getNavigationChoiceFn?: (value?: string) => "back" | "exit" | null; getCredentialFn?: (envName: string) => string | null; validateNvidiaEndpointModelFn?: (model: string, apiKey: string) => PromptValidationResult; - cloudModelOptions?: Array<{ id: string; label: string }>; + cloudModelOptions?: CloudModelOption[]; remoteModelOptions?: Record; backToSelection?: BackToSelection; /** Pre-fill this model ID as the default in interactive prompts. */ @@ -133,55 +138,80 @@ export async function promptCloudModel( ): Promise { const deps = resolvePromptOptions(options); const defaultModelId = options.defaultModelId ?? ""; + const catalogRejectedModelIds = new Set(); + while (true) { + // Find if the default matches a curated option + const defaultCuratedIdx = defaultModelId + ? deps.cloudModelOptions.findIndex( + (o) => o.id === defaultModelId && !catalogRejectedModelIds.has(o.id), + ) + : -1; + // Default list selection: match defaultModelId, or fall back to first option (index 0) + const defaultListChoice = defaultCuratedIdx >= 0 ? defaultCuratedIdx + 1 : 1; - // Find if the default matches a curated option - const defaultCuratedIdx = defaultModelId - ? deps.cloudModelOptions.findIndex((o) => o.id === defaultModelId) - : -1; - // Default list selection: match defaultModelId, or fall back to first option (index 0) - const defaultListChoice = defaultCuratedIdx >= 0 ? defaultCuratedIdx + 1 : 1; + deps.writeLine(""); + deps.writeLine(" Cloud models:"); + deps.cloudModelOptions.forEach((option, index) => { + deps.writeLine(` ${index + 1}) ${option.label} (${option.id})`); + }); + deps.writeLine(` ${deps.cloudModelOptions.length + 1}) Other...`); + deps.writeLine(""); - deps.writeLine(""); - deps.writeLine(" Cloud models:"); - deps.cloudModelOptions.forEach((option, index) => { - deps.writeLine(` ${index + 1}) ${option.label} (${option.id})`); - }); - deps.writeLine(` ${deps.cloudModelOptions.length + 1}) Other...`); - deps.writeLine(""); + const choice = await deps.promptFn(` Choose model [${defaultListChoice}]: `); + const navigation = deps.getNavigationChoiceFn(choice); + if (navigation === "back") { + return deps.backToSelection; + } + if (navigation === "exit") { + deps.exitFn(); + } + const index = parseInt(choice || String(defaultListChoice), 10) - 1; + if (Number.isFinite(index) && index >= 0 && index < deps.cloudModelOptions.length) { + const selected = deps.cloudModelOptions[index]; + if (!selected.requiresCatalogValidation) { + return selected.id; + } + const nvidiaApiKey = deps.getCredentialFn("NVIDIA_INFERENCE_API_KEY"); + if (!nvidiaApiKey) { + deps.errorLine( + ` NVIDIA_INFERENCE_API_KEY is required before selecting ${selected.label}; NemoClaw must first confirm it appears in the NVIDIA Endpoints catalog.`, + ); + return deps.backToSelection; + } + const validation = deps.validateNvidiaEndpointModelFn(selected.id, nvidiaApiKey); + if (validation.ok) { + return selected.id; + } + deps.errorLine( + ` ${validation.message || `${selected.label} is not currently available from NVIDIA Endpoints.`}`, + ); + catalogRejectedModelIds.add(selected.id); + continue; + } - const choice = await deps.promptFn(` Choose model [${defaultListChoice}]: `); - const navigation = deps.getNavigationChoiceFn(choice); - if (navigation === "back") { - return deps.backToSelection; - } - if (navigation === "exit") { - deps.exitFn(); - } - const index = parseInt(choice || String(defaultListChoice), 10) - 1; - if (Number.isFinite(index) && index >= 0 && index < deps.cloudModelOptions.length) { - return deps.cloudModelOptions[index].id; - } + const nvidiaApiKey = deps.getCredentialFn("NVIDIA_INFERENCE_API_KEY"); + if (!nvidiaApiKey) { + deps.errorLine( + " NVIDIA_INFERENCE_API_KEY is required before validating a custom NVIDIA Endpoints model.", + ); + return deps.backToSelection; + } - const nvidiaApiKey = deps.getCredentialFn("NVIDIA_INFERENCE_API_KEY"); - if (!nvidiaApiKey) { - deps.errorLine( - " NVIDIA_INFERENCE_API_KEY is required before validating a custom NVIDIA Endpoints model.", + // If default is a custom (non-curated) model ID, pre-fill it in the manual prompt + const manualDefault = + defaultCuratedIdx < 0 && defaultModelId && isSafeModelId(defaultModelId) + ? defaultModelId + : ""; + const manualLabel = manualDefault + ? ` NVIDIA Endpoints model id [${manualDefault}]: ` + : " NVIDIA Endpoints model id: "; + return promptManualModelId( + manualLabel, + "NVIDIA Endpoints", + (model) => deps.validateNvidiaEndpointModelFn(model, nvidiaApiKey), + { ...deps, promptFn: async (q) => (await deps.promptFn(q)) || manualDefault }, ); - return deps.backToSelection; } - - // If default is a custom (non-curated) model ID, pre-fill it in the manual prompt - const manualDefault = - defaultCuratedIdx < 0 && defaultModelId && isSafeModelId(defaultModelId) ? defaultModelId : ""; - const manualLabel = manualDefault - ? ` NVIDIA Endpoints model id [${manualDefault}]: ` - : " NVIDIA Endpoints model id: "; - return promptManualModelId( - manualLabel, - "NVIDIA Endpoints", - (model) => deps.validateNvidiaEndpointModelFn(model, nvidiaApiKey), - { ...deps, promptFn: async (q) => (await deps.promptFn(q)) || manualDefault }, - ); } export async function promptRemoteModel( diff --git a/src/lib/inference/onboard-probes.test.ts b/src/lib/inference/onboard-probes.test.ts index c7f0e00dd71..7230f5fef3f 100644 --- a/src/lib/inference/onboard-probes.test.ts +++ b/src/lib/inference/onboard-probes.test.ts @@ -380,27 +380,6 @@ describe("OpenAI-compatible inference probes", () => { expect(args).toContain(JSON.stringify(getChatCompletionsProbePayload("moonshotai/kimi-k2.6"))); }); - it("uses the Kimi validation budget for Kimi K2.7 Code without K2.6 thinking suppression", () => { - expect(getChatCompletionsProbePayload("moonshotai/kimi-k2.7-code")).toEqual({ - model: "moonshotai/kimi-k2.7-code", - messages: [{ role: "user", content: "Reply with exactly: OK" }], - max_tokens: 8, - }); - - const args = getChatCompletionsProbeCurlArgs({ - authHeader: ["-H", "Authorization: Bearer nvapi-test"], - model: "moonshotai/kimi-k2.7-code", - url: "https://integrate.api.nvidia.com/v1/chat/completions", - isWsl: false, - }); - - expect(args).toContain("--max-time"); - expect(args[args.indexOf("--max-time") + 1]).toBe("60"); - expect(args).toContain( - JSON.stringify(getChatCompletionsProbePayload("moonshotai/kimi-k2.7-code")), - ); - }); - it("uses an extended streaming validation budget for DeepSeek V4 Pro", () => { expect(getDeepSeekV4ProValidationProbeCurlArgs({ isWsl: false })).toEqual([ "--connect-timeout", diff --git a/test/kimi-inference-compat-plugin.test.ts b/test/kimi-inference-compat-plugin.test.ts index 4f38560ca84..a228fc33142 100644 --- a/test/kimi-inference-compat-plugin.test.ts +++ b/test/kimi-inference-compat-plugin.test.ts @@ -449,7 +449,19 @@ describe("nemoclaw Kimi inference compat plugin", () => { }); it("matches Kimi K2.7 Code managed inference refs", async () => { - const message = toolMessage("hostname; date; uptime"); + // Compatibility-prep fixture for the K2.7 rollout: if the managed route + // returns the same OpenClaw-incompatible reasoning fields plus combined + // safe exec diagnostics observed on K2.6, this plugin keeps the sandbox + // route usable. Remove the K2.7 manifest match when the managed K2.7 route + // emits discrete OpenClaw-safe tool calls without reasoning fields. + const message = { + ...toolMessage("hostname; date; uptime"), + reasoning: "PRIVATE K2.7 reasoning", + content: [ + { type: "thinking", text: "PRIVATE K2.7 thinking block" }, + ...toolMessage("hostname; date; uptime").content, + ], + }; const provider = makeProvider(); const wrapper = provider.wrapStreamFn({ ...managedKimiCtx(() => ({ @@ -473,6 +485,7 @@ describe("nemoclaw Kimi inference compat plugin", () => { expect(result.content.map(toolCommand)).toEqual(["hostname", "date", "uptime"]); expect(JSON.stringify(result)).not.toContain("hostname; date; uptime"); + expect(JSON.stringify(result)).not.toContain("PRIVATE K2.7"); }); it("rewrites object tool-call deltas at their content index without retaining compound commands", () => { From 2d3912cf99c457ffddc0079dc0b960d036633363 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Mon, 22 Jun 2026 20:37:21 -0700 Subject: [PATCH 4/8] fix(inference): address Kimi rollout advisor feedback --- .../model-specific-setup/README.md | 2 +- .../openclaw/kimi-k2.6-managed-inference.json | 4 +- .../kimi-inference-compat/index.js | 2 +- src/lib/adapters/http/curl-auth-config.ts | 44 +++++++ src/lib/inference/config.test.ts | 2 +- src/lib/inference/health.ts | 42 +------ .../model-prompts-catalog-gate.test.ts | 107 ++++++++++++++++++ src/lib/inference/model-prompts.test.ts | 72 ------------ src/lib/inference/provider-models.test.ts | 15 ++- src/lib/inference/provider-models.ts | 34 ++++-- src/lib/onboard/setup-nim-selection.test.ts | 68 +++++++++++ ...openclaw-config-inference-registry.test.ts | 11 +- test/kimi-inference-compat-plugin.test.ts | 30 +---- 13 files changed, 279 insertions(+), 154 deletions(-) create mode 100644 src/lib/adapters/http/curl-auth-config.ts create mode 100644 src/lib/inference/model-prompts-catalog-gate.test.ts diff --git a/nemoclaw-blueprint/model-specific-setup/README.md b/nemoclaw-blueprint/model-specific-setup/README.md index ebf13d27031..83121c9fbf6 100644 --- a/nemoclaw-blueprint/model-specific-setup/README.md +++ b/nemoclaw-blueprint/model-specific-setup/README.md @@ -20,7 +20,7 @@ Manifests follow `schema.json`: - `match`: model/provider route predicates. - `effects`: declarative, agent-scoped effects. -The first OpenClaw entry is `openclaw/kimi-k2.6-managed-inference.json`. It preserves the Kimi K2.6 managed `inference.local` compatibility behavior from PR #3046 and also covers later Kimi Code variants that need the same OpenClaw route handling. The Kimi K2.7 Code entry is a compatibility-prep path only: keep its curated NVIDIA Endpoints exposure catalog-gated until the authenticated NVIDIA `/v1/models` catalog and chat-completions route both list it, and remove the shared compatibility entry when Kimi returns discrete OpenClaw-safe tool calls without reasoning fields on the managed `inference.local` route. +The first OpenClaw entry is `openclaw/kimi-k2.6-managed-inference.json`. It preserves the Kimi K2.6 managed `inference.local` compatibility behavior from PR #3046. Later Kimi variants, including Kimi K2.7 Code, must stay out of this manifest until captured or source-confirmed managed-route output proves they emit the same OpenClaw-incompatible reasoning/tool-call shape and documents the removal condition. ## Contributor Guidance diff --git a/nemoclaw-blueprint/model-specific-setup/openclaw/kimi-k2.6-managed-inference.json b/nemoclaw-blueprint/model-specific-setup/openclaw/kimi-k2.6-managed-inference.json index 1fa952946e3..b473cc853fe 100644 --- a/nemoclaw-blueprint/model-specific-setup/openclaw/kimi-k2.6-managed-inference.json +++ b/nemoclaw-blueprint/model-specific-setup/openclaw/kimi-k2.6-managed-inference.json @@ -2,9 +2,9 @@ "$schema": "../schema.json", "id": "kimi-k2.6-managed-inference", "agent": "openclaw", - "description": "Preserves OpenClaw request, tool-call, and reasoning-output compatibility for Kimi K2.6 and catalog-gated Kimi K2.7 Code through NemoClaw managed inference.local chat completions; remove the K2.7 match when it emits discrete OpenClaw-safe tool calls without reasoning fields.", + "description": "Preserves OpenClaw request, tool-call, and reasoning-output compatibility for Kimi K2.6 through NemoClaw managed inference.local chat completions. Add later Kimi variants only after captured or source-confirmed managed-route output proves they need the same shim.", "match": { - "modelIds": ["moonshotai/kimi-k2.6", "moonshotai/kimi-k2.7-code"], + "modelIds": ["moonshotai/kimi-k2.6"], "providerKey": "inference", "inferenceApi": "openai-completions", "baseUrl": "https://inference.local/v1" diff --git a/nemoclaw-blueprint/openclaw-plugins/kimi-inference-compat/index.js b/nemoclaw-blueprint/openclaw-plugins/kimi-inference-compat/index.js index 3b25c875efb..199a6be4a64 100644 --- a/nemoclaw-blueprint/openclaw-plugins/kimi-inference-compat/index.js +++ b/nemoclaw-blueprint/openclaw-plugins/kimi-inference-compat/index.js @@ -9,7 +9,7 @@ function normalizeBaseUrl(value) { return String(value || "").trim().replace(/\/+$/, ""); } -const KIMI_MODEL_IDS = new Set(["moonshotai/kimi-k2.6", "moonshotai/kimi-k2.7-code"]); +const KIMI_MODEL_IDS = new Set(["moonshotai/kimi-k2.6"]); function isKimiModelId(value) { const modelId = normalize(value); diff --git a/src/lib/adapters/http/curl-auth-config.ts b/src/lib/adapters/http/curl-auth-config.ts new file mode 100644 index 00000000000..4799b642a76 --- /dev/null +++ b/src/lib/adapters/http/curl-auth-config.ts @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const DEFAULT_CURL_AUTH_CONFIG_PREFIX = "nemoclaw-auth-curl"; + +function quoteCurlConfigValue(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/[\r\n]+/g, " "); +} + +export function createAuthCurlConfig( + headerValue: string, + prefix = DEFAULT_CURL_AUTH_CONFIG_PREFIX, +): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)); + try { + fs.chmodSync(dir, 0o700); + const configPath = path.join(dir, "auth.conf"); + fs.writeFileSync(configPath, `header = "${quoteCurlConfigValue(headerValue)}"\n`, { + mode: 0o600, + encoding: "utf8", + }); + return configPath; + } catch (error) { + fs.rmSync(dir, { recursive: true, force: true }); + throw error; + } +} + +export function cleanupAuthCurlConfig( + configPath: string, + prefix = DEFAULT_CURL_AUTH_CONFIG_PREFIX, +): void { + const dir = path.dirname(configPath); + if (dir !== os.tmpdir() && path.basename(dir).startsWith(`${prefix}-`)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} diff --git a/src/lib/inference/config.test.ts b/src/lib/inference/config.test.ts index 5ff48643d68..186306b2a81 100644 --- a/src/lib/inference/config.test.ts +++ b/src/lib/inference/config.test.ts @@ -278,7 +278,7 @@ describe("getSandboxInferenceConfig", () => { }); }); - it("leaves Kimi K2.7 Code compat to the model-specific setup registry", () => { + it("routes Kimi K2.7 Code through managed inference without the K2.6 compat shim", () => { expect( getSandboxInferenceConfig("moonshotai/kimi-k2.7-code", "nvidia-prod", "openai-completions"), ).toEqual({ diff --git a/src/lib/inference/health.ts b/src/lib/inference/health.ts index 866b8c92500..cac3adb8822 100644 --- a/src/lib/inference/health.ts +++ b/src/lib/inference/health.ts @@ -7,10 +7,7 @@ * and performs lightweight reachability checks for remote cloud providers. */ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - +import { cleanupAuthCurlConfig, createAuthCurlConfig } from "../adapters/http/curl-auth-config"; import type { CurlProbeOptions, CurlProbeResult } from "../adapters/http/probe"; import { runCurlProbe } from "../adapters/http/probe"; import { normalizeCredentialValue, resolveProviderCredential } from "../credentials/store"; @@ -99,36 +96,6 @@ function useStatusProbeTiming(argv: string[]): string[] { ); } -function quoteCurlConfigValue(value: string): string { - return value - .replace(/\\/g, "\\\\") - .replace(/"/g, '\\"') - .replace(/[\r\n]+/g, " "); -} - -function createAuthCurlConfig(headerValue: string): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${KIMI_HEALTH_CURL_CONFIG_PREFIX}-`)); - try { - fs.chmodSync(dir, 0o700); - const configPath = path.join(dir, "auth.conf"); - fs.writeFileSync(configPath, `header = "${quoteCurlConfigValue(headerValue)}"\n`, { - mode: 0o600, - encoding: "utf8", - }); - return configPath; - } catch (error) { - fs.rmSync(dir, { recursive: true, force: true }); - throw error; - } -} - -function cleanupAuthCurlConfig(configPath: string): void { - const dir = path.dirname(configPath); - if (dir !== os.tmpdir() && path.basename(dir).startsWith(`${KIMI_HEALTH_CURL_CONFIG_PREFIX}-`)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -} - function buildKimiStatusProbeCurlArgs( model: string, endpoint: string, @@ -239,7 +206,10 @@ function probeNvidiaKimiHealth( const runCurlProbeImpl = options.runCurlProbeImpl ?? runCurlProbe; let authConfigPath = ""; try { - authConfigPath = createAuthCurlConfig(`Authorization: Bearer ${apiKey}`); + authConfigPath = createAuthCurlConfig( + `Authorization: Bearer ${apiKey}`, + KIMI_HEALTH_CURL_CONFIG_PREFIX, + ); } catch (error) { const reason = error instanceof Error ? error.message : String(error); return { @@ -260,7 +230,7 @@ function probeNvidiaKimiHealth( { trustedConfigFiles: [authConfigPath] }, ); } finally { - cleanupAuthCurlConfig(authConfigPath); + cleanupAuthCurlConfig(authConfigPath, KIMI_HEALTH_CURL_CONFIG_PREFIX); } })(); const healthy = result.ok; diff --git a/src/lib/inference/model-prompts-catalog-gate.test.ts b/src/lib/inference/model-prompts-catalog-gate.test.ts new file mode 100644 index 00000000000..b7eaecd2dbe --- /dev/null +++ b/src/lib/inference/model-prompts-catalog-gate.test.ts @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { BACK_TO_SELECTION, promptCloudModel } from "../../../dist/lib/inference/model-prompts"; + +function promptSequence(responses: string[]) { + const queue = [...responses]; + return vi.fn(async () => queue.shift() ?? ""); +} + +describe("catalog-gated cloud model prompts", () => { + it("accepts Kimi K2.7 Code from the default cloud model menu only after catalog validation", async () => { + const promptFn = promptSequence(["6"]); + const validateNvidiaEndpointModelFn = vi.fn(() => ({ ok: true })); + const result = await promptCloudModel({ + promptFn, + writeLine: vi.fn(), + getCredentialFn: () => "nvapi-test", + validateNvidiaEndpointModelFn, + }); + + expect(result).toBe("moonshotai/kimi-k2.7-code"); + expect(validateNvidiaEndpointModelFn).toHaveBeenCalledWith( + "moonshotai/kimi-k2.7-code", + "nvapi-test", + ); + }); + + it("rejects catalog-gated curated cloud models when NVIDIA /models omits them", async () => { + const promptFn = promptSequence(["2", "1"]); + const errorLine = vi.fn(); + const result = await promptCloudModel({ + promptFn, + errorLine, + writeLine: vi.fn(), + getCredentialFn: () => "nvapi-test", + cloudModelOptions: [ + { id: "nemotron", label: "Nemotron" }, + { + id: "moonshotai/kimi-k2.7-code", + label: "Kimi K2.7 Code", + requiresCatalogValidation: true, + }, + ], + validateNvidiaEndpointModelFn: (model) => ({ + ok: model !== "moonshotai/kimi-k2.7-code", + message: `Model '${model}' is not available from NVIDIA Endpoints. Checked https://integrate.api.nvidia.com/v1/models.`, + }), + }); + + expect(result).toBe("nemotron"); + expect(errorLine).toHaveBeenCalledWith( + " Model 'moonshotai/kimi-k2.7-code' is not available from NVIDIA Endpoints. Checked https://integrate.api.nvidia.com/v1/models.", + ); + }); + + it("requires a local NVIDIA key before accepting catalog-gated curated models", async () => { + const errorLine = vi.fn(); + const result = await promptCloudModel({ + promptFn: promptSequence(["1"]), + errorLine, + writeLine: vi.fn(), + getCredentialFn: () => null, + cloudModelOptions: [ + { + id: "moonshotai/kimi-k2.7-code", + label: "Kimi K2.7 Code", + requiresCatalogValidation: true, + }, + ], + }); + + expect(result).toBe(BACK_TO_SELECTION); + expect(errorLine).toHaveBeenCalledWith( + " NVIDIA_INFERENCE_API_KEY is required before selecting Kimi K2.7 Code; NemoClaw must first confirm it appears in the NVIDIA Endpoints catalog.", + ); + }); + + it("does not keep defaulting to a rejected catalog-gated cloud model", async () => { + const promptFn = promptSequence(["", ""]); + const result = await promptCloudModel({ + promptFn, + errorLine: vi.fn(), + writeLine: vi.fn(), + defaultModelId: "moonshotai/kimi-k2.7-code", + getCredentialFn: () => "nvapi-test", + cloudModelOptions: [ + { id: "nemotron", label: "Nemotron" }, + { + id: "moonshotai/kimi-k2.7-code", + label: "Kimi K2.7 Code", + requiresCatalogValidation: true, + }, + ], + validateNvidiaEndpointModelFn: () => ({ + ok: false, + message: "Model 'moonshotai/kimi-k2.7-code' is not available from NVIDIA Endpoints.", + }), + }); + + expect(result).toBe("nemotron"); + expect(promptFn).toHaveBeenNthCalledWith(1, " Choose model [2]: "); + expect(promptFn).toHaveBeenNthCalledWith(2, " Choose model [1]: "); + }); +}); diff --git a/src/lib/inference/model-prompts.test.ts b/src/lib/inference/model-prompts.test.ts index 8cca57ac64f..0d574b4ae71 100644 --- a/src/lib/inference/model-prompts.test.ts +++ b/src/lib/inference/model-prompts.test.ts @@ -43,78 +43,6 @@ describe("model prompt helpers", () => { expect(result).toBe("deepseek-ai/deepseek-v4-pro"); }); - it("returns Kimi K2.7 Code from the default cloud model menu", async () => { - const promptFn = promptSequence(["6"]); - const validateNvidiaEndpointModelFn = vi.fn(() => ({ ok: true })); - const result = await promptCloudModel({ - promptFn, - writeLine: vi.fn(), - getCredentialFn: () => "nvapi-test", - validateNvidiaEndpointModelFn, - }); - - expect(result).toBe("moonshotai/kimi-k2.7-code"); - expect(validateNvidiaEndpointModelFn).toHaveBeenCalledWith( - "moonshotai/kimi-k2.7-code", - "nvapi-test", - ); - }); - - it("rejects catalog-gated curated cloud models when NVIDIA /models omits them", async () => { - const promptFn = promptSequence(["2", "1"]); - const errorLine = vi.fn(); - const result = await promptCloudModel({ - promptFn, - errorLine, - writeLine: vi.fn(), - getCredentialFn: () => "nvapi-test", - cloudModelOptions: [ - { id: "nemotron", label: "Nemotron" }, - { - id: "moonshotai/kimi-k2.7-code", - label: "Kimi K2.7 Code", - requiresCatalogValidation: true, - }, - ], - validateNvidiaEndpointModelFn: (model) => ({ - ok: model !== "moonshotai/kimi-k2.7-code", - message: `Model '${model}' is not available from NVIDIA Endpoints. Checked https://integrate.api.nvidia.com/v1/models.`, - }), - }); - - expect(result).toBe("nemotron"); - expect(errorLine).toHaveBeenCalledWith( - " Model 'moonshotai/kimi-k2.7-code' is not available from NVIDIA Endpoints. Checked https://integrate.api.nvidia.com/v1/models.", - ); - }); - - it("does not keep defaulting to a rejected catalog-gated cloud model", async () => { - const promptFn = promptSequence(["", ""]); - const result = await promptCloudModel({ - promptFn, - errorLine: vi.fn(), - writeLine: vi.fn(), - defaultModelId: "moonshotai/kimi-k2.7-code", - getCredentialFn: () => "nvapi-test", - cloudModelOptions: [ - { id: "nemotron", label: "Nemotron" }, - { - id: "moonshotai/kimi-k2.7-code", - label: "Kimi K2.7 Code", - requiresCatalogValidation: true, - }, - ], - validateNvidiaEndpointModelFn: () => ({ - ok: false, - message: "Model 'moonshotai/kimi-k2.7-code' is not available from NVIDIA Endpoints.", - }), - }); - - expect(result).toBe("nemotron"); - expect(promptFn).toHaveBeenNthCalledWith(1, " Choose model [2]: "); - expect(promptFn).toHaveBeenNthCalledWith(2, " Choose model [1]: "); - }); - it("validates manual cloud model ids against the saved NVIDIA key", async () => { const promptFn = promptSequence(["9", "bad-model", "nemotron-custom"]); const errorLine = vi.fn(); diff --git a/src/lib/inference/provider-models.test.ts b/src/lib/inference/provider-models.test.ts index 4b04748cbee..fda01a1395e 100644 --- a/src/lib/inference/provider-models.test.ts +++ b/src/lib/inference/provider-models.test.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import fs from "node:fs"; + import { describe, expect, it } from "vitest"; import { @@ -15,10 +17,18 @@ import { describe("provider model helpers", () => { it("fetches NVIDIA endpoint model ids", () => { + let authConfigPath = ""; const result = fetchNvidiaEndpointModels("nvapi-x", { - runCurlProbeImpl: (argv) => { + runCurlProbeImpl: (argv, opts) => { expect(argv.at(-1)).toBe(`${BUILD_ENDPOINT_URL}/models`); - expect(argv).toContain("Authorization: Bearer nvapi-x"); + expect(argv.join(" ")).not.toContain("Authorization: Bearer nvapi-x"); + const configIndex = argv.indexOf("--config"); + expect(configIndex).toBeGreaterThanOrEqual(0); + authConfigPath = argv[configIndex + 1]; + expect(opts?.trustedConfigFiles).toContain(authConfigPath); + expect(fs.readFileSync(authConfigPath, "utf8")).toContain( + 'header = "Authorization: Bearer nvapi-x"', + ); return { ok: true, httpStatus: 200, @@ -31,6 +41,7 @@ describe("provider model helpers", () => { }); expect(result).toEqual({ ok: true, ids: ["nemotron", "llama"] }); + expect(fs.existsSync(authConfigPath)).toBe(false); }); it("returns explicit validated=true for NVIDIA model matches", () => { diff --git a/src/lib/inference/provider-models.ts b/src/lib/inference/provider-models.ts index 2d219599fa6..7afbfcbc216 100644 --- a/src/lib/inference/provider-models.ts +++ b/src/lib/inference/provider-models.ts @@ -1,7 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { CurlProbeResult } from "../adapters/http/probe"; +import { cleanupAuthCurlConfig, createAuthCurlConfig } from "../adapters/http/curl-auth-config"; +import type { CurlProbeOptions, CurlProbeResult } from "../adapters/http/probe"; import { getCurlTimingArgs, runCurlProbe } from "../adapters/http/probe"; import type { ModelCatalogFetchResult, ModelValidationResult } from "../onboard/types"; @@ -9,9 +10,10 @@ import type { ModelCatalogFetchResult, ModelValidationResult } from "../onboard/ const { normalizeCredentialValue } = require("../credentials/store"); export const BUILD_ENDPOINT_URL = "https://integrate.api.nvidia.com/v1"; +const NVIDIA_MODELS_CURL_CONFIG_PREFIX = "nemoclaw-nvidia-models-curl"; export interface ProviderModelOptions { - runCurlProbeImpl?: (argv: string[]) => CurlProbeResult; + runCurlProbeImpl?: (argv: string[], opts?: CurlProbeOptions) => CurlProbeResult; buildEndpointUrl?: string; /** When "query-param", send the API key as a ?key= URL parameter instead of * an Authorization: Bearer header. Required for Google Gemini which rejects @@ -82,16 +84,24 @@ export function fetchNvidiaEndpointModels( ): ModelCatalogFetchResult { const runCurlProbeImpl = options.runCurlProbeImpl ?? runCurlProbe; const buildEndpointUrl = options.buildEndpointUrl ?? BUILD_ENDPOINT_URL; + let authConfigPath = ""; try { - const result = runCurlProbeImpl([ - "-sS", - ...getCurlTimingArgs(), - "-H", - "Content-Type: application/json", - "-H", + authConfigPath = createAuthCurlConfig( `Authorization: Bearer ${normalizeCredentialValue(apiKey)}`, - `${buildEndpointUrl}/models`, - ]); + NVIDIA_MODELS_CURL_CONFIG_PREFIX, + ); + const result = runCurlProbeImpl( + [ + "-sS", + ...getCurlTimingArgs(), + "-H", + "Content-Type: application/json", + "--config", + authConfigPath, + `${buildEndpointUrl}/models`, + ], + { trustedConfigFiles: [authConfigPath] }, + ); return toModelCatalogFetchResult(result); } catch (error) { return { @@ -100,6 +110,10 @@ export function fetchNvidiaEndpointModels( curlStatus: 0, message: error instanceof Error ? error.message : String(error), }; + } finally { + if (authConfigPath) { + cleanupAuthCurlConfig(authConfigPath, NVIDIA_MODELS_CURL_CONFIG_PREFIX); + } } } diff --git a/src/lib/onboard/setup-nim-selection.test.ts b/src/lib/onboard/setup-nim-selection.test.ts index 49b71d85bec..96c50c47626 100644 --- a/src/lib/onboard/setup-nim-selection.test.ts +++ b/src/lib/onboard/setup-nim-selection.test.ts @@ -141,4 +141,72 @@ describe("createRemoteModelValidator", () => { assert.equal(state.model, "nvidia/local-nim"); assert.equal(state.nimContainer, "nemoclaw-nim-test"); }); + + it("returns to provider selection when catalog-present K2.7 fails route validation", async () => { + const state = makeState(); + state.provider = "nvidia-prod"; + state.endpointUrl = "https://integrate.api.nvidia.com/v1"; + state.credentialEnv = "NVIDIA_INFERENCE_API_KEY"; + state.model = "moonshotai/kimi-k2.7-code"; + let validationCall: { + label: string; + endpointUrl: string; + model: string; + credentialEnv: string | null; + retryMessage?: string; + options?: Record; + } | null = null; + const { validateSelectedRemoteModel } = createRemoteModelValidator({ + OPENAI_ENDPOINT_URL: "https://default-openai.example/v1", + ANTHROPIC_ENDPOINT_URL: "https://default-anthropic.example/v1", + requireValue: (value, message) => { + if (value === null || value === undefined) throw new Error(message); + return value; + }, + isBackToSelection: (_value): _value is never => false, + validateCustomOpenAiLikeSelection: async () => ({ ok: false, retry: "selection" }), + validateCustomAnthropicSelection: async () => ({ ok: false, retry: "selection" }), + validateAnthropicSelectionWithRetryMessage: async () => ({ ok: false, retry: "selection" }), + validateOpenAiLikeSelection: async ( + label, + endpointUrl, + model, + credentialEnv, + retryMessage, + _helpUrl, + options, + ) => { + validationCall = { label, endpointUrl, model, credentialEnv, retryMessage, options }; + return { ok: false, retry: "selection" }; + }, + shouldRequireResponsesToolCalling: () => false, + shouldSkipResponsesProbe: () => false, + getProbeAuthMode: () => undefined, + }); + + const result = await validateSelectedRemoteModel({ + selected: { key: "build" }, + remoteConfig: { + label: "NVIDIA Endpoints", + endpointUrl: "https://integrate.api.nvidia.com/v1", + helpUrl: "https://build.nvidia.com/", + }, + state, + selectedCredentialEnv: "NVIDIA_INFERENCE_API_KEY", + }); + + assert.equal(result, "retry-selection"); + assert.deepEqual(validationCall, { + label: "NVIDIA Endpoints", + endpointUrl: "https://integrate.api.nvidia.com/v1", + model: "moonshotai/kimi-k2.7-code", + credentialEnv: "NVIDIA_INFERENCE_API_KEY", + retryMessage: "Please choose a provider/model again.", + options: { + requireResponsesToolCalling: false, + skipResponsesProbe: false, + authMode: undefined, + }, + }); + }); }); diff --git a/test/generate-openclaw-config-inference-registry.test.ts b/test/generate-openclaw-config-inference-registry.test.ts index fbd728775ff..7e4266717e5 100644 --- a/test/generate-openclaw-config-inference-registry.test.ts +++ b/test/generate-openclaw-config-inference-registry.test.ts @@ -33,8 +33,8 @@ function buildInferenceConfig(model: string): any { } describe("generate-openclaw-config.mts: managed inference registry", () => { - it("adds Kimi K2.7 Code compat through the existing managed inference setup", () => { - const config = buildInferenceConfig("moonshotai/kimi-k2.7-code"); + it("adds Kimi K2.6 compat through the existing managed inference setup", () => { + const config = buildInferenceConfig("moonshotai/kimi-k2.6"); expect(config.models.providers.inference.models[0].compat).toEqual({ supportsStore: false, @@ -44,4 +44,11 @@ describe("generate-openclaw-config.mts: managed inference registry", () => { }); expect(config.plugins.entries["nemoclaw-kimi-inference-compat"]).toEqual({ enabled: true }); }); + + it("does not apply the K2.6 compat shim to Kimi K2.7 Code without source evidence", () => { + const config = buildInferenceConfig("moonshotai/kimi-k2.7-code"); + + expect(config.models.providers.inference.models[0].compat).toBeUndefined(); + expect(config.plugins.entries).not.toHaveProperty("nemoclaw-kimi-inference-compat"); + }); }); diff --git a/test/kimi-inference-compat-plugin.test.ts b/test/kimi-inference-compat-plugin.test.ts index a228fc33142..2e8dde4bf0d 100644 --- a/test/kimi-inference-compat-plugin.test.ts +++ b/test/kimi-inference-compat-plugin.test.ts @@ -448,27 +448,10 @@ describe("nemoclaw Kimi inference compat plugin", () => { expect(JSON.stringify(result)).not.toContain("hostname; date; uptime"); }); - it("matches Kimi K2.7 Code managed inference refs", async () => { - // Compatibility-prep fixture for the K2.7 rollout: if the managed route - // returns the same OpenClaw-incompatible reasoning fields plus combined - // safe exec diagnostics observed on K2.6, this plugin keeps the sandbox - // route usable. Remove the K2.7 manifest match when the managed K2.7 route - // emits discrete OpenClaw-safe tool calls without reasoning fields. - const message = { - ...toolMessage("hostname; date; uptime"), - reasoning: "PRIVATE K2.7 reasoning", - content: [ - { type: "thinking", text: "PRIVATE K2.7 thinking block" }, - ...toolMessage("hostname; date; uptime").content, - ], - }; + it("does not wrap Kimi K2.7 Code managed inference refs before source evidence exists", () => { const provider = makeProvider(); const wrapper = provider.wrapStreamFn({ - ...managedKimiCtx(() => ({ - async result() { - return message; - }, - })), + ...managedKimiCtx(() => undefined), modelId: "inference/moonshotai/kimi-k2.7-code", model: { id: "moonshotai/kimi-k2.7-code", @@ -478,14 +461,7 @@ describe("nemoclaw Kimi inference compat plugin", () => { }, }); - expect(wrapper).toEqual(expect.any(Function)); - - const stream = wrapper({}, {}, {}); - const result = await stream.result(); - - expect(result.content.map(toolCommand)).toEqual(["hostname", "date", "uptime"]); - expect(JSON.stringify(result)).not.toContain("hostname; date; uptime"); - expect(JSON.stringify(result)).not.toContain("PRIVATE K2.7"); + expect(wrapper).toBeUndefined(); }); it("rewrites object tool-call deltas at their content index without retaining compound commands", () => { From ae47ba4d346fc79bd2a58e31a9e66caadc2d5943 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Mon, 22 Jun 2026 20:41:00 -0700 Subject: [PATCH 5/8] test(onboard): keep Kimi route recovery test linear --- src/lib/onboard/setup-nim-selection.test.ts | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/lib/onboard/setup-nim-selection.test.ts b/src/lib/onboard/setup-nim-selection.test.ts index 96c50c47626..cfe4c8a05c8 100644 --- a/src/lib/onboard/setup-nim-selection.test.ts +++ b/src/lib/onboard/setup-nim-selection.test.ts @@ -27,6 +27,12 @@ function makeState(): SetupNimSelectionState { }; } +function requireValueForTest(value: T | null | undefined, message: string): T { + assert.notEqual(value, null, message); + assert.notEqual(value, undefined, message); + return value; +} + describe("setupNim selection state helpers", () => { it("applies a complete cloud fallback and clears stale local-provider state", () => { const state = makeState(); @@ -74,10 +80,7 @@ describe("createRemoteModelValidator", () => { const { validateSelectedRemoteModel } = createRemoteModelValidator({ OPENAI_ENDPOINT_URL: "https://default-openai.example/v1", ANTHROPIC_ENDPOINT_URL: "https://default-anthropic.example/v1", - requireValue: (value, message) => { - if (value === null || value === undefined) throw new Error(message); - return value; - }, + requireValue: requireValueForTest, isBackToSelection: (_value): _value is never => false, validateCustomOpenAiLikeSelection: async (_label, endpointUrl) => { calledEndpoint = endpointUrl; @@ -112,10 +115,7 @@ describe("createRemoteModelValidator", () => { const { validateSelectedRemoteModel } = createRemoteModelValidator({ OPENAI_ENDPOINT_URL: "https://default-openai.example/v1", ANTHROPIC_ENDPOINT_URL: "https://default-anthropic.example/v1", - requireValue: (value, message) => { - if (value === null || value === undefined) throw new Error(message); - return value; - }, + requireValue: requireValueForTest, isBackToSelection: (_value): _value is never => false, validateCustomOpenAiLikeSelection: async () => ({ ok: false, retry: "selection" }), validateCustomAnthropicSelection: async () => ({ ok: false, retry: "model" }), @@ -159,10 +159,7 @@ describe("createRemoteModelValidator", () => { const { validateSelectedRemoteModel } = createRemoteModelValidator({ OPENAI_ENDPOINT_URL: "https://default-openai.example/v1", ANTHROPIC_ENDPOINT_URL: "https://default-anthropic.example/v1", - requireValue: (value, message) => { - if (value === null || value === undefined) throw new Error(message); - return value; - }, + requireValue: requireValueForTest, isBackToSelection: (_value): _value is never => false, validateCustomOpenAiLikeSelection: async () => ({ ok: false, retry: "selection" }), validateCustomAnthropicSelection: async () => ({ ok: false, retry: "selection" }), From e0667acf0bdeb85e7a0c16fa0bd7a1d377df977d Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Mon, 22 Jun 2026 20:45:20 -0700 Subject: [PATCH 6/8] test(onboard): satisfy route recovery typecheck --- src/lib/onboard/setup-nim-selection.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/onboard/setup-nim-selection.test.ts b/src/lib/onboard/setup-nim-selection.test.ts index cfe4c8a05c8..0068526f452 100644 --- a/src/lib/onboard/setup-nim-selection.test.ts +++ b/src/lib/onboard/setup-nim-selection.test.ts @@ -30,7 +30,7 @@ function makeState(): SetupNimSelectionState { function requireValueForTest(value: T | null | undefined, message: string): T { assert.notEqual(value, null, message); assert.notEqual(value, undefined, message); - return value; + return value as T; } describe("setupNim selection state helpers", () => { From ac1d8def34753bc0880d00b5a589856c781eae59 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Mon, 22 Jun 2026 20:53:15 -0700 Subject: [PATCH 7/8] fix(inference): harden auth curl config cleanup --- .../adapters/http/curl-auth-config.test.ts | 63 +++++++++++++++++++ src/lib/adapters/http/curl-auth-config.ts | 26 ++++++-- 2 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 src/lib/adapters/http/curl-auth-config.test.ts diff --git a/src/lib/adapters/http/curl-auth-config.test.ts b/src/lib/adapters/http/curl-auth-config.test.ts new file mode 100644 index 00000000000..a7c9ba3b5b8 --- /dev/null +++ b/src/lib/adapters/http/curl-auth-config.test.ts @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + cleanupAuthCurlConfig, + createAuthCurlConfig, +} from "../../../../dist/lib/adapters/http/curl-auth-config"; + +const cleanupPaths: string[] = []; + +afterEach(() => { + for (const dir of cleanupPaths.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("auth curl config helpers", () => { + it("cleans up auth config directories created below the temp root", () => { + const configPath = createAuthCurlConfig("Authorization: Bearer nvapi-x", "nemoclaw-auth-test"); + const dir = path.dirname(configPath); + + expect(fs.existsSync(configPath)).toBe(true); + + cleanupAuthCurlConfig(configPath, "nemoclaw-auth-test"); + + expect(fs.existsSync(dir)).toBe(false); + }); + + it("does not remove a matching directory outside the temp root", () => { + const parentDir = fs.mkdtempSync(path.join(process.cwd(), ".tmp-nemoclaw-auth-parent-")); + const outsideDir = path.join(parentDir, "nemoclaw-auth-test-stale"); + cleanupPaths.push(parentDir); + fs.mkdirSync(outsideDir); + const configPath = path.join(outsideDir, "auth.conf"); + fs.writeFileSync(configPath, "header = \"Authorization: Bearer nvapi-x\"\n", { + mode: 0o600, + encoding: "utf8", + }); + + cleanupAuthCurlConfig(configPath, "nemoclaw-auth-test"); + + expect(fs.existsSync(outsideDir)).toBe(true); + expect(fs.existsSync(configPath)).toBe(true); + }); + + it("rejects path-like temp prefixes", () => { + expect(() => createAuthCurlConfig("Authorization: Bearer nvapi-x", "nested/prefix")).toThrow( + /Invalid temp file prefix/, + ); + expect(() => + cleanupAuthCurlConfig( + path.join(os.tmpdir(), "nested", "prefix-stale", "auth.conf"), + "nested/prefix", + ), + ).toThrow(/Invalid temp file prefix/); + }); +}); diff --git a/src/lib/adapters/http/curl-auth-config.ts b/src/lib/adapters/http/curl-auth-config.ts index 4799b642a76..e6f1dc34843 100644 --- a/src/lib/adapters/http/curl-auth-config.ts +++ b/src/lib/adapters/http/curl-auth-config.ts @@ -7,6 +7,18 @@ import path from "node:path"; const DEFAULT_CURL_AUTH_CONFIG_PREFIX = "nemoclaw-auth-curl"; +function validateTempPrefix(prefix: string): string { + if ( + prefix.length === 0 || + prefix !== path.basename(prefix) || + prefix.includes(path.posix.sep) || + prefix.includes(path.win32.sep) + ) { + throw new Error(`Invalid temp file prefix: ${prefix}`); + } + return prefix; +} + function quoteCurlConfigValue(value: string): string { return value .replace(/\\/g, "\\\\") @@ -18,7 +30,8 @@ export function createAuthCurlConfig( headerValue: string, prefix = DEFAULT_CURL_AUTH_CONFIG_PREFIX, ): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)); + const safePrefix = validateTempPrefix(prefix); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${safePrefix}-`)); try { fs.chmodSync(dir, 0o700); const configPath = path.join(dir, "auth.conf"); @@ -37,8 +50,13 @@ export function cleanupAuthCurlConfig( configPath: string, prefix = DEFAULT_CURL_AUTH_CONFIG_PREFIX, ): void { - const dir = path.dirname(configPath); - if (dir !== os.tmpdir() && path.basename(dir).startsWith(`${prefix}-`)) { - fs.rmSync(dir, { recursive: true, force: true }); + const safePrefix = validateTempPrefix(prefix); + const tempRoot = path.resolve(os.tmpdir()); + const parentDir = path.resolve(path.dirname(configPath)); + const relativeParent = path.relative(tempRoot, parentDir); + const isInsideTempRoot = + relativeParent !== "" && !relativeParent.startsWith("..") && !path.isAbsolute(relativeParent); + if (isInsideTempRoot && path.basename(parentDir).startsWith(`${safePrefix}-`)) { + fs.rmSync(parentDir, { recursive: true, force: true }); } } From 04a35de55dc8ea3dca7ee5ba4a9ee98a65610406 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Mon, 22 Jun 2026 20:58:00 -0700 Subject: [PATCH 8/8] style(inference): format auth curl config test --- src/lib/adapters/http/curl-auth-config.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/adapters/http/curl-auth-config.test.ts b/src/lib/adapters/http/curl-auth-config.test.ts index a7c9ba3b5b8..153974c1f87 100644 --- a/src/lib/adapters/http/curl-auth-config.test.ts +++ b/src/lib/adapters/http/curl-auth-config.test.ts @@ -38,7 +38,7 @@ describe("auth curl config helpers", () => { cleanupPaths.push(parentDir); fs.mkdirSync(outsideDir); const configPath = path.join(outsideDir, "auth.conf"); - fs.writeFileSync(configPath, "header = \"Authorization: Bearer nvapi-x\"\n", { + fs.writeFileSync(configPath, 'header = "Authorization: Bearer nvapi-x"\n', { mode: 0o600, encoding: "utf8", });