diff --git a/docs/inference/inference-options.mdx b/docs/inference/inference-options.mdx index f07adf448a1..ea475a5e4aa 100644 --- a/docs/inference/inference-options.mdx +++ b/docs/inference/inference-options.mdx @@ -64,7 +64,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_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 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` | @@ -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 f893e23014d..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. +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 34b5b3a0231..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,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 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 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"], "providerKey": "inference", diff --git a/nemoclaw-blueprint/openclaw-plugins/kimi-inference-compat/index.js b/nemoclaw-blueprint/openclaw-plugins/kimi-inference-compat/index.js index 57cd2c2d333..199a6be4a64 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"]); 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/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/adapters/http/curl-auth-config.test.ts b/src/lib/adapters/http/curl-auth-config.test.ts new file mode 100644 index 00000000000..153974c1f87 --- /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 new file mode 100644 index 00000000000..e6f1dc34843 --- /dev/null +++ b/src/lib/adapters/http/curl-auth-config.ts @@ -0,0 +1,62 @@ +// 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 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, "\\\\") + .replace(/"/g, '\\"') + .replace(/[\r\n]+/g, " "); +} + +export function createAuthCurlConfig( + headerValue: string, + prefix = DEFAULT_CURL_AUTH_CONFIG_PREFIX, +): string { + 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"); + 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 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 }); + } +} diff --git a/src/lib/inference/config.test.ts b/src/lib/inference/config.test.ts index d42e67a8b73..186306b2a81 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,10 +32,15 @@ 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(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", () => { @@ -272,6 +278,18 @@ describe("getSandboxInferenceConfig", () => { }); }); + 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({ + 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..75f0ebc7ee1 100644 --- a/src/lib/inference/config.ts +++ b/src/lib/inference/config.ts @@ -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.6", label: "Kimi K2.6" }, + { + 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.ts b/src/lib/inference/health.ts index 8ca7518cc12..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"; @@ -51,7 +48,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 +62,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 { @@ -91,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, @@ -177,11 +152,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 +167,7 @@ function buildKimiChatCompletionsDetail( ); } -function probeNvidiaKimiK26Health( +function probeNvidiaKimiHealth( provider: string, model: string, options: ProviderHealthProbeOptions, @@ -210,7 +186,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 +198,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.", }; } @@ -230,7 +206,10 @@ function probeNvidiaKimiK26Health( 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 { @@ -239,7 +218,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})`, }; } @@ -251,7 +230,7 @@ function probeNvidiaKimiK26Health( { trustedConfigFiles: [authConfigPath] }, ); } finally { - cleanupAuthCurlConfig(authConfigPath); + cleanupAuthCurlConfig(authConfigPath, KIMI_HEALTH_CURL_CONFIG_PREFIX); } })(); const healthy = result.ok; @@ -261,7 +240,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 +271,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/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-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.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.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/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..0068526f452 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 as T; +} + 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" }), @@ -141,4 +141,69 @@ 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: requireValueForTest, + 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 new file mode 100644 index 00000000000..7e4266717e5 --- /dev/null +++ b/test/generate-openclaw-config-inference-registry.test.ts @@ -0,0 +1,54 @@ +// @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.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, + requiresStringContent: true, + maxTokensField: "max_tokens", + requiresToolResultName: true, + }); + 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 0dc3a6741d2..2e8dde4bf0d 100644 --- a/test/kimi-inference-compat-plugin.test.ts +++ b/test/kimi-inference-compat-plugin.test.ts @@ -448,6 +448,22 @@ describe("nemoclaw Kimi inference compat plugin", () => { expect(JSON.stringify(result)).not.toContain("hostname; date; uptime"); }); + it("does not wrap Kimi K2.7 Code managed inference refs before source evidence exists", () => { + const provider = makeProvider(); + const wrapper = provider.wrapStreamFn({ + ...managedKimiCtx(() => undefined), + 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).toBeUndefined(); + }); + 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..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 = []; @@ -607,6 +606,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")),