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
5 changes: 5 additions & 0 deletions .changeset/nvidia-nim-availability-fail-open.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"openwiki": patch
---

fix: keep NVIDIA NIM availability validation fail-open on inconclusive catalogue lookups
10 changes: 4 additions & 6 deletions src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ import {
OPENWIKI_MODEL_ID_ENV_KEY,
OPENWIKI_PROVIDER_ENV_KEY,
OPENWIKI_PROVIDER_RETRY_ATTEMPTS_ENV_KEY,
providerBaseUrlIsCustom,
providerRequiresBaseUrl,
providerRequiresRegion,
providerRequiresSecretKey,
Expand Down Expand Up @@ -251,10 +252,7 @@ async function resolveRunConfig(
onProviderResolved(provider);

const providerBaseUrl = resolveProviderBaseUrl(provider);
const providerBaseUrlEnvKey = getProviderBaseUrlEnvKey(provider);
const providerBaseUrlIsCustom =
providerBaseUrlEnvKey !== undefined &&
Boolean(process.env[providerBaseUrlEnvKey]?.trim());
const baseUrlIsCustom = providerBaseUrlIsCustom(provider);
emitDebug(options, `provider=${provider}`);
if (providerBaseUrl) {
emitDebug(
Expand Down Expand Up @@ -291,11 +289,11 @@ async function resolveRunConfig(
modelId,
apiKey: getProviderApiKey(provider),
baseUrl: providerBaseUrl,
baseUrlIsCustom: providerBaseUrlIsCustom,
baseUrlIsCustom,
});
if (modelAvailability.status === "unavailable") {
throw new Error(
`${getProviderLabel(provider)} does not make model "${modelId}" available to the configured credentials. Set ${OPENWIKI_MODEL_ID_ENV_KEY} to an available model.`,
`${getProviderLabel(provider)} does not make model "${modelId}" available. ${modelAvailability.reason} Set ${OPENWIKI_MODEL_ID_ENV_KEY} to an available model.`,
);
}
if (modelAvailability.status === "unknown") {
Expand Down
32 changes: 32 additions & 0 deletions src/config/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,38 @@ export function resolveProviderBaseUrl(
return config.baseURL;
}

/**
* Reports whether the provider points at a user-supplied endpoint rather than
* its built-in default. Compares the resolved base URL against
* {@link ProviderConfig.baseURL} instead of testing whether the environment
* variable is set, so a user who pins the default value explicitly is not
* mistaken for a self-hosted deployment.
*/
export function providerBaseUrlIsCustom(
provider: OpenWikiProvider,
env: NodeJS.ProcessEnv = process.env,
): boolean {
const resolved = resolveProviderBaseUrl(provider, env);

if (!resolved) {
return false;
}

const defaultBaseUrl = getProviderConfig(provider).baseURL;
if (!defaultBaseUrl) {
return true;
}

return (
normalizeBaseUrlForComparison(resolved) !==
normalizeBaseUrlForComparison(defaultBaseUrl)
);
}

function normalizeBaseUrlForComparison(value: string): string {
return value.trim().replace(/\/+$/u, "");
}

export function getProviderBaseUrlEnvKey(
provider: OpenWikiProvider,
): string | undefined {
Expand Down
52 changes: 44 additions & 8 deletions src/model-availability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ type ModelListResponse = {

const OPENAI_API_BASE_URL = "https://api.openai.com/v1";

/**
* Wall-clock cap on the catalogue lookup. This runs before inference on an
* endpoint the user controls, so an unresponsive host must not stall the run:
* the abort surfaces as `unknown` and inference proceeds.
*/
const AVAILABILITY_LOOKUP_TIMEOUT_MS = 5_000;

/**
* Checks whether a selected model is exposed to the configured provider
* credential. `unknown` deliberately preserves the existing inference path:
Expand All @@ -40,14 +47,16 @@ export async function getSelectedModelAvailability(
}

if (check.provider === "nvidia") {
if (!check.baseUrlIsCustom || !check.baseUrl) {
const baseUrl = check.baseUrl;

if (!check.baseUrlIsCustom || !baseUrl) {
return {
status: "unknown",
reason: "The NVIDIA hosted endpoint is not validated.",
};
}

return checkNvidiaNimModelAvailability(check, fetchImpl);
return checkNvidiaNimModelAvailability(check, baseUrl, fetchImpl);
}

return {
Expand All @@ -71,12 +80,19 @@ async function checkOpenAIModelAvailability(

async function checkNvidiaNimModelAvailability(
check: ModelAvailabilityCheck,
baseUrl: string,
fetchImpl: typeof fetch,
): Promise<ModelAvailability> {
const endpoint = new URL(
"models",
ensureTrailingSlash(check.baseUrl!),
).toString();
let endpoint: string;

try {
endpoint = resolveModelListEndpoint(baseUrl);
} catch {
return {
status: "unknown",
reason: "The configured base URL could not be resolved to a catalogue.",
};
}

return checkModelListAvailability({
apiKey: check.apiKey,
Expand Down Expand Up @@ -110,6 +126,7 @@ async function checkModelListAvailability({
try {
const response = await fetchImpl(endpoint, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(AVAILABILITY_LOOKUP_TIMEOUT_MS),
});

if (!response.ok) {
Expand All @@ -127,6 +144,15 @@ async function checkModelListAvailability({
};
}

if (body.data.length === 0) {
// A gateway that hides its catalogue from a key without list scope still
// serves inference, so an empty listing is no evidence of unavailability.
return {
status: "unknown",
reason: "Model availability lookup returned an empty catalogue.",
};
}

if (body.data.some((model) => model.id === modelId)) {
return { status: "available" };
}
Expand All @@ -143,6 +169,16 @@ async function checkModelListAvailability({
}
}

function ensureTrailingSlash(value: string): string {
return value.endsWith("/") ? value : `${value}/`;
/**
* Appends `/models` to the API root's path. Building the URL from `pathname`
* rather than resolving a relative reference keeps a base URL that carries a
* query string or fragment from losing its last path segment.
*/
function resolveModelListEndpoint(baseUrl: string): string {
const url = new URL(baseUrl.trim());

url.pathname = `${url.pathname.replace(/\/+$/u, "")}/models`;
url.hash = "";

return url.toString();
}
46 changes: 46 additions & 0 deletions test/config/constants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
NVIDIA_BASE_URL_ENV_KEY,
normalizeModelId,
normalizeProvider,
OPENAI_BASE_URL_ENV_KEY,
providerBaseUrlIsCustom,
providerRequiresApiKey,
providerRequiresRegion,
providerRequiresSecretKey,
Expand Down Expand Up @@ -255,6 +257,50 @@ describe("resolveProviderBaseUrl", () => {
});
});

describe("providerBaseUrlIsCustom", () => {
test("is false when the provider falls back to its built-in default", () => {
expect(providerBaseUrlIsCustom("nvidia", {})).toBe(false);
expect(providerBaseUrlIsCustom("openai", {})).toBe(false);
});

test("is false when the override restates the built-in default", () => {
// Copying the documented endpoint into the env var is not a self-hosted
// deployment, so it must not make the hosted catalogue authoritative.
expect(
providerBaseUrlIsCustom("nvidia", {
[NVIDIA_BASE_URL_ENV_KEY]: "https://integrate.api.nvidia.com/v1",
}),
).toBe(false);
expect(
providerBaseUrlIsCustom("nvidia", {
[NVIDIA_BASE_URL_ENV_KEY]: " https://integrate.api.nvidia.com/v1/ ",
}),
).toBe(false);
});

test("is true for an endpoint that differs from the built-in default", () => {
expect(
providerBaseUrlIsCustom("nvidia", {
[NVIDIA_BASE_URL_ENV_KEY]: "https://nim.internal/v1",
}),
).toBe(true);
});

test("is true for a provider with no default once an override is set", () => {
expect(
providerBaseUrlIsCustom("openai", {
[OPENAI_BASE_URL_ENV_KEY]: "https://gateway.example/openai/v1",
}),
).toBe(true);
});

test("ignores a whitespace-only override", () => {
expect(
providerBaseUrlIsCustom("nvidia", { [NVIDIA_BASE_URL_ENV_KEY]: " " }),
).toBe(false);
});
});

describe("resolveProviderRetryAttempts", () => {
test("uses the OpenWiki default when no override is set", () => {
expect(resolveProviderRetryAttempts({})).toBe(
Expand Down
51 changes: 51 additions & 0 deletions test/model-availability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,55 @@ describe("getSelectedModelAvailability", () => {

expect(result).toMatchObject({ status: "unknown" });
});

test("does not block when a custom NVIDIA NIM endpoint hides its catalogue", async () => {
const result = await getSelectedModelAvailability(NVIDIA_NIM_CHECK, () =>
Promise.resolve(Response.json({ object: "list", data: [] })),
);

expect(result).toMatchObject({ status: "unknown" });
});

test("keeps the API root path when the base URL carries a query string", async () => {
const result = await getSelectedModelAvailability(
{ ...NVIDIA_NIM_CHECK, baseUrl: "https://nim.example/v1?api-version=1" },
(input) => {
expect(input).toBe("https://nim.example/v1/models?api-version=1");
return Promise.resolve(
Response.json({ data: [{ id: "nvidia/nemotron-test" }] }),
);
},
);

expect(result).toEqual({ status: "available" });
});

test("bounds a custom NVIDIA NIM lookup with an abort signal", async () => {
let signal: AbortSignal | undefined;

const result = await getSelectedModelAvailability(
NVIDIA_NIM_CHECK,
(_input, init) => {
signal = init?.signal ?? undefined;

return Promise.resolve(
Response.json({ data: [{ id: "nvidia/nemotron-test" }] }),
);
},
);

expect(result).toEqual({ status: "available" });
expect(signal).toBeInstanceOf(AbortSignal);
expect(signal?.aborted).toBe(false);
});

test("treats an aborted lookup as non-blocking", async () => {
const result = await getSelectedModelAvailability(NVIDIA_NIM_CHECK, () =>
Promise.reject(
new DOMException("The operation was aborted.", "TimeoutError"),
),
);

expect(result).toMatchObject({ status: "unknown" });
});
});