Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions ci/test-file-size-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
"src/lib/inference/nim.test.ts": 2068,
"src/lib/onboard/preflight.test.ts": 1904,
"test/channels-add-preset.test.ts": 1871,
"test/generate-openclaw-config.test.ts": 1972,
"test/generate-openclaw-config.test.ts": 1945,
"test/install-preflight.test.ts": 3934,
"test/nemoclaw-start.test.ts": 4827,
"test/onboard-messaging.test.ts": 2062,
"test/onboard-selection.test.ts": 6867,
"test/onboard.test.ts": 4774,
"test/onboard-selection.test.ts": 6146,
"test/onboard.test.ts": 4057,
"test/policies.test.ts": 2332
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ const unusedCommonInferenceDeps = {
verifyOnboardInferenceSmoke: vi.fn(),
isNonInteractive: () => true,
registry: { updateSandbox: vi.fn() },
error: vi.fn(),
log: vi.fn(),
exitProcess: (code: number): never => {
throw new Error(`EXIT_CALLED:${code}`);
},
};

const localProviderScenarios = [
Expand Down Expand Up @@ -121,6 +126,8 @@ function makeRouteApplier() {
compactText: (value) => value.trim(),
redact: (value) => value,
localInferenceTimeoutSecs: 30,
error: unusedCommonInferenceDeps.error,
exitProcess: unusedCommonInferenceDeps.exitProcess,
});
}

Expand Down
29 changes: 29 additions & 0 deletions src/lib/inference/onboard-probes-curl-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,35 @@ export function makeFakeCurlScript(bodyLogic: string): string {
return `${FAKE_CURL_HEADER}${bodyLogic}`;
}

// Fake curl for the strict Responses API compatibility check. It records each
// requested URL, returns a successful Responses payload without a tool call,
// then returns a successful Chat Completions payload so callers can assert the
// exact fallback order without duplicating shell parsing in a test body.
export function makeResponsesFallbackUrlRecordingFakeCurlScript(): string {
return `#!/usr/bin/env bash
outfile=""
url=""
while [ "$#" -gt 0 ]; do
case "$1" in
-o) outfile="$2"; shift 2 ;;
-w|-d|--config) shift 2 ;;
http://*|https://*) url="$1"; shift ;;
*) shift ;;
esac
done
n=$(cat "${HARNESS_COUNTER}")
n=$((n + 1))
echo "$n" > "${HARNESS_COUNTER}"
printf '%s' "$url" > "${HARNESS_TMPDIR}/request-$n-url.txt"
if echo "$url" | grep -q '/responses$'; then
printf '%s' '{"output":[{"type":"message","content":[{"type":"output_text","text":"OK"}]}]}' > "$outfile"
else
printf '%s' '{"choices":[{"message":{"content":"OK"}}]}' > "$outfile"
fi
printf '200'
`;
}

// Restore an env var to its pre-test value without branching at the call
// site (kept identical to the helper the test file uses so restore semantics
// are unchanged).
Expand Down
43 changes: 43 additions & 0 deletions src/lib/inference/onboard-probes-responses-fallback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import fs from "node:fs";
import path from "node:path";
import { expect, it } from "vitest";

import {
makeResponsesFallbackUrlRecordingFakeCurlScript,
withFakeCurlProbe,
} from "./onboard-probes-curl-harness";

const { probeOpenAiLikeEndpoint } = require("./onboard-probes");

it("falls back to chat completions for custom OpenAI-compatible endpoints when /responses lacks tool calls", () => {
withFakeCurlProbe(
{
script: makeResponsesFallbackUrlRecordingFakeCurlScript(),
dirPrefix: "nemoclaw-responses-tool-fallback-",
},
({ counter, tmpDir }) => {
const result = probeOpenAiLikeEndpoint(
"https://proxy.example.com/v1",
"custom-model",
"proxy-key",
{ requireResponsesToolCalling: true },
);

expect(result).toMatchObject({
ok: true,
api: "openai-completions",
label: "Chat Completions API",
});
expect(fs.readFileSync(counter, "utf8").trim()).toBe("2");
expect(fs.readFileSync(path.join(tmpDir, "request-1-url.txt"), "utf8")).toBe(
"https://proxy.example.com/v1/responses",
);
expect(fs.readFileSync(path.join(tmpDir, "request-2-url.txt"), "utf8")).toBe(
"https://proxy.example.com/v1/chat/completions",
);
},
);
});
194 changes: 60 additions & 134 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,8 @@ const { DEFAULT_CLOUD_MODEL, getProviderSelectionConfig, parseGatewayInference }

