Skip to content

Commit 3f3f6cd

Browse files
committed
test(status): verify route drift health outcome
Signed-off-by: Apurv Kumaria <akumaria@nvidia.com>
1 parent 187982c commit 3f3f6cd

4 files changed

Lines changed: 44 additions & 33 deletions

File tree

docs/inference/verify-inference-route.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ $$nemoclaw <name> status
3232

3333
The `Inference` row first checks the sandbox's `inference.local` path.
3434
When that route responds, `status` sends one inference request through the same path.
35-
The row reports `healthy` only when the route returns a structurally valid result for the recorded API family.
35+
When the live provider matches the recorded provider, `status` validates the result against the recorded API family, even when only the model differs.
36+
When the live provider differs, `status` does not carry the recorded API family to the live provider.
37+
The row reports `healthy` only when the route returns a structurally valid result for the selected API family.
3638
An empty body, malformed JSON, provider-error envelope, or wrong response shape reports `unhealthy`, even with a 2xx status.
3739
Status diagnostics do not include the response body.
3840
An HTTP `401` or `403` response reports `unauthorized`.

docs/reference/commands.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1876,8 +1876,9 @@ An empty body, malformed JSON, provider-error envelope, or wrong response shape
18761876
The probe captures at most 64 KiB and does not include the response body in diagnostics.
18771877
The route probe treats any final HTTP status from `200` through `499` as reachable, so a route with an invalidated provider credential answers HTTP `401` while the route is up.
18781878
The request uses the live gateway route's provider and model, and falls back to the recorded values when the live route is unreadable.
1879-
When the live provider and model match the recorded route, the request also uses the sandbox's recorded API family, including `openai-responses`.
1880-
During route drift, NemoClaw does not carry the sandbox's recorded API family to the live provider and model.
1879+
When the live provider matches the recorded provider, the request uses the sandbox's recorded API family, even when only the model differs.
1880+
This includes `openai-responses`.
1881+
When the live provider differs, NemoClaw does not carry the recorded API family to the live provider.
18811882
An ordinary run sends one 16-token request through the stored provider credential, with a 30-second timeout, and consumes provider tokens on a hosted route.
18821883
When the same `status` run recovers a managed gateway, it retries the route and inference request together up to three total attempts, with a two-second delay between failed attempts.
18831884
Each attempt can consume another 16 tokens on a hosted route.

src/lib/actions/sandbox/status-snapshot-route-drift.test.ts

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -190,21 +190,20 @@ describe("collectSandboxStatusSnapshot inference invocation route (#9302)", () =
190190
});
191191

