mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix: make gateway nodes and local inference reliable under stress (#115185)
This commit is contained in:
committed by
GitHub
parent
6d4e7b765c
commit
107bc4f963
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,6 +15,7 @@ export async function buildSglangProvider(params?: {
|
||||
baseUrl,
|
||||
apiKey: params?.apiKey,
|
||||
label: SGLANG_PROVIDER_LABEL,
|
||||
discoverRuntimeContext: false,
|
||||
});
|
||||
return {
|
||||
baseUrl,
|
||||
|
||||
@@ -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.
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" }] });
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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")],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user