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
16 changes: 16 additions & 0 deletions src/lib/inference/onboard-probes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,20 @@ export async function verifyOnboardInferenceSmoke(options: any) {
if (process.env.VITEST === "true") return;

const endpointUrl = options.endpointUrl || require("./config").INFERENCE_ROUTE_URL;
if (
options.capabilityCache?.takeCompletedOpenAiChat({
endpointUrl,
model: options.model,
authMode: getProbeAuthMode(options.provider),
extraHeaders: getProbeExtraHeaders(options.provider),
pinnedAddresses: options.pinnedAddresses,
})
) {
console.log(
` ✓ Reusing selected Chat Completions validation: ${options.provider} / ${options.model}`,
);
return;
}
const credentialEnv = options.credentialEnv || null;
const apiKey = credentialEnv
? resolveProviderCredential(credentialEnv) || getCredential(credentialEnv) || ""
Expand All @@ -1067,6 +1081,8 @@ export async function verifyOnboardInferenceSmoke(options: any) {
return;
}

options.capabilityCache?.invalidate();

const { compactText } = require("../core/url-utils");
const { redact } = require("../runner");
console.error(" Onboard inference smoke check failed.");
Expand Down
48 changes: 48 additions & 0 deletions src/lib/onboard/inference-capability-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it } from "vitest";

import { OnboardInferenceCapabilityCache } from "./inference-capability-cache";

describe("OnboardInferenceCapabilityCache", () => {
it("reuses one selected Chat Completions validation for one matching smoke request", () => {
const cache = new OnboardInferenceCapabilityCache();
const input = {
endpointUrl: "https://api.example.test/v1/",
model: "model-a",
authMode: "bearer" as const,
};

expect(cache.rememberCompletedOpenAiChat(input)).toBe(true);
expect(
cache.takeCompletedOpenAiChat({ ...input, endpointUrl: "https://api.example.test/v1" }),
).toBe(true);
expect(cache.takeCompletedOpenAiChat(input)).toBe(false);
});

it("does not reuse mismatched or security-sensitive validation", () => {
const cache = new OnboardInferenceCapabilityCache();
const input = {
endpointUrl: "https://api.example.test/v1",
model: "model-a",
authMode: "query-param" as const,
};

expect(cache.rememberCompletedOpenAiChat(input)).toBe(true);
expect(cache.takeCompletedOpenAiChat({ ...input, model: "model-b" })).toBe(false);
expect(cache.takeCompletedOpenAiChat({ ...input, authMode: "bearer" })).toBe(false);
expect(
cache.rememberCompletedOpenAiChat({
...input,
endpointUrl: "https://api.example.test/v1?key=x",
}),
).toBe(false);
expect(
cache.rememberCompletedOpenAiChat({ ...input, pinnedAddresses: ["93.184.216.34"] }),
).toBe(false);

cache.invalidate();
expect(cache.takeCompletedOpenAiChat(input)).toBe(false);
});
});
70 changes: 70 additions & 0 deletions src/lib/onboard/inference-capability-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

type OpenAiChatCapabilityInput = {
endpointUrl: string;
model: string;
authMode?: "bearer" | "query-param";
requireChatCompletionsToolCalling?: boolean;
extraHeaders?: readonly string[];
pinnedAddresses?: readonly string[];
};

function capabilityKey(input: OpenAiChatCapabilityInput): string | null {
// Query strings, embedded URL credentials, and custom headers can carry
// credentials. Do not retain either those values or a derived identifier.
if (input.extraHeaders?.length || input.pinnedAddresses?.length) return null;

let endpoint: URL;
try {
endpoint = new URL(input.endpointUrl);
} catch {
return null;
}
if (
(endpoint.protocol !== "http:" && endpoint.protocol !== "https:") ||
endpoint.username ||
endpoint.password ||
endpoint.search ||
endpoint.hash
) {
return null;
}

const model = input.model.trim();
if (!model || model !== input.model) return null;
endpoint.pathname = endpoint.pathname.replace(/\/+$/, "") || "/";
return JSON.stringify({
endpoint: endpoint.toString(),
authMode: input.authMode ?? "bearer",
model,
requireChatCompletionsToolCalling: input.requireChatCompletionsToolCalling === true,
});
}

