Skip to content

Commit 187982c

Browse files
committed
fix(status): keep the recorded API family when only the model drifts
Sandbox status probes the live shared route with one real inference request, and that request's result decides `status --json`'s exit code. The probe dropped the sandbox's recorded API family whenever the live route was not exactly aligned, including when the shared route drifted by model alone and the provider was unchanged. For a compatible endpoint the recorded family is the only signal that the route speaks openai-responses or anthropic-messages, so dropping it fell back to openai-completions and sent the request to an endpoint the provider does not serve. A healthy route was then reported unhealthy and the command exited nonzero, which is what automation reading the route drift fields observes. Keep the recorded family while the live provider matches the recorded provider, and drop it only when the provider itself changed, so one provider's family is still never carried onto another. Refs #9302 Signed-off-by: Yanyun Liao <yanyunl@nvidia.com>
1 parent 588bb6d commit 187982c

2 files changed

Lines changed: 91 additions & 4 deletions

File tree

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

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,3 +183,87 @@ describe("collectSandboxStatusSnapshot route drift", () => {
183183
expect(snapshot.routeDrift).toMatchObject({ canConnect: false });
184184
});
185185
});
186+
187+
describe("collectSandboxStatusSnapshot inference invocation route (#9302)", () => {
188+
afterEach(() => {
189+
vi.clearAllMocks();
190+
});
191+
192+
/**
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.
195+
*/
196+
async function captureInvocationRoute(
197+
entry: Partial<SandboxEntry>,
198+
): Promise<Record<string, unknown> | null> {
199+
let seen: Record<string, unknown> | null = null;
200+
const sandbox = {
201+
name: "alpha",
202+
agent: "openclaw",
203+
policies: [],
204+
gatewayName: "nemoclaw",
205+
...entry,
206+
} as SandboxEntry;
207+
await collectSandboxStatusSnapshot("alpha", {
208+
deps: {
209+
getSandbox: () => sandbox,
210+
listSandboxes: () => ({ sandboxes: [sandbox], defaultSandbox: "alpha" }),
211+
reconcile: async () => ({ state: "present", output: "Phase: Ready" }),
212+
probeProviderHealthImpl: () => null,
213+
probeSandboxInferenceGatewayHealthImpl: async () => ({
214+
ok: true,
215+
endpoint: "https://inference.local/v1/models",
216+
detail: "reachable",
217+
httpStatus: 200,
218+
}),
219+
probeSandboxInferenceInvocationImpl: (input: Record<string, unknown>) => {
220+
seen = input;
221+
return { ok: true };
222+
},
223+
},
224+
} as never);
225+
return seen;
226+
}
227+
228+
const recorded = {
229+
provider: "compatible-endpoint",
230+
model: "recorded/model",
231+
endpointUrl: "https://target.example/v1",
232+
credentialEnv: "TARGET_KEY",
233+
preferredInferenceApi: "openai-responses",
234+
} satisfies Partial<SandboxEntry>;
235+
236+
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+
// dropping it would probe /v1/chat/completions against a responses-only
239+
// endpoint and report a healthy route as unhealthy.
240+
liveGatewayInference("compatible-endpoint", "live/model");
241+
242+
expect(await captureInvocationRoute(recorded)).toMatchObject({
243+
provider: "compatible-endpoint",
244+
model: "live/model",
245+
preferredInferenceApi: "openai-responses",
246+
});
247+
});
248+
249+
it("keeps the recorded API family when the route is aligned", async () => {
250+
liveGatewayInference("compatible-endpoint", "recorded/model");
251+
252+
expect(await captureInvocationRoute(recorded)).toMatchObject({
253+
model: "recorded/model",
254+
preferredInferenceApi: "openai-responses",
255+
});
256+
});
257+
258+
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.
261+
liveGatewayInference("nvidia-prod", "nvidia/nemotron");
262+
263+
expect(await captureInvocationRoute(recorded)).toMatchObject({
264+
provider: "nvidia-prod",
265+
model: "nvidia/nemotron",
266+
preferredInferenceApi: null,
267+
});
268+
});
269+
});

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -589,11 +589,14 @@ 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. Do
593-
// not carry an API family across route drift. When the live pair
594-
// is unchanged, the recorded family still describes that route.
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 —
595+
// including when only the model drifted. Drop it only when the
596+
// provider itself changed, so one provider's family cannot be
597+
// carried onto another that has no such endpoint (#9302).
595598
preferredInferenceApi:
596-
routeDriftPlan?.kind === "aligned" ? (sb?.preferredInferenceApi ?? null) : null,
599+
live.provider === sb?.provider ? (sb?.preferredInferenceApi ?? null) : null,
597600
}
598601
: {
599602
provider: currentProvider,

0 commit comments

Comments
 (0)