Skip to content

Commit b4b6680

Browse files
authored
fix(onboard): distinguish Gemini runtime 404 (#9347)
<!-- markdownlint-disable MD041 --> ## Summary Distinguish a Google Gemini OpenAI-compatible Chat Completions 404 from native model-catalog validation. The report in #9298 attributes its failure to `/v1beta/openai/models`, but v0.0.108 and current `main` already validate Gemini models through `/v1beta/models`; the captured `Chat Completions API: HTTP 404` instead comes from the separate runtime route. This change preserves fail-closed validation. It does not accept native catalog availability as proof that the OpenAI-compatible route used by the sandbox can serve the model. A real-key reproduction of the runtime 404 is still needed before this PR can claim to resolve provider availability. ## Related Issue Refs #9298 ## Changes - Carry the selected provider into validation as diagnostic-only context without forwarding it to the network probe. - Explain an exact Gemini Chat Completions HTTP 404 as a runtime-route failure, not a native model-catalog failure. - Add regression tests for provider-context isolation, credential redaction, and the failure guidance. - Document the distinction and the reason onboarding stops. ## Type of Change - [ ] Code change (feature, bug fix, or refactor) - [x] Code change with doc updates - [ ] Doc only (prose changes, no code sample modifications) - [ ] Doc only (includes code sample changes) ## Quality Gates - [x] Tests added or updated for changed behavior - [ ] Existing tests cover changed behavior — justification: - [ ] Tests not applicable — justification: - [x] Sensitive paths changed (security, policy, credentials, preflight, onboarding, inference, runner, sandbox, or messaging) - [ ] Sensitive-path review completed or maintainer-approved waiver recorded — reviewer/approval link/justification: Pending external review from @cv. - [ ] Non-success, skipped, or missing CI check accepted by maintainer — check name, approval link, and follow-up issue: ## DGX Station Hardware Evidence - [ ] Tested on DGX Station - Tested commit: Not applicable - Station profile/scenario: Not applicable - Result: Not applicable - Supporting evidence: Not applicable ## Verification - [x] PR description includes a `Signed-off-by:` line and every commit appears as `Verified` in GitHub - [x] Normal `pre-commit`, `commit-msg`, and `pre-push` hooks passed, or `npm run validate:pr` passed after refreshing `origin/main` when hooks were skipped or unavailable - [x] Targeted behavior tests pass for the current change set, or tests are marked not applicable above — `npx vitest run --project cli src/lib/onboard/inference-selection-validation.test.ts src/lib/onboard/setup-nim-selection.test.ts` (34 passed) - [ ] Applicable broad gate passed — `npm test` for broad runtime/test-harness changes; `npm run check` for repo-wide validation/coverage changes — command/result: Not applicable; the change is limited to provider-specific failure diagnostics and wiring. - [x] Quality Gates section completed with required justifications or waivers - [x] No secrets, API keys, or credentials committed - [ ] `npm run docs` builds without warnings (doc changes only) — build succeeded with 0 errors and 2 pre-existing warnings - [x] Doc pages follow the [style guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md) (doc changes only) - [ ] New doc pages include SPDX header and frontmatter (new pages only) Additional validation: `npm run build:cli`, `npm --prefix nemoclaw run build`, `npm run typecheck:cli`, and `npm run checks:repository` passed. --- Signed-off-by: Prekshi Vyas <prekshiv@nvidia.com> Signed-off-by: Prekshi Vyas <prekshiv@nvidia.com>
1 parent 243ac5a commit b4b6680

5 files changed

Lines changed: 161 additions & 35 deletions

File tree

docs/inference/use-google-gemini.mdx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ Model validation can fail with these messages:
7474
The validation request failed.
7575
The output shows the provider, model, and API base URL.
7676
Compare these values with your configuration.
77+
- `Validation probe summary: Chat Completions API: HTTP 404.`
78+
This result comes from Google's OpenAI-compatible `/v1beta/openai/chat/completions` runtime route, not the native `/v1beta/models` catalog.
79+
A model appearing in the native catalog does not prove that the runtime route can serve it.
80+
NemoClaw stops because the sandbox uses the Chat Completions route for inference.
81+
Retry the request, then verify that the same key and model can invoke Google's OpenAI-compatible Chat Completions endpoint.
7782

7883
## Related Topics
7984

src/lib/onboard/inference-selection-validation.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,55 @@ describe("inference selection validation", () => {
8787
}
8888
});
8989

