fix: make gateway nodes and local inference reliable under stress (#115185)

This commit is contained in:
Peter Steinberger
2026-07-28 08:41:14 -04:00
committed by GitHub
parent 6d4e7b765c
commit 107bc4f963
13 changed files with 184 additions and 15 deletions
+28 -2
View File
@@ -27,7 +27,10 @@ async function withOllamaServer<T>(
chatRequests: Record<string, unknown>[],
showRequests: string[],
) => Promise<T>,
options?: { models: Array<Record<string, unknown>> },
options?: {
models: Array<Record<string, unknown>>;
loadedModels?: Array<Record<string, unknown>>;
},
): Promise<T> {
const chatRequests: Record<string, unknown>[] = [];
const showRequests: string[] = [];
@@ -72,7 +75,7 @@ async function withOllamaServer<T>(
return;
}
if (request.url === "/api/ps") {
response.end(JSON.stringify({ models: [{ name: "chat:large" }] }));
response.end(JSON.stringify({ models: options?.loadedModels ?? [{ name: "chat:large" }] }));
return;
}
if (request.url === "/api/show") {
@@ -194,6 +197,29 @@ describe("Ollama node host inference", () => {
);
});
it("discovers a loaded local model beyond the completion model limit", async () => {
const models = [
...Array.from({ length: 200 }, (_, index) => ({ name: `chat-${index}:latest` })),
{ name: "chat:loaded", size: 500 },
];
await withOllamaServer(
async (baseUrl, _chatRequests, showRequests) => {
const result = JSON.parse(await commandByName(baseUrl, OLLAMA_MODELS_COMMAND).handle()) as {
provider: string;
models: Array<{ name: string; loaded: boolean }>;
};
expect(result.provider).toBe("ollama");
expect(result.models).toHaveLength(200);
expect(result.models[0]).toMatchObject({ name: "chat:loaded", loaded: true });
expect(showRequests[0]).toBe("chat:loaded");
expect(showRequests).toHaveLength(200);
},
{ models, loadedModels: [{ name: "chat:loaded" }] },
);
});
it("runs bounded chat and returns compact usage", async () => {
await withOllamaServer(async (baseUrl, chatRequests, showRequests) => {
const result = JSON.parse(
+10 -6
View File
@@ -188,12 +188,16 @@ async function discoverOllamaNodeModels(
const localModels = discovered.models.filter(
(model) => !model.remote_host?.trim() && !isOllamaCloudModel(model.name),
);
const [models, loadedNames] = await Promise.all([
// Paired nodes must positively confirm completion; unlike provider catalogs,
// failed or legacy show probes must never expose unrunnable remote commands.
enrichOllamaCompletionModels(apiBase, localModels, { requireCompletionCapability: true }),
fetchLoadedModelNames(apiBase),
]);
const loadedNames = await fetchLoadedModelNames(apiBase);
// Probe loaded models before the bounded catalog can hide already-runnable node models.
const prioritizedModels = localModels.toSorted(
(left, right) => Number(loadedNames.has(right.name)) - Number(loadedNames.has(left.name)),
);
// Paired nodes must positively confirm completion; unlike provider catalogs,
// failed or legacy show probes must never expose unrunnable remote commands.
const models = await enrichOllamaCompletionModels(apiBase, prioritizedModels, {
requireCompletionCapability: true,
});
const rows = models
.map((model): NodeModel => {
const details = model.details;
+1
View File
@@ -15,6 +15,7 @@ export async function buildSglangProvider(params?: {
baseUrl,
apiKey: params?.apiKey,
label: SGLANG_PROVIDER_LABEL,
discoverRuntimeContext: false,
});
return {
baseUrl,
+1
View File
@@ -15,6 +15,7 @@ export async function buildVllmProvider(params?: {
baseUrl,
apiKey: params?.apiKey,
label: VLLM_PROVIDER_LABEL,
discoverRuntimeContext: false,
});
return {
baseUrl,
@@ -0,0 +1,30 @@
title: Ollama paired-node local inference regression
scenario:
id: ollama-paired-node-inference
surface: models
category: agent-runtime.local-and-self-hosted-providers
coverage:
secondary:
- gateway.node-capabilities
- gateway.remote-host-commands
- agent-runtime.tool-capability-flags
- agent-runtime.local-smoke-checks
- agent-runtime.local-failure-handling
objective: Verify paired-node Ollama discovery and inference with the provider-owned deterministic local test server.
successCriteria:
- Embedding and cloud models are excluded from local chat discovery.
- A chat model after 200 embedding-only models remains discoverable.
- Loaded local chat models sort first.
- Only Gateway-authorized node commands are invoked.
- Bounded local chat returns the expected response and usage.
docsRefs:
- docs/providers/ollama.md
- docs/nodes/index.md
codeRefs:
- extensions/ollama/src/node-inference.ts
- extensions/ollama/src/node-inference.test.ts
execution:
kind: vitest
path: extensions/ollama/src/node-inference.test.ts
summary: Run deterministic fake-Ollama paired-node discovery, authorization, model-selection, and bounded-chat regression tests.
+25
View File
@@ -200,6 +200,31 @@ describe("runNodeDaemonStatus", () => {
mocks.service.readRuntime.mockReset().mockResolvedValue({ status: "running" });
});
it("reports a failed service check instead of claiming the node is not installed", async () => {
mocks.service.isLoaded.mockRejectedValue(new Error("systemd unavailable"));
await runNodeDaemonStatus();
expect(mocks.runtime.error).toHaveBeenCalledWith(
"Node service check failed: Error: systemd unavailable",
);
expect(mocks.runtime.exit).toHaveBeenCalledWith(1);
expect(stdout()).not.toContain("not loaded");
expect(stdout()).not.toContain("openclaw node install");
});
it("reports a failed service check as JSON without inventing node status", async () => {
mocks.service.isLoaded.mockRejectedValue(new Error("systemd unavailable"));
await runNodeDaemonStatus({ json: true });
expect(mocks.runtime.writeJson).toHaveBeenCalledWith({
error: "Node service check failed: Error: systemd unavailable",
});
expect(mocks.runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.runtime.error).not.toHaveBeenCalled();
});
it("keeps missing service-unit status on stderr and prints recovery hints on stdout", async () => {
mocks.service.readRuntime.mockResolvedValue({ status: "stopped", missingUnit: true });
+14 -2
View File
@@ -215,8 +215,20 @@ export async function runNodeDaemonStop(opts: NodeDaemonLifecycleOptions = {}) {
export async function runNodeDaemonStatus(opts: NodeDaemonStatusOptions = {}) {
const json = Boolean(opts.json);
const service = resolveNodeService();
const [loaded, command, runtime] = await Promise.all([
service.isLoaded({ env: process.env }).catch(() => false),
let loaded: boolean;
try {
loaded = await service.isLoaded({ env: process.env });
} catch (error) {
const message = `Node service check failed: ${String(error)}`;
if (json) {
defaultRuntime.writeJson({ error: message });
} else {
defaultRuntime.error(message);
}
defaultRuntime.exit(1);
return;
}
const [command, runtime] = await Promise.all([
service.readCommand(process.env).catch(() => null),
service
.readRuntime(process.env)
+1
View File
@@ -132,6 +132,7 @@ describe("mcp.tools.call.v1", () => {
{ signal: controller.signal },
);
await vi.waitFor(() => expect(callMcpTool).toHaveBeenCalledOnce());
expect(callMcpTool.mock.calls[0]?.[0].signal).toBe(controller.signal);
controller.abort();
resolveTool?.({ content: [{ type: "text", text: "stale MCP result" }] });
+3 -1
View File
@@ -720,7 +720,7 @@ async function dispatchInvoke(
}
if (command === NODE_MCP_TOOLS_CALL_COMMAND) {
await handleMcpToolsCall(frame, client, mcpManager);
await handleMcpToolsCall(frame, client, mcpManager, runtime.signal);
return;
}
@@ -1009,6 +1009,7 @@ async function handleMcpToolsCall(
frame: NodeInvokeRequestPayload,
client: NodeHostClient,
mcpManager: NodeHostMcpManager | undefined,
signal?: AbortSignal,
): Promise<void> {
if (!mcpManager) {
await sendErrorResult(client, frame, "MCP_SERVER_UNAVAILABLE", "node host MCP is unavailable");
@@ -1025,6 +1026,7 @@ async function handleMcpToolsCall(
const result = await mcpManager.callMcpTool({
...params,
timeoutMs: frame.timeoutMs ?? undefined,
...(signal ? { signal } : {}),
});
if (result.isError) {
await sendErrorResult(client, frame, "MCP_TOOL_ERROR", mcpToolErrorMessage(result));
+40 -2
View File
@@ -18,7 +18,7 @@ function tool(name: string, description?: string): Tool {
function createClient(params?: {
connectError?: Error;
tools?: Tool[];
call?: (options?: { timeout?: number }) => Promise<CallToolResult>;
call?: (options?: { timeout?: number; signal?: AbortSignal }) => Promise<CallToolResult>;
}) {
return {
onclose: undefined as (() => void) | undefined,
@@ -32,7 +32,7 @@ function createClient(params?: {
async (
_input: unknown,
_schema?: undefined,
options?: { timeout?: number },
options?: { timeout?: number; signal?: AbortSignal },
): Promise<CallToolResult> =>
params?.call ? await params.call(options) : { content: [{ type: "text", text: "ok" }] },
),
@@ -207,6 +207,44 @@ describe("node host MCP manager", () => {
await manager.close();
});
it("cancels an in-flight MCP tool when its node invocation is aborted", async () => {
const controller = new AbortController();
const client = createClient({
tools: [tool("slow")],
call: async (options) =>
await new Promise<CallToolResult>((_resolve, reject) => {
options?.signal?.addEventListener(
"abort",
() => {
const reason = options.signal?.reason;
reject(reason instanceof Error ? reason : new Error("node invocation canceled"));
},
{ once: true },
);
}),
});
const manager = await startNodeHostMcpManager(
{ docs: { command: "docs" } },
{ createClient: () => client, resolveTransport: () => transport, warn: vi.fn() },
);
const pending = manager.callMcpTool({
server: "docs",
tool: "slow",
signal: controller.signal,
});
await vi.waitFor(() => expect(client.callTool).toHaveBeenCalledOnce());
expect(client.callTool).toHaveBeenCalledWith({ name: "slow", arguments: {} }, undefined, {
timeout: 120_000,
signal: controller.signal,
});
controller.abort(new Error("node invocation canceled"));
await expect(pending).rejects.toMatchObject({ code: "MCP_TOOL_ERROR" });
await manager.close();
});
it("returns structured timeout, unknown-server, and dead-client errors", async () => {
const client = createClient({
tools: [tool("slow")],
+3 -1
View File
@@ -40,7 +40,7 @@ type NodeHostMcpClient = {
callTool(
params: { name: string; arguments?: Record<string, unknown> },
resultSchema?: undefined,
options?: { timeout?: number },
options?: { timeout?: number; signal?: AbortSignal },
): Promise<CallToolResult>;
close(): Promise<void>;
};
@@ -84,6 +84,7 @@ export type NodeHostMcpManager = {
tool: string;
arguments?: Record<string, unknown>;
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<CallToolResult>;
close(): Promise<void>;
};
@@ -397,6 +398,7 @@ export async function startNodeHostMcpManager(
undefined,
{
timeout: Math.min(resolveCallTimeoutMs(params.timeoutMs), session.toolCallTimeoutMs),
...(params.signal ? { signal: params.signal } : {}),
},
);
} catch (error) {
@@ -147,6 +147,32 @@ function cancelTrackedResponse(init?: ResponseInit): {
}
describe("discoverOpenAICompatibleLocalModels", () => {
it("discovers a large non-llama.cpp catalog without probing per-model llama.cpp props", async () => {
const release = vi.fn(async () => undefined);
const data = Array.from({ length: 500 }, (_, index) => ({
id: `Qwen/model-${index}`,
meta: { n_ctx_train: 32_768 },
}));
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response(JSON.stringify({ data }), { status: 200 }),
finalUrl: "http://127.0.0.1:8000/v1/models",
release,
});
const models = await discoverOpenAICompatibleLocalModels({
baseUrl: "http://127.0.0.1:8000/v1",
label: "vLLM",
discoverRuntimeContext: false,
env: {},
});
expect(models).toHaveLength(500);
expect(models[0]).toMatchObject({ id: "Qwen/model-0", contextWindow: 32_768 });
expect(models[499]).toMatchObject({ id: "Qwen/model-499", contextWindow: 32_768 });
expect(fetchWithSsrFGuardMock).toHaveBeenCalledOnce();
expect(release).toHaveBeenCalledOnce();
});
it("labels malformed discovery JSON in the warning", async () => {
const release = vi.fn(async () => undefined);
fetchWithSsrFGuardMock.mockResolvedValueOnce({
+2 -1
View File
@@ -167,6 +167,7 @@ export async function discoverOpenAICompatibleLocalModels(params: {
apiKey?: string;
label: string;
contextWindow?: number;
discoverRuntimeContext?: boolean;
maxTokens?: number;
env?: NodeJS.ProcessEnv;
}): Promise<ModelDefinitionConfig[]> {
@@ -212,7 +213,7 @@ export async function discoverOpenAICompatibleLocalModels(params: {
return [{ id: modelId, meta: model.meta }];
});
const runtimeContextTokensByModelId = new Map<string, number>();
if (params.contextWindow === undefined) {
if (params.contextWindow === undefined && params.discoverRuntimeContext !== false) {
const uniqueModelIds = uniqueStrings(discoveredModels.map((model) => model.id));
const runtimeContextTokenResults = await Promise.all(
uniqueModelIds.map(