diff --git a/bin/lib/inference-config.js b/bin/lib/inference-config.js index 6384d8a60..7e8faf292 100644 --- a/bin/lib/inference-config.js +++ b/bin/lib/inference-config.js @@ -1,143 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +// +// Thin re-export shim — the implementation lives in src/lib/inference-config.ts, +// compiled to dist/lib/inference-config.js. -const INFERENCE_ROUTE_URL = "https://inference.local/v1"; -const DEFAULT_CLOUD_MODEL = "nvidia/nemotron-3-super-120b-a12b"; -const CLOUD_MODEL_OPTIONS = [ - { id: "nvidia/nemotron-3-super-120b-a12b", label: "Nemotron 3 Super 120B" }, - { id: "moonshotai/kimi-k2.5", label: "Kimi K2.5" }, - { id: "z-ai/glm5", label: "GLM-5" }, - { id: "minimaxai/minimax-m2.5", label: "MiniMax M2.5" }, - { id: "qwen/qwen3.5-397b-a17b", label: "Qwen3.5 397B A17B" }, - { id: "openai/gpt-oss-120b", label: "GPT-OSS 120B" }, -]; -const DEFAULT_ROUTE_PROFILE = "inference-local"; -const DEFAULT_ROUTE_CREDENTIAL_ENV = "OPENAI_API_KEY"; -const MANAGED_PROVIDER_ID = "inference"; -const { DEFAULT_OLLAMA_MODEL } = require("./local-inference"); - -function getProviderSelectionConfig(provider, model) { - switch (provider) { - case "nvidia-prod": - case "nvidia-nim": - return { - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: model || DEFAULT_CLOUD_MODEL, - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: DEFAULT_ROUTE_CREDENTIAL_ENV, - provider, - providerLabel: "NVIDIA Endpoints", - }; - case "openai-api": - return { - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: model || "gpt-5.4", - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: "OPENAI_API_KEY", - provider, - providerLabel: "OpenAI", - }; - case "anthropic-prod": - return { - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: model || "claude-sonnet-4-6", - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: "ANTHROPIC_API_KEY", - provider, - providerLabel: "Anthropic", - }; - case "compatible-anthropic-endpoint": - return { - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: model || "custom-anthropic-model", - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", - provider, - providerLabel: "Other Anthropic-compatible endpoint", - }; - case "gemini-api": - return { - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: model || "gemini-2.5-flash", - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: "GEMINI_API_KEY", - provider, - providerLabel: "Google Gemini", - }; - case "compatible-endpoint": - return { - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: model || "custom-model", - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: "COMPATIBLE_API_KEY", - provider, - providerLabel: "Other OpenAI-compatible endpoint", - }; - case "vllm-local": - return { - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: model || "vllm-local", - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: DEFAULT_ROUTE_CREDENTIAL_ENV, - provider, - providerLabel: "Local vLLM", - }; - case "ollama-local": - return { - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: model || DEFAULT_OLLAMA_MODEL, - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: DEFAULT_ROUTE_CREDENTIAL_ENV, - provider, - providerLabel: "Local Ollama", - }; - default: - return null; - } -} - -function getOpenClawPrimaryModel(provider, model) { - const resolvedModel = - model || (provider === "ollama-local" ? DEFAULT_OLLAMA_MODEL : DEFAULT_CLOUD_MODEL); - return resolvedModel ? `${MANAGED_PROVIDER_ID}/${resolvedModel}` : null; -} - -function parseGatewayInference(output) { - if (!output || /Not configured/i.test(output)) return null; - const provider = output.match(/Provider:\s*(.+)/); - const model = output.match(/Model:\s*(.+)/); - if (!provider && !model) return null; - return { - provider: provider ? provider[1].trim() : null, - model: model ? model[1].trim() : null, - }; -} - -module.exports = { - CLOUD_MODEL_OPTIONS, - DEFAULT_CLOUD_MODEL, - DEFAULT_OLLAMA_MODEL, - DEFAULT_ROUTE_CREDENTIAL_ENV, - DEFAULT_ROUTE_PROFILE, - INFERENCE_ROUTE_URL, - MANAGED_PROVIDER_ID, - getOpenClawPrimaryModel, - getProviderSelectionConfig, - parseGatewayInference, -}; +module.exports = require("../../dist/lib/inference-config"); diff --git a/package.json b/package.json index 60e1ce9a3..f5af4ef70 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "typecheck": "tsc -p jsconfig.json", "build:cli": "tsc -p tsconfig.src.json", "typecheck:cli": "tsc -p tsconfig.cli.json", - "prepare": "npm install --omit=dev --ignore-scripts 2>/dev/null || true && if [ -d .git ]; then if command -v prek >/dev/null 2>&1; then prek install; elif [ -d node_modules/@j178/prek ]; then echo \"ERROR: prek package found but binary not in PATH\" && exit 1; else echo \"Skipping git hook setup (prek not installed)\"; fi; fi", + "prepare": "npm install --omit=dev --ignore-scripts 2>/dev/null || true && if command -v tsc >/dev/null 2>&1 || [ -x node_modules/.bin/tsc ]; then npm run build:cli; fi && if [ -d .git ]; then if command -v prek >/dev/null 2>&1; then prek install; elif [ -d node_modules/@j178/prek ]; then echo \"ERROR: prek package found but binary not in PATH\" && exit 1; else echo \"Skipping git hook setup (prek not installed)\"; fi; fi", "prepublishOnly": "cd nemoclaw && env -u npm_config_global -u npm_config_prefix -u npm_config_omit npm install --ignore-scripts && ./node_modules/.bin/tsc" }, "dependencies": { diff --git a/test/inference-config.test.js b/src/lib/inference-config.test.ts similarity index 61% rename from test/inference-config.test.js rename to src/lib/inference-config.test.ts index 04378bc00..48df749bf 100644 --- a/test/inference-config.test.js +++ b/src/lib/inference-config.test.ts @@ -1,9 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import assert from "node:assert/strict"; import { describe, it, expect } from "vitest"; +// Import from compiled dist/ for correct coverage attribution. import { CLOUD_MODEL_OPTIONS, DEFAULT_OLLAMA_MODEL, @@ -14,11 +14,11 @@ import { getOpenClawPrimaryModel, getProviderSelectionConfig, parseGatewayInference, -} from "../bin/lib/inference-config"; +} from "../../dist/lib/inference-config"; describe("inference selection config", () => { it("exposes the curated cloud model picker options", () => { - expect(CLOUD_MODEL_OPTIONS.map((option) => option.id)).toEqual([ + expect(CLOUD_MODEL_OPTIONS.map((option: { id: string }) => option.id)).toEqual([ "nvidia/nemotron-3-super-120b-a12b", "moonshotai/kimi-k2.5", "z-ai/glm5", @@ -55,22 +55,22 @@ describe("inference selection config", () => { }); it("maps compatible-anthropic-endpoint to the sandbox inference route", () => { - assert.deepEqual( + expect( getProviderSelectionConfig("compatible-anthropic-endpoint", "claude-sonnet-proxy"), - { - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: "claude-sonnet-proxy", - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", - provider: "compatible-anthropic-endpoint", - providerLabel: "Other Anthropic-compatible endpoint", - }, - ); + ).toEqual({ + endpointType: "custom", + endpointUrl: INFERENCE_ROUTE_URL, + ncpPartner: null, + model: "claude-sonnet-proxy", + profile: DEFAULT_ROUTE_PROFILE, + credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", + provider: "compatible-anthropic-endpoint", + providerLabel: "Other Anthropic-compatible endpoint", + }); }); it("maps the remaining hosted providers to the sandbox inference route", () => { + // Full-object assertion for one hosted provider to catch structural regressions expect(getProviderSelectionConfig("openai-api", "gpt-5.4-mini")).toEqual({ endpointType: "custom", endpointUrl: INFERENCE_ROUTE_URL, @@ -81,40 +81,19 @@ describe("inference selection config", () => { provider: "openai-api", providerLabel: "OpenAI", }); - - expect(getProviderSelectionConfig("anthropic-prod", "claude-sonnet-4-6")).toEqual({ - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: "claude-sonnet-4-6", - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: "ANTHROPIC_API_KEY", - provider: "anthropic-prod", - providerLabel: "Anthropic", - }); - - expect(getProviderSelectionConfig("gemini-api", "gemini-2.5-pro")).toEqual({ - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: "gemini-2.5-pro", - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: "GEMINI_API_KEY", - provider: "gemini-api", - providerLabel: "Google Gemini", - }); - - expect(getProviderSelectionConfig("compatible-endpoint", "openrouter/auto")).toEqual({ - endpointType: "custom", - endpointUrl: INFERENCE_ROUTE_URL, - ncpPartner: null, - model: "openrouter/auto", - profile: DEFAULT_ROUTE_PROFILE, - credentialEnv: "COMPATIBLE_API_KEY", - provider: "compatible-endpoint", - providerLabel: "Other OpenAI-compatible endpoint", - }); - + expect(getProviderSelectionConfig("anthropic-prod", "claude-sonnet-4-6")).toEqual( + expect.objectContaining({ model: "claude-sonnet-4-6", providerLabel: "Anthropic" }), + ); + expect(getProviderSelectionConfig("gemini-api", "gemini-2.5-pro")).toEqual( + expect.objectContaining({ model: "gemini-2.5-pro", providerLabel: "Google Gemini" }), + ); + expect(getProviderSelectionConfig("compatible-endpoint", "openrouter/auto")).toEqual( + expect.objectContaining({ + model: "openrouter/auto", + providerLabel: "Other OpenAI-compatible endpoint", + }), + ); + // Full-object assertion for one local provider expect(getProviderSelectionConfig("vllm-local", "meta-llama")).toEqual({ endpointType: "custom", endpointUrl: INFERENCE_ROUTE_URL, @@ -131,10 +110,6 @@ describe("inference selection config", () => { expect(getProviderSelectionConfig("bogus-provider")).toBe(null); }); - // Guard: the provider list is intentionally closed. CSP-specific wrappers - // (Bedrock, Vertex, Azure OpenAI, etc.) are already reachable through the - // "compatible-endpoint" or "compatible-anthropic-endpoint" options. - // Adding a new first-class provider key requires explicit approval. it("does not grow beyond the approved provider set", () => { const APPROVED_PROVIDERS = [ "nvidia-prod", @@ -147,15 +122,9 @@ describe("inference selection config", () => { "vllm-local", "ollama-local", ]; - - // Every approved provider must still be recognised. for (const key of APPROVED_PROVIDERS) { expect(getProviderSelectionConfig(key)).not.toBe(null); } - - // Probe a broad set of plausible names; none outside the approved list - // should resolve. If this fails you are adding a new provider — use - // "compatible-endpoint" or "compatible-anthropic-endpoint" instead. const CANDIDATES = [ "bedrock", "vertex", @@ -173,31 +142,25 @@ describe("inference selection config", () => { "sambanova", ]; for (const key of CANDIDATES) { - expect( - getProviderSelectionConfig(key), - `"${key}" resolved as a provider — the provider list is closed. ` + - "CSP-specific endpoints should use the compatible-endpoint or " + - "compatible-anthropic-endpoint options instead. " + - "See https://github.com/NVIDIA/NemoClaw/pull/963 for rationale.", - ).toBe(null); + expect(getProviderSelectionConfig(key)).toBe(null); } }); - it("builds a qualified OpenClaw primary model for ollama-local", () => { - expect(getOpenClawPrimaryModel("ollama-local", "nemotron-3-nano:30b")).toBe( - `${MANAGED_PROVIDER_ID}/nemotron-3-nano:30b`, + it("falls back to provider defaults when model is omitted", () => { + expect(getProviderSelectionConfig("openai-api")?.model).toBe("gpt-5.4"); + expect(getProviderSelectionConfig("anthropic-prod")?.model).toBe("claude-sonnet-4-6"); + expect(getProviderSelectionConfig("gemini-api")?.model).toBe("gemini-2.5-flash"); + expect(getProviderSelectionConfig("compatible-endpoint")?.model).toBe("custom-model"); + expect(getProviderSelectionConfig("compatible-anthropic-endpoint")?.model).toBe( + "custom-anthropic-model", ); + expect(getProviderSelectionConfig("vllm-local")?.model).toBe("vllm-local"); }); - it("falls back to provider defaults when model is omitted", () => { - expect(getProviderSelectionConfig("openai-api").model).toBe("gpt-5.4"); - expect(getProviderSelectionConfig("anthropic-prod").model).toBe("claude-sonnet-4-6"); - expect(getProviderSelectionConfig("gemini-api").model).toBe("gemini-2.5-flash"); - expect(getProviderSelectionConfig("compatible-endpoint").model).toBe("custom-model"); - expect(getProviderSelectionConfig("compatible-anthropic-endpoint").model).toBe( - "custom-anthropic-model", + it("builds a qualified OpenClaw primary model for ollama-local", () => { + expect(getOpenClawPrimaryModel("ollama-local", "nemotron-3-nano:30b")).toBe( + `${MANAGED_PROVIDER_ID}/nemotron-3-nano:30b`, ); - expect(getProviderSelectionConfig("vllm-local").model).toBe("vllm-local"); }); it("builds a default OpenClaw primary model for non-ollama providers", () => { @@ -232,35 +195,18 @@ describe("parseGatewayInference", () => { }); it("returns null when inference is not configured", () => { - const output = "Gateway inference:\n\n Not configured"; - expect(parseGatewayInference(output)).toBeNull(); - }); - - it("parses output with different provider/model combinations", () => { - const output = [ - "Gateway inference:", - "", - " Provider: ollama-local", - " Model: qwen/qwen3.5-397b-a17b", - " Version: 1", - ].join("\n"); - expect(parseGatewayInference(output)).toEqual({ - provider: "ollama-local", - model: "qwen/qwen3.5-397b-a17b", - }); + expect(parseGatewayInference("Gateway inference:\n\n Not configured")).toBeNull(); }); it("handles output with only provider (no model line)", () => { - const output = "Gateway inference:\n\n Provider: nvidia-nim"; - expect(parseGatewayInference(output)).toEqual({ + expect(parseGatewayInference("Gateway inference:\n\n Provider: nvidia-nim")).toEqual({ provider: "nvidia-nim", model: null, }); }); it("handles output with only model (no provider line)", () => { - const output = "Gateway inference:\n\n Model: some/model"; - expect(parseGatewayInference(output)).toEqual({ + expect(parseGatewayInference("Gateway inference:\n\n Model: some/model")).toEqual({ provider: null, model: "some/model", }); diff --git a/src/lib/inference-config.ts b/src/lib/inference-config.ts new file mode 100644 index 000000000..489df4650 --- /dev/null +++ b/src/lib/inference-config.ts @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Inference provider selection config, model resolution, and gateway + * inference output parsing. All functions are pure. + */ + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { DEFAULT_OLLAMA_MODEL } = require("../../bin/lib/local-inference"); + +export const INFERENCE_ROUTE_URL = "https://inference.local/v1"; +export const DEFAULT_CLOUD_MODEL = "nvidia/nemotron-3-super-120b-a12b"; +export const CLOUD_MODEL_OPTIONS = [ + { id: "nvidia/nemotron-3-super-120b-a12b", label: "Nemotron 3 Super 120B" }, + { id: "moonshotai/kimi-k2.5", label: "Kimi K2.5" }, + { id: "z-ai/glm5", label: "GLM-5" }, + { id: "minimaxai/minimax-m2.5", label: "MiniMax M2.5" }, + { id: "qwen/qwen3.5-397b-a17b", label: "Qwen3.5 397B A17B" }, + { id: "openai/gpt-oss-120b", label: "GPT-OSS 120B" }, +]; +export const DEFAULT_ROUTE_PROFILE = "inference-local"; +export const DEFAULT_ROUTE_CREDENTIAL_ENV = "OPENAI_API_KEY"; +export const MANAGED_PROVIDER_ID = "inference"; +export { DEFAULT_OLLAMA_MODEL }; + +export interface ProviderSelectionConfig { + endpointType: string; + endpointUrl: string; + ncpPartner: string | null; + model: string; + profile: string; + credentialEnv: string; + provider: string; + providerLabel: string; +} + +export interface GatewayInference { + provider: string | null; + model: string | null; +} + +export function getProviderSelectionConfig( + provider: string, + model?: string, +): ProviderSelectionConfig | null { + const base = { + endpointType: "custom" as const, + endpointUrl: INFERENCE_ROUTE_URL, + ncpPartner: null, + profile: DEFAULT_ROUTE_PROFILE, + provider, + }; + + switch (provider) { + case "nvidia-prod": + case "nvidia-nim": + return { + ...base, + model: model || DEFAULT_CLOUD_MODEL, + credentialEnv: DEFAULT_ROUTE_CREDENTIAL_ENV, + providerLabel: "NVIDIA Endpoints", + }; + case "openai-api": + return { + ...base, + model: model || "gpt-5.4", + credentialEnv: "OPENAI_API_KEY", + providerLabel: "OpenAI", + }; + case "anthropic-prod": + return { + ...base, + model: model || "claude-sonnet-4-6", + credentialEnv: "ANTHROPIC_API_KEY", + providerLabel: "Anthropic", + }; + case "compatible-anthropic-endpoint": + return { + ...base, + model: model || "custom-anthropic-model", + credentialEnv: "COMPATIBLE_ANTHROPIC_API_KEY", + providerLabel: "Other Anthropic-compatible endpoint", + }; + case "gemini-api": + return { + ...base, + model: model || "gemini-2.5-flash", + credentialEnv: "GEMINI_API_KEY", + providerLabel: "Google Gemini", + }; + case "compatible-endpoint": + return { + ...base, + model: model || "custom-model", + credentialEnv: "COMPATIBLE_API_KEY", + providerLabel: "Other OpenAI-compatible endpoint", + }; + case "vllm-local": + return { + ...base, + model: model || "vllm-local", + credentialEnv: DEFAULT_ROUTE_CREDENTIAL_ENV, + providerLabel: "Local vLLM", + }; + case "ollama-local": + return { + ...base, + model: model || DEFAULT_OLLAMA_MODEL, + credentialEnv: DEFAULT_ROUTE_CREDENTIAL_ENV, + providerLabel: "Local Ollama", + }; + default: + return null; + } +} + +export function getOpenClawPrimaryModel(provider: string, model?: string): string { + const resolvedModel = + model || (provider === "ollama-local" ? DEFAULT_OLLAMA_MODEL : DEFAULT_CLOUD_MODEL); + return `${MANAGED_PROVIDER_ID}/${resolvedModel}`; +} + +export function parseGatewayInference(output: string | null | undefined): GatewayInference | null { + if (!output || /Not configured/i.test(output)) return null; + const provider = output.match(/Provider:\s*(.+)/); + const model = output.match(/Model:\s*(.+)/); + if (!provider && !model) return null; + return { + provider: provider ? provider[1].trim() : null, + model: model ? model[1].trim() : null, + }; +}