Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
84 changes: 84 additions & 0 deletions src/lib/actions/sandbox/status-snapshot-route-drift.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,87 @@ describe("collectSandboxStatusSnapshot route drift", () => {
expect(snapshot.routeDrift).toMatchObject({ canConnect: false });
});
});

describe("collectSandboxStatusSnapshot inference invocation route (#9302)", () => {
afterEach(() => {
vi.clearAllMocks();
});

/**
* Capture the route the in-sandbox invocation probe is asked to exercise.
* The probe reports the health that decides `status --json`'s exit code.
*/
async function captureInvocationRoute(
entry: Partial<SandboxEntry>,
): Promise<Record<string, unknown> | null> {
let seen: Record<string, unknown> | null = null;
const sandbox = {
name: "alpha",
agent: "openclaw",
policies: [],
gatewayName: "nemoclaw",
...entry,
} as SandboxEntry;
await collectSandboxStatusSnapshot("alpha", {
deps: {
getSandbox: () => sandbox,
listSandboxes: () => ({ sandboxes: [sandbox], defaultSandbox: "alpha" }),
reconcile: async () => ({ state: "present", output: "Phase: Ready" }),
probeProviderHealthImpl: () => null,
probeSandboxInferenceGatewayHealthImpl: async () => ({
ok: true,
endpoint: "https://inference.local/v1/models",
detail: "reachable",
httpStatus: 200,
}),
probeSandboxInferenceInvocationImpl: (input: Record<string, unknown>) => {
seen = input;
return { ok: true };
},
},
} as never);
return seen;
}

const recorded = {
provider: "compatible-endpoint",
model: "recorded/model",
endpointUrl: "https://target.example/v1",
credentialEnv: "TARGET_KEY",
preferredInferenceApi: "openai-responses",
} satisfies Partial<SandboxEntry>;

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

expect(await captureInvocationRoute(recorded)).toMatchObject({
provider: "compatible-endpoint",
model: "live/model",
preferredInferenceApi: "openai-responses",
});
});

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

expect(await captureInvocationRoute(recorded)).toMatchObject({
model: "recorded/model",
preferredInferenceApi: "openai-responses",
});
});

it("drops the recorded API family when the provider itself drifted", async () => {
// Regression lock: one provider's family must never be carried onto
// another that may have no such endpoint.
liveGatewayInference("nvidia-prod", "nvidia/nemotron");

expect(await captureInvocationRoute(recorded)).toMatchObject({
provider: "nvidia-prod",
model: "nvidia/nemotron",
preferredInferenceApi: null,
});
});
});
11 changes: 7 additions & 4 deletions src/lib/actions/sandbox/status-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,11 +589,14 @@ export async function collectSandboxStatusSnapshot(
? {
provider: live.provider,
model: live.model,
// The live gateway RPC does not expose a stored API override. Do
// not carry an API family across route drift. When the live pair
// is unchanged, the recorded family still describes that route.
// The live gateway RPC does not expose a stored API override. The
// recorded family describes the recorded *provider*, so it keeps
// describing the live route while that provider is unchanged —
// including when only the model drifted. Drop it only when the
// provider itself changed, so one provider's family cannot be
// carried onto another that has no such endpoint (#9302).
preferredInferenceApi:
routeDriftPlan?.kind === "aligned" ? (sb?.preferredInferenceApi ?? null) : null,
live.provider === sb?.provider ? (sb?.preferredInferenceApi ?? null) : null,
}
: {
provider: currentProvider,
Expand Down
Loading