192192
/**
193-
* Capture the route the in-sandbox invocation probe is asked to exercise.
194-
* The probe reports the health that decides `status --json`'s exit code.
193+
* The probe stub rejects a request that does not contain every expected route field.
195194
*/
196-
async function captureInvocationRoute(
195+
async function collectInferenceHealth(
197196
entry: Partial<SandboxEntry>,
198-
): Promise<Record<string, unknown> | null> {
199-
let seen: Record<string, unknown> | null = null;
197+
expectedRoute: Record<string, unknown>,
198+
) {
200199
const sandbox = {
201200
name: "alpha",
202201
agent: "openclaw",
203202
policies: [],
204203
gatewayName: "nemoclaw",
205204
...entry,
206205
} as SandboxEntry;
207-
await collectSandboxStatusSnapshot("alpha", {
206+
const snapshot = await collectSandboxStatusSnapshot("alpha", {
208207
deps: {
209208
getSandbox: () => sandbox,
210209
listSandboxes: () => ({ sandboxes: [sandbox], defaultSandbox: "alpha" }),
@@ -217,12 +216,14 @@ describe("collectSandboxStatusSnapshot inference invocation route (#9302)", () =
217216
httpStatus: 200,
218217
}),
219218
probeSandboxInferenceInvocationImpl: (input: Record<string, unknown>) => {
220-
seen = input;
221-
return { ok: true };
219+
const accepted = Object.entries(expectedRoute).every(
220+
([key, value]) => input[key] === value,
221+
);
222+
return accepted ? { ok: true } : { ok: false, reason: "unexpected invocation route" };
222223
},
223224
},
224225
} as never);
225-
return seen;
226+
return snapshot.inferenceHealth;
226227
}
227228

228229
const recorded = {
@@ -234,36 +235,43 @@ describe("collectSandboxStatusSnapshot inference invocation route (#9302)", () =
234235
} satisfies Partial<SandboxEntry>;
235236

236237
it("keeps the recorded API family when only the model drifted", async () => {
237-
// The recorded family describes the provider, which has not changed, so
238+
// The recorded API family describes the provider, which has not changed, so
238239
// dropping it would probe /v1/chat/completions against a responses-only
239240
// endpoint and report a healthy route as unhealthy.
240241
liveGatewayInference("compatible-endpoint", "live/model");
241242

242-
expect(await captureInvocationRoute(recorded)).toMatchObject({
243-
provider: "compatible-endpoint",
244-
model: "live/model",
245-
preferredInferenceApi: "openai-responses",
246-
});
243+
expect(
244+
await collectInferenceHealth(recorded, {
245+
provider: "compatible-endpoint",
246+
model: "live/model",
247+
preferredInferenceApi: "openai-responses",
248+
}),
249+
).toMatchObject({ ok: true, probed: true });
247250
});
248251

249252
it("keeps the recorded API family when the route is aligned", async () => {
250253
liveGatewayInference("compatible-endpoint", "recorded/model");
251254

252-
expect(await captureInvocationRoute(recorded)).toMatchObject({
253-
model: "recorded/model",
254-
preferredInferenceApi: "openai-responses",
255-
});
255+
expect(
256+
await collectInferenceHealth(recorded, {
257+
provider: "compatible-endpoint",
258+
model: "recorded/model",
259+
preferredInferenceApi: "openai-responses",
260+
}),
261+
).toMatchObject({ ok: true, probed: true });
256262
});
257263

258264
it("drops the recorded API family when the provider itself drifted", async () => {
259-
// Regression lock: one provider's family must never be carried onto
260-
// another that may have no such endpoint.
265+
// A provider change must remove the recorded API family because the live
266+
// provider might not implement it.
261267
liveGatewayInference("nvidia-prod", "nvidia/nemotron");
262268

263-
expect(await captureInvocationRoute(recorded)).toMatchObject({
264-
provider: "nvidia-prod",
265-
model: "nvidia/nemotron",
266-
preferredInferenceApi: null,
267-
});
269+
expect(
270+
await collectInferenceHealth(recorded, {
271+
provider: "nvidia-prod",
272+
model: "nvidia/nemotron",
273+
preferredInferenceApi: null,
274+
}),
275+
).toMatchObject({ ok: true, probed: true });
268276
});
269277
});

src/lib/actions/sandbox/status-snapshot.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -589,11 +589,11 @@ export async function collectSandboxStatusSnapshot(
589589
? {
590590
provider: live.provider,
591591
model: live.model,
592-
// The live gateway RPC does not expose a stored API override. The
593-
// recorded family describes the recorded *provider*, so it keeps
594-
// describing the live route while that provider is unchanged
592+
// The live gateway RPC does not expose a stored API family. The
593+
// recorded API family describes the recorded provider, so it keeps
594+
// describing the live route while that provider is unchanged,
595595
// including when only the model drifted. Drop it only when the
596-
// provider itself changed, so one provider's family cannot be
596+
// provider itself changed, so one provider's API family cannot be
597597
// carried onto another that has no such endpoint (#9302).
598598
preferredInferenceApi:
599599
live.provider === sb?.provider ? (sb?.preferredInferenceApi ?? null) : null,

0 commit comments

Comments
 (0)