/**
* One onboarding invocation may validate a selected Chat Completions route and
* then immediately run the same host-side smoke check. This cache is strictly
* in-memory, one-shot, and refuses credential-bearing or pinned paths.
*/
export class OnboardInferenceCapabilityCache {
readonly #entries = new Set<string>();

rememberCompletedOpenAiChat(input: OpenAiChatCapabilityInput): boolean {
const key = capabilityKey(input);
if (!key) return false;
this.#entries.add(key);
return true;
}

takeCompletedOpenAiChat(input: OpenAiChatCapabilityInput): boolean {
const key = capabilityKey(input);
if (!key || !this.#entries.has(key)) return false;
this.#entries.delete(key);
return true;
}

invalidate(): void {
this.#entries.clear();
}
}
4 changes: 4 additions & 0 deletions src/lib/onboard/inference-providers/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export async function setupRemoteProviderInference(
skipHostInferenceSmoke?: boolean;
preferredInferenceApi?: string | null;
pinnedAddresses?: readonly string[];
capabilityCache?: import("../inference-capability-cache").OnboardInferenceCapabilityCache;
},
deps: RemoteProviderDeps,
): Promise<{ done: true; result: SetupInferenceResult } | { done: false }> {
Expand All @@ -142,6 +143,7 @@ export async function setupRemoteProviderInference(
skipHostInferenceSmoke,
preferredInferenceApi,
pinnedAddresses,
capabilityCache,
} = args;
const {
runOpenshell,
Expand Down Expand Up @@ -321,6 +323,7 @@ export async function setupRemoteProviderInference(
}
}
if (!providerResult.ok) {
capabilityCache?.invalidate();
error(` ${providerResult.message}`);
if (isNonInteractive()) {
return exitProcess(providerResult.status || 1);
Expand Down Expand Up @@ -355,6 +358,7 @@ export async function setupRemoteProviderInference(
const message =
compactText(redact(`${applyResult.stderr || ""} ${applyResult.stdout || ""}`)) ||
`Failed to configure inference provider '${provider}'.`;
capabilityCache?.invalidate();
error(` ${message}`);
if (isNonInteractive()) {
return exitProcess(applyResult.status || 1);
Expand Down
2 changes: 2 additions & 0 deletions src/lib/onboard/inference-providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
// duplicate every helper's exact signature.

import type { HermesAuthMethod } from "../hermes-auth";
import type { OnboardInferenceCapabilityCache } from "../inference-capability-cache";

export type SetupInferenceResult = { ok: true; retry?: undefined } | { retry: "selection" };

Expand Down Expand Up @@ -66,6 +67,7 @@ export type VerifyOnboardInferenceSmoke = (input: {
credentialEnv?: string | null;
forceOpenAiLike?: boolean;
pinnedAddresses?: readonly string[];
capabilityCache?: OnboardInferenceCapabilityCache;
}) => void | Promise<void>;

export type PromptValidationRecovery = (
Expand Down
39 changes: 39 additions & 0 deletions src/lib/onboard/inference-selection-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,48 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";

import { OnboardInferenceCapabilityCache } from "./inference-capability-cache";
import { createInferenceSelectionValidationHelpers } from "./inference-selection-validation";

describe("inference selection validation", () => {
it("records a completed Chat Completions selection for the matching smoke check", async () => {
const capabilityCache = new OnboardInferenceCapabilityCache();
const helpers = createInferenceSelectionValidationHelpers({
isNonInteractive: () => false,
agentProductName: () => "OpenClaw",
getCredential: () => "test-key",
probeOpenAiLikeEndpoint: vi.fn(() => ({
ok: true,
api: "openai-completions",
label: "Chat Completions API",
})),
promptValidationRecovery: vi.fn(async () => "selection" as const),
});
const log = vi.spyOn(console, "log").mockImplementation(() => {});

try {
await expect(
helpers.validateOpenAiLikeSelection(
"OpenAI",
"https://api.example.test/v1/",
"model-a",
"OPENAI_API_KEY",
undefined,
undefined,
{ capabilityCache },
),
).resolves.toEqual({ ok: true, api: "openai-completions" });
expect(
capabilityCache.takeCompletedOpenAiChat({
endpointUrl: "https://api.example.test/v1",
model: "model-a",
}),
).toBe(true);
} finally {
log.mockRestore();
}
});

it("preserves non-zero exit signaling when non-interactive endpoint validation fails (#5721)", async () => {
const originalExitCode = process.exitCode;
const error = vi.spyOn(console, "error").mockImplementation(() => {});
Expand Down
16 changes: 15 additions & 1 deletion src/lib/onboard/inference-selection-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { getCredential } from "../credentials/store";
import { getCompatibleAnthropicOpenAiSurfaceBaseUrl } from "../inference/config";
import type { OnboardInferenceCapabilityCache } from "./inference-capability-cache";

const { probeAnthropicEndpoint, probeOpenAiLikeEndpointOptimized } =
require("../inference/onboard-probes") as {
Expand Down Expand Up @@ -78,6 +79,7 @@ export interface InferenceSelectionValidationHelpers {
skipResponsesProbe?: boolean;
probeStreaming?: boolean;
allowHostDockerInternal?: boolean;
capabilityCache?: OnboardInferenceCapabilityCache;
},
): Promise<EndpointValidationResult>;
validateAnthropicSelectionWithRetryMessage(
Expand Down Expand Up @@ -196,6 +198,7 @@ export function createInferenceSelectionValidationHelpers(
skipResponsesProbe?: boolean;
probeStreaming?: boolean;
allowHostDockerInternal?: boolean;
capabilityCache?: OnboardInferenceCapabilityCache;
} = {},
): Promise<EndpointValidationResult> {
const apiKey = credentialEnv ? resolveCredential(credentialEnv) : "";
Expand All @@ -204,6 +207,7 @@ export function createInferenceSelectionValidationHelpers(
calibrateTimeouts: true,
});
if (!probe.ok) {
options.capabilityCache?.invalidate();
printValidationFailure(label, probe);
if (deps.isNonInteractive()) {
exitNonInteractiveValidationFailure();
Expand All @@ -225,7 +229,17 @@ export function createInferenceSelectionValidationHelpers(
} else {
console.log(` ${probe.label} available — ${deps.agentProductName()} will use ${probe.api}.`);
}
return { ok: true, api: probe.api ?? "openai-completions" };
const api = probe.api ?? "openai-completions";
if (api === "openai-completions" && probe.validated !== false) {
options.capabilityCache?.rememberCompletedOpenAiChat({
endpointUrl,
model,
authMode: options.authMode,
requireChatCompletionsToolCalling: options.requireChatCompletionsToolCalling,
extraHeaders: options.extraHeaders,
});
}
return { ok: true, api };
}

async function validateAnthropicSelectionWithRetryMessage(
Expand Down
8 changes: 8 additions & 0 deletions src/lib/onboard/machine/handlers/provider-inference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
} from "../../../inference/gateway-route-compatibility";
import type { WebSearchConfig } from "../../../inference/web-search";
import type { HermesAuthMethod, Session, SessionUpdates } from "../../../state/onboard-session";
import type { OnboardInferenceCapabilityCache } from "../../inference-capability-cache";
import type {
createProviderRecoveryReceiptLedger,
ProviderRecoveryReceipt,
Expand Down Expand Up @@ -39,6 +40,8 @@ export interface ProviderInferenceSetupOptions {
preferredInferenceApi?: string | null;
/** Public addresses approved for custom endpoint host probes. */
endpointPinnedAddresses?: readonly string[];
/** One-shot host capability cache carried only through this onboarding run. */
inferenceCapabilityCache?: OnboardInferenceCapabilityCache;
/** Onboard session that owns the route reservation this setup creates. */
reservationSessionId?: string;
/** Recheck recorded-route ownership after acquiring route mutation locks. */
Expand All @@ -60,6 +63,7 @@ export interface ProviderSelectionResult {
reuseGatewayCredentialWithoutLocalKey?: boolean;
recoveredFromSandbox?: boolean;
endpointPinnedAddresses?: string[];
inferenceCapabilityCache?: OnboardInferenceCapabilityCache;
}

export interface ProviderInferenceStateOptions<Gpu, Agent, Host> {
Expand Down Expand Up @@ -339,6 +343,7 @@ export async function handleProviderInferenceState<Gpu, Agent, Host>({
let skipHostInferenceSmoke = false;
let reuseGatewayCredentialWithoutLocalKey = false;
let endpointPinnedAddresses: string[] | undefined;
let inferenceCapabilityCache: OnboardInferenceCapabilityCache | undefined;
const effectiveResume = resume && !fresh;
const stateResults: OnboardStateTransitionResult[] = [];
const retryStateResults: OnboardStateTransitionResult[] = [];
Expand Down Expand Up @@ -509,6 +514,7 @@ export async function handleProviderInferenceState<Gpu, Agent, Host>({
recoveredRecordedProvider = selection.recoveredFromSandbox === true;
forceInferenceSetup ||= recoveredRecordedProvider;
endpointPinnedAddresses = selection.endpointPinnedAddresses;
inferenceCapabilityCache = selection.inferenceCapabilityCache;
shouldRecordProviderSelection = true;
}

Expand Down Expand Up @@ -584,6 +590,7 @@ export async function handleProviderInferenceState<Gpu, Agent, Host>({
: {}),
...(preferredInferenceApi ? { preferredInferenceApi } : {}),
...(endpointPinnedAddresses ? { endpointPinnedAddresses } : {}),
...(inferenceCapabilityCache ? { inferenceCapabilityCache } : {}),
reservationSessionId: session?.sessionId,
};
await deps.startRecordedStep("inference", { provider, model });
Expand Down Expand Up @@ -772,6 +779,7 @@ export async function handleProviderInferenceState<Gpu, Agent, Host>({
...(reuseGatewayCredentialWithoutLocalKey ? { reuseGatewayCredentialWithoutLocalKey } : {}),
...(preferredInferenceApi ? { preferredInferenceApi } : {}),
...(endpointPinnedAddresses ? { endpointPinnedAddresses } : {}),
...(inferenceCapabilityCache ? { inferenceCapabilityCache } : {}),
...providerRecovery.setupOptions(
recoveredRecordedProvider,
confirmedSandboxName,
Expand Down
3 changes: 3 additions & 0 deletions src/lib/onboard/setup-inference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ export function createSetupInference(
deps.verifyOnboardInferenceSmoke({
...input,
pinnedAddresses: endpointPinnedAddresses,
capabilityCache: options.inferenceCapabilityCache,
}),
isNonInteractive: deps.isNonInteractive,
registry: {
Expand Down Expand Up @@ -364,6 +365,7 @@ export function createSetupInference(
skipHostInferenceSmoke: options.skipHostInferenceSmoke === true,
preferredInferenceApi: options.preferredInferenceApi ?? null,
pinnedAddresses: endpointPinnedAddresses,
capabilityCache: options.inferenceCapabilityCache,
},
{
...commonDeps,
Expand Down Expand Up @@ -449,6 +451,7 @@ export function createSetupInference(
endpointUrl,
credentialEnv,
pinnedAddresses: endpointPinnedAddresses,
capabilityCache: options.inferenceCapabilityCache,
});
if (sandboxName) {
commonDeps.registry.updateSandbox(sandboxName);
Expand Down
5 changes: 4 additions & 1 deletion src/lib/onboard/setup-nim-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";

import type { AgentDefinition } from "../agent/defs";
import type { VllmProfile } from "../inference/vllm";
import { OnboardInferenceCapabilityCache } from "./inference-capability-cache";
import { getWindowsHostOllamaDockerRequirement } from "./local-inference-topology";
import type { InferenceProviderHostState } from "./provider-host-state";
import { createSetupNim, type SetupNimFlowDeps } from "./setup-nim-flow";
Expand Down Expand Up @@ -280,7 +281,9 @@ describe("createSetupNim", () => {
expect(maybePromptForInferenceInputCapability).toHaveBeenCalledWith(
"nvidia/nemotron-3-super-120b-a12b",
);
expect(result).toEqual({
const { inferenceCapabilityCache, ...resultWithoutCache } = result;
expect(inferenceCapabilityCache).toBeInstanceOf(OnboardInferenceCapabilityCache);
expect(resultWithoutCache).toEqual({
model: "nvidia/nemotron-3-super-120b-a12b",
provider: "nvidia-prod",
endpointUrl: "https://integrate.api.nvidia.com/v1",
Expand Down
Loading
Loading