fix(coding-agent): name the llama.cpp server and cause when it cannot be reached (#11779)

When `/login llama.cpp` could not connect to the server URL, pi showed
only `Error: Failed to save API key for llama.cpp: fetch failed`. That
happens on a refused connection, a wrong port, or `https://` against a
plain-HTTP server. Node's `fetch` keeps the real reason in
`error.cause`, and pi dropped it, so the user had nothing to go on.

`LlamaClient` now sends its requests, including the `/models/sse` watch,
through a small `fetchServer` helper. When `fetch` fails at the network
level, the helper throws `Could not reach llama.cpp server at <url>:
<cause>` and keeps the original error as `cause`. Aborts and timeouts
are rethrown unchanged. HTTP error responses are handled as before.

The same message also improves `/llama` and the model catalog refresh,
because they use the same client.

**Testing**
- Added a test in `test/llama-extension.test.ts` that calls
`LlamaClient.list()` against a closed local port and expects the server
URL and `connect ECONNREFUSED ...` in the error. It fails without the
change (`got 'fetch failed'`) and passes with it. The whole file passes
(16 tests).
- `npm run check` passes.
- Manual check, `/login llama.cpp` with `http://127.0.0.1:8099` (nothing
listening):
  - Before: `Error: Failed to save API key for llama.cpp: fetch failed`
- After: `Error: Failed to save API key for llama.cpp: Could not reach
llama.cpp server at http://127.0.0.1:8099: connect ECONNREFUSED
127.0.0.1:8099`

Fixes #11745

Requested by @badlogic

Co-authored-by: triagent <triagent@localhost>
This commit is contained in:
robopipi[bot] 2026-11-04 09:32:51 +01:00 • committed by GitHub
parent 2af8a42f5f
commit ea0f49a365
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 34 additions and 2 deletions

View file

@ -158,6 +158,13 @@ export function llamaInferenceUrl(serverUrl: string): string {
return `${normalizeLlamaServerUrl(serverUrl)}/v1`;
}
function connectionErrorDetail(error: unknown): string {
const cause = error instanceof Error ? error.cause : undefined;
if (cause instanceof Error && cause.message) return cause.message;
if (cause && typeof cause === "object" && "code" in cause && typeof cause.code === "string") return cause.code;
return error instanceof Error ? error.message : String(error);
}
export class LlamaClient {
readonly serverUrl: string;
private readonly apiKey: string | undefined;
@ -167,13 +174,25 @@ export class LlamaClient {
this.apiKey = apiKey;
}
/** Fetches from the server, naming the server and the network-level cause when the connection fails. */
private async fetchServer(path: string, init: RequestInit): Promise<Response> {
try {
return await fetch(`${this.serverUrl}${path}`, init);
} catch (error) {
if (init.signal?.aborted) throw error;
throw new Error(`Could not reach llama.cpp server at ${this.serverUrl}: ${connectionErrorDetail(error)}`, {
cause: error,
});
}
}
private async request(path: string, init: RequestInit = {}): Promise<unknown> {
const headers = new Headers(init.headers);
if (init.body !== undefined) headers.set("Content-Type", "application/json");
if (this.apiKey) headers.set("Authorization", `Bearer ${this.apiKey}`);
const timeout = AbortSignal.timeout(15_000);
const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
const response = await fetch(`${this.serverUrl}${path}`, { ...init, headers, signal });
const response = await this.fetchServer(path, { ...init, headers, signal });
let payload: unknown;
try {
payload = await response.json();
@ -229,7 +248,7 @@ export class LlamaClient {
async watch(onEvent: (event: LlamaModelEvent) => void, signal?: AbortSignal): Promise<void> {
const headers = new Headers();
if (this.apiKey) headers.set("Authorization", `Bearer ${this.apiKey}`);
const response = await fetch(`${this.serverUrl}/models/sse`, { headers, signal });
const response = await this.fetchServer("/models/sse", { headers, signal });
if (!response.ok || !response.body) throw new Error(`llama.cpp SSE returned HTTP ${response.status}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();

View file

@ -59,6 +59,19 @@ describe("llama.cpp extension", () => {
expect(() => normalizeLlamaServerUrl("file:///tmp/llama")).toThrow("http or https");
});
// #11745: a refused connection showed only "fetch failed"
it("names the server and the cause when it cannot be reached", async () => {
const server = createServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
const port = (server.address() as AddressInfo).port;
await new Promise<void>((resolve) => server.close(() => resolve()));
await expect(new LlamaClient(`http://127.0.0.1:${port}`).list()).rejects.toThrow(
`Could not reach llama.cpp server at http://127.0.0.1:${port}: connect ECONNREFUSED 127.0.0.1:${port}`,
);
});
it("exposes loaded and sleeping models with router metadata", () => {
const controller = createLlamaProvider();
controller.setCatalog(