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
38 changes: 38 additions & 0 deletions src/lib/onboard-inference-probes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const {
getChatCompletionsProbeCurlArgs,
getChatCompletionsProbePayload,
getDeepSeekV4ProValidationProbeCurlArgs,
isSandboxInternalUrl,
probeOpenAiLikeEndpoint,
} = require("../../dist/lib/onboard-inference-probes");

Expand Down Expand Up @@ -59,6 +60,43 @@ describe("OpenAI-compatible inference probes", () => {
expect(args).toContain("Authorization: Bearer nvapi-test");
});

describe("sandbox-internal URL handling", () => {
it("identifies host.openshell.internal and host.docker.internal as sandbox-internal", () => {
expect(isSandboxInternalUrl("http://host.openshell.internal:8001/v1")).toBe(true);
expect(isSandboxInternalUrl("http://host.docker.internal:11434/v1")).toBe(true);
});

it("does not treat normal hostnames as sandbox-internal", () => {
expect(isSandboxInternalUrl("http://localhost:8001/v1")).toBe(false);
expect(isSandboxInternalUrl("https://api.openai.com/v1")).toBe(false);
expect(isSandboxInternalUrl("http://127.0.0.1:8001/v1")).toBe(false);
});

it("skips the curl probe for sandbox-internal URLs and returns ok with a note", () => {
const result = probeOpenAiLikeEndpoint(
"http://host.openshell.internal:8001/v1",
"openai/local-model",
"dummy",
);
expect(result).toMatchObject({
ok: true,
api: null,
note: expect.stringContaining("host.openshell.internal"),
});
expect(result.note).toMatch(/only resolves inside the sandbox/);
});

it("skips the curl probe for host.docker.internal and returns ok with a note", () => {
const result = probeOpenAiLikeEndpoint(
"http://host.docker.internal:11434/v1",
"openai/nemotron-mini",
"",
);
expect(result).toMatchObject({ ok: true, api: null });
expect(result.note).toMatch(/host\.docker\.internal/);
});
});

it("continues with openai-completions when DeepSeek V4 Pro stream validation times out", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-deepseek-probe-"));
const fakeBin = path.join(tmpDir, "bin");
Expand Down
25 changes: 25 additions & 0 deletions src/lib/onboard-inference-probes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ const {

// ── Helpers ──────────────────────────────────────────────────────

// Hostnames that only resolve from inside the OpenShell sandbox network.
// Probing them from the host always fails with curl exit 6 ("Could not
// resolve host"), so we skip host-side validation for these URLs. See #893.
const SANDBOX_INTERNAL_HOSTS = ["host.openshell.internal", "host.docker.internal"];

function isSandboxInternalUrl(url) {
try {
const { hostname } = new URL(String(url));
return SANDBOX_INTERNAL_HOSTS.includes(hostname);
} catch {
return false;
}
}

function parseJsonObject(body) {
if (!body) return null;
try {
Expand Down Expand Up @@ -247,6 +261,16 @@ function runChatCompletionsProbe({ authHeader, model, url, isWsl: isWslOverride
}

function probeOpenAiLikeEndpoint(endpointUrl, model, apiKey, options = {}) {
if (isSandboxInternalUrl(endpointUrl)) {
const { hostname } = new URL(String(endpointUrl));
return {
ok: true,
api: null,
label: null,
note: `${hostname} only resolves inside the sandbox — validation skipped. If the endpoint is unreachable at runtime, re-run onboard with a routable URL.`,
};
}

const useQueryParam = options.authMode === "query-param";
const normalizedKey = apiKey ? normalizeCredentialValue(apiKey) : "";
const baseUrl = String(endpointUrl).replace(/\/+$/, "");
Expand Down Expand Up @@ -479,6 +503,7 @@ function probeAnthropicEndpoint(endpointUrl, model, apiKey) {
}

module.exports = {
isSandboxInternalUrl,
parseJsonObject,
hasResponsesToolCall,
shouldRequireResponsesToolCalling,
Expand Down
21 changes: 15 additions & 6 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1149,7 +1149,7 @@ function upsertProvider(
type MessagingTokenDef = { name: string; envKey: string; token: string | null };

type EndpointValidationResult =
| { ok: true; api: string; retry?: undefined }
| { ok: true; api: string | null; retry?: undefined }
| { ok: false; retry: "credential" | "selection" | "retry" | "model"; api?: undefined };

type SelectionDrift = {
Expand Down Expand Up @@ -2234,8 +2234,12 @@ async function validateOpenAiLikeSelection(
}
return { ok: false, retry };
}
console.log(` ${probe.label} available — ${agentProductName()} will use ${probe.api}.`);
return { ok: true, api: probe.api };
if (probe.note) {
console.log(` ℹ ${probe.note}`);
} else {
console.log(` ${probe.label} available — ${agentProductName()} will use ${probe.api}.`);
}
return { ok: true, api: probe.api ?? "openai-completions" };
}

async function validateAnthropicSelectionWithRetryMessage(
Expand Down Expand Up @@ -2284,8 +2288,12 @@ async function validateCustomOpenAiLikeSelection(
probeStreaming: true,
});
if (probe.ok) {
console.log(` ${probe.label} available — ${agentProductName()} will use ${probe.api}.`);
return { ok: true, api: probe.api };
if (probe.note) {
console.log(` ℹ ${probe.note}`);
} else {
console.log(` ${probe.label} available — ${agentProductName()} will use ${probe.api}.`);
}
return { ok: true, api: probe.api ?? "openai-completions" };
}
console.error(` ${label} endpoint validation failed.`);
console.error(` ${probe.message}`);
Expand Down Expand Up @@ -5923,9 +5931,10 @@ async function setupNim(
console.error(" Local NVIDIA NIM base URL could not be determined.");
process.exit(1);
}
const nimValidationUrl = getLocalProviderValidationBaseUrl(provider) || endpointUrl;
const validation = await validateOpenAiLikeSelection(
"Local NVIDIA NIM",
endpointUrl,
nimValidationUrl,
requireValue(model, "Expected a Local NVIDIA NIM model after startup"),
null,
);
Expand Down
Loading