Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
97 changes: 97 additions & 0 deletions src/__tests__/ollama.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, afterEach } from "vitest";
import * as http from "node:http";
import * as net from "node:net";
import type { Fixture, HandlerDefaults } from "../types.js";
import { createServer, type ServerInstance } from "../server.js";
import { ollamaToCompletionRequest, handleOllama, handleOllamaGenerate } from "../ollama.js";
Expand Down Expand Up @@ -106,6 +107,66 @@ function postRaw(url: string, raw: string): Promise<{ status: number; body: stri
});
}

// Sends a raw request with `Upgrade: h2c` + `Connection: Upgrade` headers, as
// OkHttp (and thus langchain4j) speculatively does even for plain requests.
// http.request() can't produce this from the client side, so we use a raw
// socket and parse the response ourselves.
function requestWithH2cUpgradeHeaders(
url: string,
method: string,
body?: string,
): Promise<{ status: number; body: string }> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const port = parsed.port ? Number(parsed.port) : 80;
const socket = net.connect(port, parsed.hostname, () => {
const bodyHeaders = body
? `Content-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\n`
: "";
socket.write(
`${method} ${parsed.pathname} HTTP/1.1\r\n` +
`Host: ${parsed.host}\r\n` +
bodyHeaders +
`Upgrade: h2c\r\n` +
`Connection: Upgrade\r\n` +
`\r\n` +
(body ?? ""),
);
});
let data = "";
// The server keeps the connection alive (we don't send a real
// `Connection: close`), so completion must be detected from the response
// framing itself rather than waiting for the socket to close.
socket.on("data", (chunk: Buffer) => {
data += chunk.toString();
const headerEnd = data.indexOf("\r\n\r\n");
if (headerEnd === -1) return;
const head = data.slice(0, headerEnd);
const rest = data.slice(headerEnd + 4);
const isChunked = /transfer-encoding:\s*chunked/i.test(head);
const contentLengthMatch = head.match(/content-length:\s*(\d+)/i);
const done = isChunked
? rest.endsWith("0\r\n\r\n")
: contentLengthMatch
? Buffer.byteLength(rest) >= Number(contentLengthMatch[1])
: false;
if (!done) return;

const statusLine = head.split("\r\n")[0] ?? "";
const status = Number(statusLine.split(" ")[1] ?? 0);
const responseBody = isChunked
? rest
.split("\r\n")
.filter((line) => !/^[0-9a-f]+$/i.test(line.trim()))
.join("")
: rest.slice(0, Number(contentLengthMatch![1]));
socket.destroy();
resolve({ status, body: responseBody });
});
socket.on("error", reject);
});
}

function parseNDJSON(body: string): object[] {
return body
.split("\n")
Expand Down Expand Up @@ -882,6 +943,42 @@ describe("GET /api/tags", () => {
const names = body.models.map((m: { name: string }) => m.name);
expect(names).toContain("gpt-4");
});

// Regression test: langchain4j's OkHttp-based client probes for HTTP/2
// cleartext support by sending `Upgrade: h2c` + `Connection: Upgrade` on
// its plain GET /api/tags request. Node's http module treats any
// `Connection: Upgrade` header as a protocol-upgrade request, so this must
// not be swallowed by the WebSocket-upgrade path and 404.
it("responds normally when the request carries h2c upgrade probe headers", async () => {
instance = await createServer(allFixtures);
const res = await requestWithH2cUpgradeHeaders(`${instance.url}/api/tags`, "GET");

expect(res.status).toBe(200);
const body = JSON.parse(res.body);
const names = body.models.map((m: { name: string }) => m.name);
expect(names).toContain("llama3");
});
});

describe("POST /api/chat (h2c upgrade probe)", () => {
// Regression test: the same h2c probe headers on a POST with a body used to
// reach the request handler with an empty body (Node detaches its parser
// from `req` once "upgrade" fires, so bytes buffered in `head` never became
// part of `req`'s stream), producing a "Malformed JSON body" error instead
// of the real validation error for the request that was actually sent.
it("still parses the JSON body and returns the real validation error", async () => {
instance = await createServer(allFixtures);
const res = await requestWithH2cUpgradeHeaders(
`${instance.url}/api/chat`,
"POST",
JSON.stringify({ model: "llama3.2" }),
);

expect(res.status).toBe(400);
const body = JSON.parse(res.body);
expect(body.error.message).toMatch(/messages/i);
expect(body.error.message).not.toMatch(/Malformed JSON/i);
});
});

// ─── Integration tests: journal ─────────────────────────────────────────────
Expand Down
26 changes: 26 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3145,6 +3145,32 @@ export async function createServerWithResolvedAuth(
socket: import("node:net").Socket,
head: Buffer,
): Promise<void> {
// `Upgrade: h2c` + `Connection: Upgrade` on plain requests — including
// ones with a body — to probe for HTTP/2 cleartext support. Only actual
// WebSocket upgrades belong on this path.
//
// Node detaches its HTTP parser from the socket the moment "upgrade"
// fires, so `req` never receives a body: any bytes already read land in
// `head` instead, and reconnecting them to `req` (e.g. via `req.push`)
// would still leave chunked bodies and further framing unhandled. Rather
// than reimplementing body parsing, rebuild the request line and headers
// with `Upgrade`/`Connection` dropped — the only thing that made this
// look like an upgrade — replay them plus `head` onto the socket, and
// let Node re-parse the connection from scratch as an ordinary request.
if ((req.headers.upgrade ?? "").toLowerCase() !== "websocket") {
const requestLine = `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`;
const headerLines: string[] = [];
for (let i = 0; i < req.rawHeaders.length; i += 2) {
if (/^(?:upgrade|connection)$/i.test(req.rawHeaders[i])) continue;
headerLines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`);
}
const rebuilt = Buffer.from(requestLine + headerLines.join("\r\n") + "\r\n\r\n");
socket.unshift(head);
socket.unshift(rebuilt);
server.emit("connection", socket);
return;
}

const parsedUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
let pathname = parsedUrl.pathname;

Expand Down