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>
118 lines
3.2 KiB
Go
118 lines
3.2 KiB
Go
package web
|
|
|
|
import (
|
|
"net/http"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
// ChatModel is one entry in the chat page's model picker.
|
|
type ChatModel struct {
|
|
Name string
|
|
// Simulated marks a model that returns synthetic text rather than
|
|
// inference output. The picker says so, because the two are
|
|
// indistinguishable from the outside until the reply arrives.
|
|
Simulated bool
|
|
// OnDemand marks a model with nothing running that loads when asked. It is
|
|
// offered anyway: the first message is what asks, so hiding it until it
|
|
// runs meant it could never be started from here.
|
|
OnDemand bool
|
|
// Hardware is what the model's running replicas compute on: "cpu", or a
|
|
// GPU class. Several when it runs on both, and empty when nothing runs
|
|
// or nothing said.
|
|
Hardware []string
|
|
}
|
|
|
|
// Runs names the hardware for the picker: "GPU", "CPU", or "GPU + CPU" for a
|
|
// model with replicas on both, where which one answers is the router's choice
|
|
// and each reply says which it was.
|
|
func (m ChatModel) Runs() string {
|
|
var gpu, cpu bool
|
|
for _, h := range m.Hardware {
|
|
if h == "cpu" {
|
|
cpu = true
|
|
} else {
|
|
gpu = true
|
|
}
|
|
}
|
|
switch {
|
|
case gpu && cpu:
|
|
return "GPU + CPU"
|
|
case gpu:
|
|
return "GPU"
|
|
case cpu:
|
|
return "CPU"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ChatContext is one thing the chat page can spend against: a project, or the
|
|
// person themselves.
|
|
type ChatContext struct {
|
|
Value string
|
|
Label string
|
|
}
|
|
|
|
type chatData struct {
|
|
Nav Nav
|
|
Contexts []ChatContext
|
|
Context string
|
|
User store.User
|
|
Models []ChatModel
|
|
CSRF string
|
|
Version string
|
|
Now string
|
|
}
|
|
|
|
// Chat renders the in-browser chat for one signed-in person.
|
|
func Chat(user store.User, models []ChatModel, contexts []ChatContext, current, version string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
noStore(w)
|
|
render(w, "chat", chatData{
|
|
Nav: navFrom(r, "chat"),
|
|
Contexts: contexts, Context: current,
|
|
User: user, Models: models, CSRF: csrfFor(r),
|
|
Version: version, Now: time.Now().UTC().Format(time.RFC3339),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Guard runs a handler only for a signed-in, approved account.
|
|
//
|
|
// Exported because the chat lives in the router — it needs the endpoint
|
|
// registry and the request path — while the session machinery lives here. The
|
|
// alternative was a second copy of the session check next to the one thing that
|
|
// must never have two copies.
|
|
//
|
|
// Its POSTs carry the CSRF token in a header rather than a form field, because
|
|
// they are JSON. A header is a stronger check than a field: a cross-site form
|
|
// post cannot set one at all.
|
|
func (p *AccountPages) Guard(
|
|
next func(w http.ResponseWriter, r *http.Request, user store.User),
|
|
) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
noStore(w)
|
|
sess, user, ok := p.session(r)
|
|
if !ok {
|
|
if r.Method == http.MethodGet {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
} else {
|
|
http.Error(w, "not signed in", http.StatusUnauthorized)
|
|
}
|
|
return
|
|
}
|
|
if r.Method != http.MethodGet {
|
|
token := r.Header.Get("X-CSRF-Token")
|
|
if token == "" {
|
|
token = r.FormValue("csrf")
|
|
}
|
|
if !p.accounts.CheckCSRF(sess, token) {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
next(w, p.withNav(r, user), user)
|
|
}
|
|
}
|