const onboardProviders = require("./onboard/providers");
const inferenceProviders: typeof import("./onboard/inference-providers") = require("./onboard/inference-providers");
const setupInferenceFactory: typeof import("./onboard/setup-inference") =
require("./onboard/setup-inference");
const { ensureResumeProviderReady } = require("./onboard/resume-provider-shim");
const hermesProviderAuth = require("./hermes-provider-auth");
const onboardHermesDashboard: typeof import("./onboard/hermes-dashboard") = require("./onboard/hermes-dashboard");
Expand Down Expand Up @@ -393,9 +395,6 @@ const {
const {
createValidationRecoveryPromptHelpers,
}: typeof import("./onboard/validation-recovery-prompt") = require("./onboard/validation-recovery-prompt");
const {
createLocalInferenceRouteApplier,
}: typeof import("./onboard/local-inference-route") = require("./onboard/local-inference-route");
const {
createOpenshellCliHelpers,
}: typeof import("./onboard/openshell-cli") = require("./onboard/openshell-cli");
Expand Down Expand Up @@ -837,6 +836,8 @@ const {
checkHermesProviderStoreReachable,
} = hermesAuth.createHermesAuthHelpers({
isNonInteractive,
error: (message) => console.error(message),
exitProcess: (code) => process.exit(code),
note,
prompt,
getNavigationChoice,
Expand All @@ -858,16 +859,6 @@ const { promptValidationRecovery } = createValidationRecoveryPromptHelpers({
exitOnboardFromPrompt,
});

const applyLocalInferenceRoute = createLocalInferenceRouteApplier({
runOpenshell,
isNonInteractive,
promptValidationRecovery,
classifyApplyFailure,
compactText,
redact,
localInferenceTimeoutSecs: LOCAL_INFERENCE_TIMEOUT_SECS,
});

// Provider CRUD — thin wrappers that inject runOpenshell to avoid circular deps.
const { buildProviderArgs } = onboardProviders;

Expand Down Expand Up @@ -3657,6 +3648,9 @@ async function handleRemoteProviderSelection(args: RemoteProviderSelectionArgs,
isNonInteractive,
promptInputModel,
replaceNamedCredential,
exitProcess: (code) => process.exit(code),
error: (message) => console.error(message),
log: (message) => console.log(message),
});
if (bedrockSelection.action === "retry-selection") {
console.log(" Returning to provider selection.");
Expand Down Expand Up @@ -4162,137 +4156,68 @@ async function setupNim(gpu: ReturnType<typeof nim.detectGpu>, sandboxName: stri

// ── Step 4: Inference provider ───────────────────────────────────

async function setupInference(
sandboxName: string | null,
model: string,
provider: string,
endpointUrl: string | null = null,
credentialEnv: string | null = null,
hermesAuthMethod: HermesAuthMethod | string | null = null,
hermesToolGateways: string[] = [],
options: import("./onboard/machine/handlers/provider-inference").ProviderInferenceSetupOptions = {},
): Promise<{ ok: true; retry?: undefined } | { retry: "selection" }> {
step(4, 8, "Setting up inference provider");
runOpenshell(["gateway", "select", GATEWAY_NAME], { ignoreError: true });

const commonDeps = {
function getSetupInferenceDeps(): SetupInferenceDeps {
return {
step,
getGatewayName: () => GATEWAY_NAME,
runOpenshell,
upsertProvider,
verifyInferenceRoute,
verifyOnboardInferenceSmoke,
isNonInteractive,
registry,
updateSandbox: registry.updateSandbox,
hermesProviderAuth,
getHermesToolGatewayBroker,
providerExistsInGateway,
normalizeHermesAuthMethod,
resolveHermesNousApiKey,
checkHermesProviderStoreReachable,
hermesAuthMethodLabel,
hermesConstants: {
HERMES_NOUS_API_KEY_CREDENTIAL_ENV,
HERMES_AUTH_METHOD_API_KEY,
HERMES_AUTH_METHOD_OAUTH,
},
requireValue,
redact,
compactText,
REMOTE_PROVIDER_CONFIG,
hydrateCredentialEnv,
promptValidationRecovery,
classifyApplyFailure,
localInferenceTimeoutSecs: LOCAL_INFERENCE_TIMEOUT_SECS,
bedrockRuntimeOnboard,
validateLocalProvider,
getLocalProviderHealthCheck,
getLocalProviderBaseUrl,
run,
vllmLocalCredentialEnv: VLLM_LOCAL_CREDENTIAL_ENV,
getOllamaWarmupCommand,
shouldFrontOllamaWithProxy,
ensureOllamaAuthProxy,
isProxyHealthy,
getOllamaProxyToken,
persistAndProbeOllamaProxy,
localInference,
ollamaProxyCredentialEnv: OLLAMA_PROXY_CREDENTIAL_ENV,
isRoutedInferenceProvider,
reconcileModelRouter,
routedInference,
log: (message: string) => console.log(message),
error: (message: string) => console.error(message),
exitProcess: (code: number): never => process.exit(code),
};
}

if (provider === hermesProviderAuth.HERMES_PROVIDER_NAME) {
return inferenceProviders.setupHermesProviderInference(
{
sandboxName,
model,
provider,
endpointUrl,
credentialEnv,
hermesAuthMethod,
hermesToolGateways,
},
{
...commonDeps,
hermesProviderAuth,
getHermesToolGatewayBroker,
providerExistsInGateway,
normalizeHermesAuthMethod,
resolveHermesNousApiKey,
checkHermesProviderStoreReachable,
hermesAuthMethodLabel,
hermesConstants: {
HERMES_NOUS_API_KEY_CREDENTIAL_ENV,
HERMES_AUTH_METHOD_API_KEY,
HERMES_AUTH_METHOD_OAUTH,
},
requireValue,
redact,
compactText,
},
);
}

if (inferenceProviders.isRemoteProviderName(provider)) {
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
const outcome = await inferenceProviders.setupRemoteProviderInference(
{ sandboxName, model, provider, endpointUrl, credentialEnv, reuseGatewayCredentialWithoutLocalKey: options.reuseGatewayCredentialWithoutLocalKey === true },
{
...commonDeps,
REMOTE_PROVIDER_CONFIG,
hydrateCredentialEnv,
promptValidationRecovery,
classifyApplyFailure,
LOCAL_INFERENCE_TIMEOUT_SECS,
bedrockRuntimeOnboard,
redact,
compactText,
},
);
if (outcome.done) return outcome.result;
} else if (provider === "vllm-local") {
const outcome = await inferenceProviders.setupVllmLocalInference(
{ model, provider },
{
...commonDeps,
validateLocalProvider,
getLocalProviderHealthCheck,
getLocalProviderBaseUrl,
applyLocalInferenceRoute,
run,
VLLM_LOCAL_CREDENTIAL_ENV,
},
);
if (outcome.done) return outcome.result;
} else if (provider === "ollama-local") {
const outcome = await inferenceProviders.setupOllamaLocalInference(
{ model, provider, allowToolsIncompatible: options.allowToolsIncompatible === true },
{
...commonDeps,
validateLocalProvider,
getLocalProviderBaseUrl,
applyLocalInferenceRoute,
getOllamaWarmupCommand,
run,
shouldFrontOllamaWithProxy,
ensureOllamaAuthProxy,
isProxyHealthy,
getOllamaProxyToken,
persistAndProbeOllamaProxy,
localInference,
OLLAMA_PROXY_CREDENTIAL_ENV,
},
);
if (outcome.done) return outcome.result;
} else if (isRoutedInferenceProvider(provider)) {
await inferenceProviders.setupRoutedInference(
{ model, provider, endpointUrl, credentialEnv },
{
...commonDeps,
reconcileModelRouter,
routedInference,
hydrateCredentialEnv,
},
);
} else {
console.error(` Unsupported provider configuration: ${provider}`);
process.exit(1);
}
export type SetupInferenceDeps = import("./onboard/setup-inference").SetupInferenceDeps;
export type SetupInference = import("./onboard/setup-inference").SetupInference;

verifyInferenceRoute(provider, model);
if (options.skipHostInferenceSmoke === true)
console.log(" Reusing existing gateway credential; skipping host inference smoke.");
else verifyOnboardInferenceSmoke({ provider, model, endpointUrl, credentialEnv });
if (sandboxName) {
registry.updateSandbox(sandboxName, { model, provider });
}
console.log(` ✓ Inference route set: ${provider} / ${model}`);
return { ok: true };
function createSetupInference(overrides: Partial<SetupInferenceDeps> = {}): SetupInference {
return setupInferenceFactory.createSetupInference(getSetupInferenceDeps(), overrides);
}

const setupInference = createSetupInference();

// ── Step 6: Messaging channels ───────────────────────────────────

const MESSAGING_CHANNELS = listChannels();
Expand Down Expand Up @@ -5301,6 +5226,7 @@ module.exports = {
runCaptureOpenshell,
agentSupportsWebSearch,
agentSupportsWebSearchProvider,
createSetupInference,
setupInference,
setupMessagingChannels,
MESSAGING_CHANNELS,
Expand Down
Loading
Loading