HANKAN_TUTOR_BACKEND=openai talks to anything serving /chat/completions -- LM Studio, Ollama, llama.cpp, vLLM, LiteLLM, OpenRouter, OpenAI. The TutorBackend seam already existed for this, so the model becomes a config line rather than a code change. Written against fetch rather than the openai package. The Anthropic SDK alone is 14MB in the image, this backend uses one endpoint with no tools and no retries, and local servers are the ones most likely to deviate from an SDK's expectations. The real risk in hand-rolling it is SSE reassembly, so that is where the tests are: a JSON payload split across two TCP reads, an event whose blank-line terminator lands in the next read, heartbeat comments, CRLF framing, and a stream that ends without [DONE]. The two split cases both fail against a naive per-read parser, which is what makes them worth having. <think> blocks are stripped from the stream, tags split across chunks included. Reasoning models served locally often emit chain-of-thought inline in `content` rather than in a separate field, and left in it lands in the lesson transcript where the block parser reads it as prose. WHAT THIS COSTS: prompt caching. The Anthropic backend marks the ~12k character gate as a cached prefix, so every turn after the first reads it at a fraction of the input price. There is no portable equivalent, so against a paid hosted endpoint the system prompt is re-billed every turn -- the biggest cost lever in the design, gone. Against a local model it costs nothing, and the shape still pays: llama.cpp and LM Studio reuse their KV cache for an unchanged prefix. Measured on a 6,948-character prompt against gpt-oss-20b, first token 1,563ms cold and 324ms warm, so the system prompt goes first and stays put here too. Verified against LM Studio running openai/gpt-oss-20b, not only a fake: a turn streams from the browser through this server to the model and back, rendered in the chat, no page errors. Also makes test/server/http.test.ts backend-agnostic. It asserted the echo backend's wording and so failed the moment the server was pointed at a real model -- precisely the case a transport test should survive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
106 lines
4.5 KiB
TypeScript
106 lines
4.5 KiB
TypeScript
/* The server as a browser actually meets it: over HTTP, cross-origin.
|
|
|
|
These need a running server (HANKAN_TEST_SERVER) and are skipped
|
|
otherwise. The tutor half needs HANKAN_TUTOR_BACKEND=echo, which is
|
|
keyless — the point is the transport, not the model.
|
|
|
|
The bug this file exists for: CORS was absent and the auth middleware ran
|
|
first, so a preflight OPTIONS — which a browser is not allowed to send
|
|
credentials on — came back 401 and every cross-origin request failed
|
|
before reaching any handler. The Android build always talks to the Pi
|
|
cross-origin, so that was launch-blocking for the phone, and it presents
|
|
as an opaque "Failed to fetch" rather than as an auth error. */
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
|
|
const BASE = process.env.HANKAN_TEST_SERVER;
|
|
const TOKEN = process.env.HANKAN_TEST_TOKEN ?? "test-token";
|
|
const run = BASE ? describe : describe.skip;
|
|
|
|
/** What a Capacitor webview sends as its Origin. */
|
|
const PHONE_ORIGIN = "http://localhost";
|
|
|
|
run("CORS", () => {
|
|
it("answers the preflight instead of rejecting it as unauthorised", async () => {
|
|
const res = await fetch(`${BASE}/api/sync`, {
|
|
method: "OPTIONS",
|
|
headers: {
|
|
origin: PHONE_ORIGIN,
|
|
"access-control-request-method": "POST",
|
|
"access-control-request-headers": "authorization,content-type",
|
|
},
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).toBeLessThan(300);
|
|
expect(res.headers.get("access-control-allow-origin")).toBe(PHONE_ORIGIN);
|
|
expect(res.headers.get("access-control-allow-headers")).toMatch(/authorization/i);
|
|
});
|
|
|
|
it("allows the Authorization header, without which the token cannot be sent", async () => {
|
|
const res = await fetch(`${BASE}/api/tutor`, {
|
|
method: "OPTIONS",
|
|
headers: { origin: PHONE_ORIGIN, "access-control-request-method": "POST" },
|
|
});
|
|
expect(res.headers.get("access-control-allow-headers")).toMatch(/authorization/i);
|
|
});
|
|
|
|
it("puts the allow-origin header on the real response too", async () => {
|
|
const res = await fetch(`${BASE}/api/sync?cursor=0`, {
|
|
headers: { origin: PHONE_ORIGIN, authorization: `Bearer ${TOKEN}` },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("access-control-allow-origin")).toBe(PHONE_ORIGIN);
|
|
// Caches must not serve one origin's response to another.
|
|
expect(res.headers.get("vary")).toMatch(/origin/i);
|
|
});
|
|
|
|
it("still refuses a bad token", async () => {
|
|
const res = await fetch(`${BASE}/api/sync?cursor=0`, {
|
|
headers: { origin: PHONE_ORIGIN, authorization: "Bearer wrong" },
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
run("tutor over HTTP", () => {
|
|
it("streams SSE events that parse, and ends with done", async () => {
|
|
const res = await fetch(`${BASE}/api/tutor`, {
|
|
method: "POST",
|
|
headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" },
|
|
body: JSON.stringify({ system: "system prompt here", history: [], message: "hello" }),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get("content-type")).toMatch(/text\/event-stream/);
|
|
// Compression is the usual reason SSE appears to hang behind a proxy.
|
|
expect(res.headers.get("cache-control")).toMatch(/no-transform/);
|
|
|
|
const body = await res.text();
|
|
const events = body
|
|
.split("\n\n")
|
|
.map((b) => b.split("\n").find((l) => l.startsWith("data:")))
|
|
.filter((l): l is string => Boolean(l))
|
|
.map((l) => JSON.parse(l.slice(5).trim()) as { type: string; text?: string });
|
|
|
|
expect(events.length).toBeGreaterThan(1);
|
|
expect(events.some((e) => e.type === "delta")).toBe(true);
|
|
expect(events[events.length - 1]!.type).toBe("done");
|
|
|
|
/* Deliberately not asserting the content. This file tests the
|
|
transport, and the server may be configured with any backend —
|
|
anthropic, openai or echo. Asserting the echo backend's wording made
|
|
the test fail the moment the server was pointed at a real model,
|
|
which is exactly the case it should have kept working through. */
|
|
const text = events.filter((e) => e.type === "delta").map((e) => e.text).join("");
|
|
expect(text.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("rejects a request with no system prompt", async () => {
|
|
const res = await fetch(`${BASE}/api/tutor`, {
|
|
method: "POST",
|
|
headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" },
|
|
body: JSON.stringify({ message: "hello" }),
|
|
});
|
|
expect(res.status).toBe(400);
|
|
});
|
|
});
|