90+
it("distinguishes a Gemini runtime 404 from native model catalog validation (#9298)", async () => {
91+
const apiKey = "gemini-test-secret";
92+
const probeOpenAiLikeEndpoint = vi.fn(() => ({
93+
ok: false,
94+
failures: [{ name: "Chat Completions API", httpStatus: 404, curlStatus: 0 }],
95+
}));
96+
const promptValidationRecovery = vi.fn(async () => "selection" as const);
97+
const helpers = createInferenceSelectionValidationHelpers({
98+
isNonInteractive: () => false,
99+
agentProductName: () => "OpenClaw",
100+
getCredential: () => apiKey,
101+
probeOpenAiLikeEndpoint,
102+
promptValidationRecovery,
103+
});
104+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
105+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
106+
107+
try {
108+
await expect(
109+
helpers.validateOpenAiLikeSelection(
110+
"Google Gemini",
111+
"https://generativelanguage.googleapis.com/v1beta/openai",
112+
"gemini-2.5-flash",
113+
"GEMINI_API_KEY",
114+
undefined,
115+
undefined,
116+
{ provider: "gemini-api", skipResponsesProbe: true },
117+
),
118+
).resolves.toEqual({ ok: false, retry: "selection" });
119+
expect(probeOpenAiLikeEndpoint).toHaveBeenCalledWith(
120+
"https://generativelanguage.googleapis.com/v1beta/openai",
121+
"gemini-2.5-flash",
122+
apiKey,
123+
{ skipResponsesProbe: true, calibrateTimeouts: true },
124+
);
125+
const errorOutput = error.mock.calls.map((args) => args.join(" ")).join("\n");
126+
expect(errorOutput).toContain(
127+
"This 404 came from Google's OpenAI-compatible Chat Completions runtime route, not the native /v1beta/models catalog.",
128+
);
129+
expect(errorOutput).toContain(
130+
"the sandbox uses that Chat Completions route at runtime",
131+
);
132+
expect(errorOutput).not.toContain(apiKey);
133+
} finally {
134+
log.mockRestore();
135+
error.mockRestore();
136+
}
137+
});
138+
90139
it("preserves non-zero exit signaling when non-interactive endpoint validation fails (#5721)", async () => {
91140
const originalExitCode = process.exitCode;
92141
const error = vi.spyOn(console, "error").mockImplementation(() => {});

src/lib/onboard/inference-selection-validation.ts

Lines changed: 48 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,27 @@ export type EndpointValidationResult =
5656
}
5757
| { ok: false; retry: "credential" | "selection" | "retry" | "model"; api?: undefined };
5858

59+
export interface OpenAiSelectionValidationOptions {
60+
/** In-memory credential for managed local endpoints; never read from ambient env. */
61+
apiKey?: string | null;
62+
/** Approved no-DNS endpoint pin; [] also disables ambient proxies for managed IP URLs. */
63+
pinnedAddresses?: readonly string[];
64+
trustedPrivateCapability?: TrustedPrivateEndpointCapability;
65+
authMode?: "bearer" | "query-param";
66+
extraHeaders?: readonly string[];
67+
requireResponsesToolCalling?: boolean;
68+
requireChatCompletionsToolCalling?: boolean;
69+
retryChatCompletionsToolReadiness?: boolean;
70+
/** Provider identity used only for safe, provider-specific diagnostics. */
71+
provider?: string;
72+
73+
skipResponsesProbe?: boolean;
74+
probeStreaming?: boolean;
75+
allowHostDockerInternal?: boolean;
76+
probeFromDocker?: { expectedPort: number } | null;
77+
capabilityCache?: OnboardInferenceCapabilityCache;
78+
}
79+
5980
export interface InferenceSelectionValidationDeps {
6081
isNonInteractive(): boolean;
6182
agentProductName(): string;
@@ -87,24 +108,7 @@ export interface InferenceSelectionValidationHelpers {
87108
credentialEnv?: string | null,
88109
retryMessage?: string,
89110
helpUrl?: string | null,
90-
options?: {
91-
/** In-memory credential for managed local endpoints; never read from ambient env. */
92-
apiKey?: string | null;
93-
/** Approved no-DNS endpoint pin; [] also disables ambient proxies for managed IP URLs. */
94-
pinnedAddresses?: readonly string[];
95-
trustedPrivateCapability?: TrustedPrivateEndpointCapability;
96-
authMode?: "bearer" | "query-param";
97-
extraHeaders?: readonly string[];
98-
requireResponsesToolCalling?: boolean;
99-
requireChatCompletionsToolCalling?: boolean;
100-
retryChatCompletionsToolReadiness?: boolean;
101-
102-
skipResponsesProbe?: boolean;
103-
probeStreaming?: boolean;
104-
allowHostDockerInternal?: boolean;
105-
probeFromDocker?: { expectedPort: number } | null;
106-
capabilityCache?: OnboardInferenceCapabilityCache;
107-
},
111+
options?: OpenAiSelectionValidationOptions,
108112
): Promise<EndpointValidationResult>;
109113
validateAnthropicSelectionWithRetryMessage(
110114
label: string,
@@ -183,6 +187,29 @@ export function createInferenceSelectionValidationHelpers(
183187
console.error(" Validation details were omitted to avoid exposing credentials.");
184188
}
185189

190+
function printGeminiRuntimeNotFoundGuidance(
191+
provider: string | undefined,
192+
probe: { failures?: unknown[] },
193+
): void {
194+
if (provider !== "gemini-api" || !Array.isArray(probe.failures)) return;
195+
const chatNotFound = probe.failures.some((failure) => {
196+
if (!failure || typeof failure !== "object") return false;
197+
const { name, httpStatus } = failure as Record<string, unknown>;
198+
return (
199+
typeof name === "string" &&
200+
name.startsWith("Chat Completions API") &&
201+
httpStatus === 404
202+
);
203+
});
204+
if (!chatNotFound) return;
205+
console.error(
206+
" This 404 came from Google's OpenAI-compatible Chat Completions runtime route, not the native /v1beta/models catalog.",
207+
);
208+
console.error(
209+
" NemoClaw cannot continue from catalog availability alone because the sandbox uses that Chat Completions route at runtime.",
210+
);
211+
}
212+
186213
// DNS-backed SSRF preflight for user-supplied custom endpoints. Resolves the
187214
// endpoint host and fails closed before any host-side probe curl when it (or
188215
// a resolved address) is private/reserved, so a public-looking name that
@@ -274,24 +301,9 @@ export function createInferenceSelectionValidationHelpers(
274301
credentialEnv: string | null = null,
275302
retryMessage = "Please choose a provider/model again.",
276303
helpUrl: string | null = null,
277-
options: {
278-
apiKey?: string | null;
279-
pinnedAddresses?: readonly string[];
280-
trustedPrivateCapability?: TrustedPrivateEndpointCapability;
281-
authMode?: "bearer" | "query-param";
282-
extraHeaders?: readonly string[];
283-
requireResponsesToolCalling?: boolean;
284-
requireChatCompletionsToolCalling?: boolean;
285-
retryChatCompletionsToolReadiness?: boolean;
286-
287-
skipResponsesProbe?: boolean;
288-
probeStreaming?: boolean;
289-
allowHostDockerInternal?: boolean;
290-
probeFromDocker?: { expectedPort: number } | null;
291-
capabilityCache?: OnboardInferenceCapabilityCache;
292-
} = {},
304+
options: OpenAiSelectionValidationOptions = {},
293305
): Promise<EndpointValidationResult> {
294-
const { apiKey: explicitApiKey, ...probeOptions } = options;
306+
const { apiKey: explicitApiKey, provider, ...probeOptions } = options;
295307
const apiKey =
296308
explicitApiKey !== undefined
297309
? explicitApiKey
@@ -305,6 +317,7 @@ export function createInferenceSelectionValidationHelpers(
305317
if (!probe.ok) {
306318
probeOptions.capabilityCache?.invalidate();
307319
printValidationFailure(label, probe);
320+
printGeminiRuntimeNotFoundGuidance(provider, probe);
308321
if (deps.isNonInteractive()) {
309322
exitNonInteractiveValidationFailure();
310323
}

src/lib/onboard/setup-nim-selection.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,4 +221,61 @@ describe("createRemoteModelValidator", () => {
221221
assert.equal(state.model, "nvidia/local-nim");
222222
assert.equal(state.nimContainer, "nemoclaw-nim-test");
223223
});
224+
225+
it("passes the selected provider only as validation context (#9298)", async () => {
226+
const state = makeState();
227+
state.provider = "gemini-api";
228+
state.endpointUrl = "https://generativelanguage.googleapis.com/v1beta/openai";
229+
state.model = "gemini-2.5-flash";
230+
let receivedOptions: unknown;
231+
const { validateSelectedRemoteModel } = createRemoteModelValidator({
232+
OPENAI_ENDPOINT_URL: "https://default-openai.example/v1",
233+
ANTHROPIC_ENDPOINT_URL: "https://default-anthropic.example/v1",
234+
requireValue,
235+
isBackToSelection: (_value): _value is never => false,
236+
validateCustomOpenAiLikeSelection: async () => ({ ok: false, retry: "selection" }),
237+
validateCustomAnthropicSelection: async () => ({ ok: false, retry: "selection" }),
238+
validateAnthropicSelectionWithRetryMessage: async () => ({
239+
ok: false,
240+
retry: "selection",
241+
}),
242+
validateOpenAiLikeSelection: async (
243+
_label,
244+
_endpointUrl,
245+
_model,
246+
_credentialEnv,
247+
_retryMessage,
248+
_helpUrl,
249+
options,
250+
) => {
251+
receivedOptions = options;
252+
return { ok: true, api: "openai-completions" };
253+
},
254+
shouldRequireResponsesToolCalling: () => true,
255+
shouldSkipResponsesProbe: () => true,
256+
getProbeAuthMode: () => undefined,
257+
});
258+
259+
assert.equal(
260+
await validateSelectedRemoteModel({
261+
selected: { key: "gemini" },
262+
remoteConfig: {
263+
label: "Google Gemini",
264+
endpointUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
265+
helpUrl: null,
266+
},
267+
state,
268+
selectedCredentialEnv: "GEMINI_API_KEY",
269+
}),
270+
"selected",
271+
);
272+
assert.deepEqual(receivedOptions, {
273+
provider: "gemini-api",
274+
requireResponsesToolCalling: true,
275+
skipResponsesProbe: true,
276+
authMode: undefined,
277+
extraHeaders: [],
278+
capabilityCache: undefined,
279+
});
280+
});
224281
});

src/lib/onboard/setup-nim-selection.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ type ProbeOptions = {
206206
authMode?: ProbeAuthMode;
207207
extraHeaders?: readonly string[];
208208
capabilityCache?: OnboardInferenceCapabilityCache;
209+
provider?: string;
209210
};
210211

211212
type ValidationResult =
@@ -425,6 +426,7 @@ export function createRemoteModelValidator(deps: RemoteModelValidatorDeps): {
425426
retryMessage,
426427
remoteConfig.helpUrl,
427428
{
429+
provider: state.provider,
428430
requireResponsesToolCalling: deps.shouldRequireResponsesToolCalling(state.provider),
429431
skipResponsesProbe: deps.shouldSkipResponsesProbe(state.provider),
430432
authMode: deps.getProbeAuthMode(state.provider),

0 commit comments

Comments
 (0)