diff --git a/src/__tests__/ollama.test.ts b/src/__tests__/ollama.test.ts index e409825e..48ef877f 100644 --- a/src/__tests__/ollama.test.ts +++ b/src/__tests__/ollama.test.ts @@ -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"; @@ -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") @@ -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 ───────────────────────────────────────────── diff --git a/src/server.ts b/src/server.ts index 7a77f46f..97296f0d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3145,6 +3145,32 @@ export async function createServerWithResolvedAuth( socket: import("node:net").Socket, head: Buffer, ): Promise { + // `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;