A model with replicas on a card and on CPUs answered at either speed with no way to tell which. The picker names where each model runs, each reply names the hardware that served it, and every answer carries x-llmgw-hardware. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
204 lines
7.0 KiB
Go
204 lines
7.0 KiB
Go
package web
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
func chatPage(t *testing.T, names ...string) string {
|
|
t.Helper()
|
|
models := make([]ChatModel, 0, len(names))
|
|
for _, n := range names {
|
|
models = append(models, ChatModel{Name: n})
|
|
}
|
|
return chatPageWith(t, models)
|
|
}
|
|
|
|
func chatPageWith(t *testing.T, models []ChatModel) string {
|
|
t.Helper()
|
|
h := Chat(store.User{Email: "a@example.com"}, models, nil, "", "v1")
|
|
r := httptest.NewRequest(http.MethodGet, "http://x/chat", nil)
|
|
w := httptest.NewRecorder()
|
|
h(w, r)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status %d", w.Code)
|
|
}
|
|
return w.Body.String()
|
|
}
|
|
|
|
func TestMathIsRenderedWithoutTrustingTheModel(t *testing.T) {
|
|
// The ordering in the page is the safety argument, so it is worth asserting
|
|
// rather than remembering: the reply is put in place with textContent and
|
|
// only then walked for maths. KaTeX replaces the spans it recognises, so
|
|
// nothing the model wrote is ever parsed as HTML.
|
|
page := chatPage(t, "m")
|
|
|
|
if !strings.Contains(page, "/_static/katex/katex.min.css") ||
|
|
!strings.Contains(page, "/_static/katex/auto-render.min.js") {
|
|
t.Fatal("the page does not load KaTeX")
|
|
}
|
|
// Vendored, not fetched: this gateway makes no outbound requests and is
|
|
// expected to run where it could not.
|
|
for _, cdn := range []string{"cdn.jsdelivr", "unpkg.com", "cdnjs.", "//katex.org"} {
|
|
if strings.Contains(page, cdn) {
|
|
t.Errorf("the page loads KaTeX from %s instead of the binary", cdn)
|
|
}
|
|
}
|
|
|
|
if !strings.Contains(page, "trust: false") {
|
|
t.Error("trust is not pinned off; \\href in model output would become a link")
|
|
}
|
|
if !strings.Contains(page, "throwOnError: false") {
|
|
t.Error("a single malformed expression would take the whole reply with it")
|
|
}
|
|
|
|
// The reply still reaches the page as text first.
|
|
if !strings.Contains(page, "out.textContent = reply") {
|
|
t.Error("the reply is no longer written with textContent")
|
|
}
|
|
// Only the server's rendering of a finished reply is placed as HTML; see
|
|
// TestTheTruncationNoticeIsNotPutInsideTheReply.
|
|
if strings.Count(page, "innerHTML") != 1 {
|
|
t.Error("the chat page assigns innerHTML beyond the rendered reply")
|
|
}
|
|
}
|
|
|
|
func TestMathIsRenderedOnceTheReplyIsComplete(t *testing.T) {
|
|
// Typesettingevery chunk would try to render half an expression on every token
|
|
// and flicker through the whole reply.
|
|
page := chatPage(t, "m")
|
|
body := page[strings.Index(page, "form.onsubmit"):]
|
|
|
|
renderAt := strings.Index(body, "renderMath(out)")
|
|
streamAt := strings.Index(body, "out.textContent = reply")
|
|
if renderAt < 0 || streamAt < 0 {
|
|
t.Fatal("the streaming loop or the render call is missing")
|
|
}
|
|
if renderAt < streamAt {
|
|
t.Error("maths is rendered before the text that carries it")
|
|
}
|
|
}
|
|
|
|
func TestDisplayDelimitersComeBeforeInlineOnes(t *testing.T) {
|
|
// $$…$$ has to be tried before $…$, or the opening $$ is read as an empty
|
|
// inline expression and everything after it is wrong.
|
|
page := chatPage(t, "m")
|
|
dd := strings.Index(page, `left: "$$"`)
|
|
d := strings.Index(page, `left: "$"`)
|
|
if dd < 0 || d < 0 {
|
|
t.Fatal("the delimiters are missing")
|
|
}
|
|
if dd > d {
|
|
t.Error(`$ is tried before $$`)
|
|
}
|
|
}
|
|
|
|
func TestTheChatPageOffersWhatItWasGiven(t *testing.T) {
|
|
page := chatPage(t, "alpha", "beta")
|
|
for _, m := range []string{"alpha", "beta"} {
|
|
if !strings.Contains(page, `value="`+m+`"`) {
|
|
t.Errorf("the picker does not offer %s", m)
|
|
}
|
|
}
|
|
empty := chatPageWith(t, nil)
|
|
if strings.Contains(empty, "<form id=\"composer\"") {
|
|
t.Error("a composer was rendered with no model to send to")
|
|
}
|
|
if !strings.Contains(empty, "No models are available") {
|
|
t.Error("the page does not explain why it is empty")
|
|
}
|
|
}
|
|
|
|
func TestTheSimulatedModelSaysSo(t *testing.T) {
|
|
// big-model returns Greek letters at a fixed rate. Offered next to a real
|
|
// model with nothing to tell them apart, it reads as a broken model rather
|
|
// than a load generator, which is how it was first reported.
|
|
page := chatPageWith(t, []ChatModel{
|
|
{Name: "qwen"},
|
|
{Name: "big-model", Simulated: true},
|
|
})
|
|
if !strings.Contains(page, `>big-model (simulated)<`) {
|
|
t.Error("the simulated model is offered without saying it is simulated")
|
|
}
|
|
if strings.Contains(page, `>qwen (simulated)<`) {
|
|
t.Error("a real model was labelled simulated")
|
|
}
|
|
if !strings.Contains(page, `value="big-model" data-simulated="1"`) ||
|
|
!strings.Contains(page, `value="qwen" data-simulated="0"`) {
|
|
t.Error("the script cannot tell the two apart")
|
|
}
|
|
if !strings.Contains(page, "placeholder text, not inference") {
|
|
t.Error("the page never explains what a simulated model returns")
|
|
}
|
|
}
|
|
|
|
func TestTheFirstOfferedModelAnswers(t *testing.T) {
|
|
// A browser selects the first option, so whatever leads the list is what
|
|
// someone gets when they type and press Send without looking at the picker.
|
|
page := chatPageWith(t, []ChatModel{
|
|
{Name: "qwen"},
|
|
{Name: "big-model", Simulated: true},
|
|
})
|
|
real, sim := strings.Index(page, `value="qwen"`), strings.Index(page, `value="big-model"`)
|
|
if real < 0 || sim < 0 {
|
|
t.Fatal("both models should be offered")
|
|
}
|
|
if real > sim {
|
|
t.Error("a simulated model is preselected; the default reply would be placeholder text")
|
|
}
|
|
}
|
|
|
|
func TestATruncatedReplySaysSo(t *testing.T) {
|
|
// A reply that hit the token cap looks exactly like one that finished: the
|
|
// stream ends and the text stops. Reported as the model not completing the
|
|
// request, which is a fair reading when nothing says otherwise.
|
|
page := chatPage(t, "m")
|
|
if !strings.Contains(page, "finish_reason") {
|
|
t.Error("the page never reads why the model stopped")
|
|
}
|
|
if !strings.Contains(page, `finish === "length"`) {
|
|
t.Error("the page does not distinguish running out of room from finishing")
|
|
}
|
|
if !strings.Contains(page, "Cut off at the length limit") {
|
|
t.Error("nothing tells the reader the answer was cut short")
|
|
}
|
|
}
|
|
|
|
func TestTheTruncationNoticeIsNotPutInsideTheReply(t *testing.T) {
|
|
// out holds model output placed with textContent. The notice is ours, so
|
|
// it goes beside that element — putting it inside would mean building
|
|
// markup in the same node the model writes into.
|
|
page := chatPage(t, "m")
|
|
if !strings.Contains(page, "out.after(note)") {
|
|
t.Error("the notice is not appended as a sibling of the reply")
|
|
}
|
|
// The one exception is the finished reply as /chat/render returns it —
|
|
// markdown the server has already made safe — and nothing else may be
|
|
// written into the reply as HTML.
|
|
if n := strings.Count(page, "innerHTML ="); n != 1 ||
|
|
!strings.Contains(page, "out.innerHTML = await resp.text();") ||
|
|
!strings.Contains(page, `fetch("/chat/render"`) {
|
|
t.Errorf("the reply is written as HTML other than from /chat/render (%d assignments)", n)
|
|
}
|
|
}
|
|
|
|
func TestChatModelRunsNamesTheHardware(t *testing.T) {
|
|
for _, c := range []struct {
|
|
hw []string
|
|
want string
|
|
}{
|
|
{nil, ""},
|
|
{[]string{"cpu"}, "CPU"},
|
|
{[]string{"6000-24g"}, "GPU"},
|
|
{[]string{"cpu", "6000-24g"}, "GPU + CPU"},
|
|
} {
|
|
if got := (ChatModel{Hardware: c.hw}).Runs(); got != c.want {
|
|
t.Errorf("%v: %q, want %q", c.hw, got, c.want)
|
|
}
|
|
}
|
|
}
|