CI / test (push) Successful in 10m43s
A 1280x720 PNG sent to qwen3-vl-8b was estimated at 138 thousand prompt tokens and refused against its 8k context before reaching the engine, which makes about a thousand of it. Parts are counted by kind: text by its text, a picture or a sound as a thousand tokens. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
461 lines
15 KiB
Go
461 lines
15 KiB
Go
package router
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
|
|
storepkg "gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
// Answering from knowledge bases (BACKLOG F3): a chat request that names
|
|
// collections in X-Llmgw-Knowledge has the passages its last question needs
|
|
// found in them and put in front of the model, with their sources, before it
|
|
// is judged and sent. Which collections it may name is decided by the key's
|
|
// place in the tree, exactly as llmgw-kb's own requests are; one it may not
|
|
// read is answered as one that does not exist.
|
|
|
|
const (
|
|
// knowledgeHeader names the collections, by name or id, comma separated.
|
|
knowledgeHeader = "X-Llmgw-Knowledge"
|
|
// knowledgeKHeader is how many passages; kbDefaultK when absent.
|
|
knowledgeKHeader = "X-Llmgw-Knowledge-K"
|
|
// sourcesHeader answers which passages were given: document#chunk, each
|
|
// path-escaped, in the order they were numbered.
|
|
sourcesHeader = "x-llmgw-sources"
|
|
|
|
kbDefaultK = 5
|
|
kbMaxK = 20
|
|
)
|
|
|
|
// kbContextIntro is what the model is told about the passages. In English,
|
|
// which every model reads; it is asked to answer in the question's language.
|
|
const kbContextIntro = "Below are passages from the organisation's own documents, found for the user's last " +
|
|
"question. Answer from them, and cite the ones you use as [1], [2] and so on. If they do not hold the " +
|
|
"answer, say so rather than guessing. Answer in the language of the question."
|
|
|
|
const kbNothingFound = "The user asked a question meant to be answered from the organisation's documents, " +
|
|
"and nothing in them matched it. Say so, rather than answering from general knowledge as if it were " +
|
|
"the organisation's."
|
|
|
|
// kbEffective is a caller's place in the tree for reading collections: a
|
|
// personal key has one only in a project it names and belongs to.
|
|
func (r *Router) kbEffective(ctx context.Context, p auth.Principal, project string) (storepkg.Effective, error) {
|
|
e := storepkg.Effective{KeyID: p.KeyID, UserID: p.UserID, ProjectID: p.ProjectID, TeamID: p.TeamID, OrgID: p.OrgID}
|
|
project = strings.TrimSpace(project)
|
|
if project == "" || p.ProjectID != "" || p.UserID == "" {
|
|
return e, nil
|
|
}
|
|
may, err := r.store.CanUseProject(ctx, p.UserID, project)
|
|
if err != nil {
|
|
return e, err
|
|
}
|
|
if !may {
|
|
return e, &kbError{status: http.StatusForbidden, kind: "permission_error", msg: "you are not a member of project " + project}
|
|
}
|
|
eff, err := r.store.ResolveFor(ctx, p.KeyID, p.UserID, project)
|
|
if err != nil {
|
|
return e, err
|
|
}
|
|
e.ProjectID, e.TeamID, e.OrgID = eff.ProjectID, eff.TeamID, eff.OrgID
|
|
return e, nil
|
|
}
|
|
|
|
// kbError is a refusal with the status and kind a client is answered with.
|
|
type kbError struct {
|
|
status int
|
|
kind, msg string
|
|
retryAfter string
|
|
}
|
|
|
|
func (e *kbError) Error() string { return e.msg }
|
|
|
|
// retrieve finds and adds the passages a request asked for. It answers false
|
|
// when it has answered the client itself.
|
|
func (r *Router) retrieve(ctx context.Context, w http.ResponseWriter, req *parsedRequest, start time.Time) bool {
|
|
names := splitList(req.Header.Get(knowledgeHeader))
|
|
if len(names) == 1 && strings.EqualFold(names[0], "none") {
|
|
// Asked for no passages, whatever the default.
|
|
return true
|
|
}
|
|
// Without the header, the default of the caller's part of the tree, if
|
|
// it has one. A default is the administrator's choice, not the
|
|
// caller's, so it never refuses a request: a collection the caller
|
|
// cannot read is left out, and a search that fails is answered without
|
|
// passages.
|
|
explicit := len(names) > 0
|
|
if !explicit {
|
|
if req.Path != "/v1/chat/completions" || req.internal || r.store == nil || r.store.KnowledgeAvailable() != nil {
|
|
return true
|
|
}
|
|
p, ok := auth.FromContext(ctx)
|
|
if !ok {
|
|
return true
|
|
}
|
|
if names = r.kbDefaults(ctx, p, req.Header.Get(projectHeader)); len(names) == 0 {
|
|
return true
|
|
}
|
|
}
|
|
fail := func(err error) bool {
|
|
if !explicit {
|
|
r.log.Warn("a knowledge-base default could not be searched; answering without it", "model", req.Model, "err", err)
|
|
r.m.kbRetrievals.WithLabelValues("skipped").Inc()
|
|
return true
|
|
}
|
|
var ke *kbError
|
|
if !errors.As(err, &ke) {
|
|
ke = &kbError{status: http.StatusInternalServerError, kind: "server_error", msg: err.Error()}
|
|
}
|
|
if ke.retryAfter != "" {
|
|
w.Header().Set("Retry-After", ke.retryAfter)
|
|
}
|
|
writeError(w, ke.status, ke.kind, ke.msg)
|
|
r.m.kbRetrievals.WithLabelValues("refused").Inc()
|
|
r.m.observeRequest(r.metricModel(req.Model), "", ke.status, time.Since(start), nil, 0)
|
|
return false
|
|
}
|
|
if req.Path != "/v1/chat/completions" {
|
|
return fail(&kbError{status: http.StatusBadRequest, kind: "invalid_request_error",
|
|
msg: knowledgeHeader + " is for chat completions"})
|
|
}
|
|
p, ok := auth.FromContext(ctx)
|
|
if !ok || r.store == nil {
|
|
return fail(&kbError{status: http.StatusNotImplemented, kind: "invalid_request_error",
|
|
msg: "knowledge bases need authentication and the store, which this gateway runs without"})
|
|
}
|
|
if err := r.store.KnowledgeAvailable(); err != nil {
|
|
return fail(&kbError{status: http.StatusNotImplemented, kind: "invalid_request_error", msg: err.Error()})
|
|
}
|
|
k := kbDefaultK
|
|
if v := req.Header.Get(knowledgeKHeader); v != "" {
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil || n < 1 || n > kbMaxK {
|
|
return fail(&kbError{status: http.StatusBadRequest, kind: "invalid_request_error",
|
|
msg: fmt.Sprintf("%s is 1 to %d", knowledgeKHeader, kbMaxK)})
|
|
}
|
|
k = n
|
|
}
|
|
began := time.Now()
|
|
e, err := r.kbEffective(ctx, p, req.Header.Get(projectHeader))
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
allowed, err := r.store.CollectionsFor(ctx, e, false)
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
var colls []storepkg.KBCollection
|
|
for _, n := range names {
|
|
c, ok := pickCollection(allowed, n)
|
|
if !ok && !explicit {
|
|
continue
|
|
}
|
|
if !ok {
|
|
msg := "no collection " + n
|
|
if e.OrgID == "" {
|
|
msg += " (a personal key names its project in " + projectHeader + ")"
|
|
}
|
|
return fail(&kbError{status: http.StatusNotFound, kind: "not_found_error", msg: msg})
|
|
}
|
|
colls = append(colls, c)
|
|
}
|
|
if len(colls) == 0 {
|
|
return true
|
|
}
|
|
|
|
question := lastUserText(req.UpstreamBody)
|
|
if strings.TrimSpace(question) == "" && !explicit {
|
|
return true
|
|
}
|
|
if strings.TrimSpace(question) == "" {
|
|
return fail(&kbError{status: http.StatusBadRequest, kind: "invalid_request_error",
|
|
msg: knowledgeHeader + " needs a last user message with text to search for"})
|
|
}
|
|
embeddings := map[string][]float32{}
|
|
for _, c := range colls {
|
|
if _, done := embeddings[c.EmbedModel]; done {
|
|
continue
|
|
}
|
|
v, err := r.embedFor(ctx, p, c.EmbedModel, question)
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
embeddings[c.EmbedModel] = v
|
|
}
|
|
hits, err := r.store.SearchChunks(ctx, storepkg.KBSearch{OrgID: e.OrgID, Collections: colls,
|
|
Embeddings: embeddings, Text: question, K: k})
|
|
if err != nil {
|
|
return fail(err)
|
|
}
|
|
|
|
// What fits: the context the model has, less what the request already
|
|
// holds and the answer it asked room for — the last passages go first.
|
|
room := -1
|
|
if _, entry := r.resolve(req.Model); entry.ContextLength > 0 {
|
|
room = (entry.ContextLength-req.MaxTokens)*4 - len(req.UpstreamBody) - len(kbContextIntro) - 64
|
|
}
|
|
var b strings.Builder
|
|
var sources []string
|
|
for i, h := range hits {
|
|
block := fmt.Sprintf("\n\n[%d] %s\n%s", i+1, h.Document, strings.TrimSpace(h.Body))
|
|
if room >= 0 && b.Len()+len(block) > room {
|
|
break
|
|
}
|
|
b.WriteString(block)
|
|
sources = append(sources, url.PathEscape(h.Document)+"#"+strconv.Itoa(h.Ord))
|
|
}
|
|
text := kbNothingFound
|
|
if len(sources) > 0 {
|
|
text = kbContextIntro + b.String()
|
|
}
|
|
body, err := withSystemText(req.UpstreamBody, text)
|
|
if err != nil {
|
|
return fail(&kbError{status: http.StatusBadRequest, kind: "invalid_request_error", msg: err.Error()})
|
|
}
|
|
req.UpstreamBody = body
|
|
req.PromptTokens = promptBytesOf(body) / 4
|
|
req.knowledge = true
|
|
// The collections searched, which is who else could have had these
|
|
// passages added: the prefix cache is shared among exactly them.
|
|
ids := make([]string, len(colls))
|
|
for i, c := range colls {
|
|
ids[i] = c.ID
|
|
}
|
|
sort.Strings(ids)
|
|
req.kbSalt = strings.Join(ids, ",")
|
|
// Set now: the proxy writes the headers when the answer starts.
|
|
w.Header().Set(sourcesHeader, strings.Join(sources, ","))
|
|
result := "found"
|
|
if len(sources) == 0 {
|
|
result = "nothing"
|
|
}
|
|
r.m.kbRetrievals.WithLabelValues(result).Inc()
|
|
r.m.kbRetrieval.Observe(time.Since(began).Seconds())
|
|
return true
|
|
}
|
|
|
|
// pickCollection finds a collection by id, then by name.
|
|
func pickCollection(cs []storepkg.KBCollection, want string) (storepkg.KBCollection, bool) {
|
|
for _, c := range cs {
|
|
if c.ID == want {
|
|
return c, true
|
|
}
|
|
}
|
|
for _, c := range cs {
|
|
if strings.EqualFold(c.Name, want) {
|
|
return c, true
|
|
}
|
|
}
|
|
return storepkg.KBCollection{}, false
|
|
}
|
|
|
|
func splitList(v string) []string {
|
|
var out []string
|
|
seen := map[string]bool{}
|
|
for _, s := range strings.Split(v, ",") {
|
|
if s = strings.TrimSpace(s); s != "" && !seen[s] {
|
|
seen[s] = true
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// embedFor has a model embed the question as the caller: through the
|
|
// ordinary path, so the caller's limits, budget and guardrails apply to it
|
|
// and it is counted to them.
|
|
func (r *Router) embedFor(ctx context.Context, p auth.Principal, model, text string) ([]float32, error) {
|
|
// A question, not a document: what the model wants before a query.
|
|
if _, m := r.resolve(model); m.QueryPrefix != "" {
|
|
text = m.QueryPrefix + text
|
|
}
|
|
body, _ := json.Marshal(map[string]any{"model": model, "input": []string{text}})
|
|
hreq, err := http.NewRequestWithContext(auth.WithPrincipal(ctx, p), http.MethodPost, "/v1/embeddings", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
hreq.Header.Set("Content-Type", "application/json")
|
|
w := &bufferWriter{header: http.Header{}}
|
|
r.handleInference("/v1/embeddings")(w, hreq)
|
|
status := w.status
|
|
if status == 0 {
|
|
status = http.StatusOK
|
|
}
|
|
if status != http.StatusOK {
|
|
msg := errorMessageOf(w.buf.Bytes())
|
|
ke := &kbError{status: status, kind: "server_error",
|
|
msg: fmt.Sprintf("the knowledge base's model %s could not embed the question: %s", model, msg),
|
|
retryAfter: w.header.Get("Retry-After")}
|
|
if status < 500 {
|
|
ke.kind = "invalid_request_error"
|
|
}
|
|
return nil, ke
|
|
}
|
|
var res struct {
|
|
Data []struct {
|
|
Embedding []float32 `json:"embedding"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(w.buf.Bytes(), &res); err != nil || len(res.Data) != 1 {
|
|
return nil, fmt.Errorf("%s answered no embedding", model)
|
|
}
|
|
return res.Data[0].Embedding, nil
|
|
}
|
|
|
|
func errorMessageOf(b []byte) string {
|
|
var e struct {
|
|
Error struct {
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
if json.Unmarshal(b, &e) == nil && e.Error.Message != "" {
|
|
return e.Error.Message
|
|
}
|
|
return strings.TrimSpace(string(b))
|
|
}
|
|
|
|
// lastUserText is the text of a chat request's last user message: its
|
|
// content, or the text parts of it.
|
|
func lastUserText(body []byte) string {
|
|
var in struct {
|
|
Messages []struct {
|
|
Role string `json:"role"`
|
|
Content json.RawMessage `json:"content"`
|
|
} `json:"messages"`
|
|
}
|
|
if json.Unmarshal(body, &in) != nil {
|
|
return ""
|
|
}
|
|
for i := len(in.Messages) - 1; i >= 0; i-- {
|
|
m := in.Messages[i]
|
|
if m.Role != "user" {
|
|
continue
|
|
}
|
|
var s string
|
|
if json.Unmarshal(m.Content, &s) == nil {
|
|
return s
|
|
}
|
|
var parts []struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
}
|
|
if json.Unmarshal(m.Content, &parts) == nil {
|
|
var b strings.Builder
|
|
for _, p := range parts {
|
|
if p.Type == "text" {
|
|
b.WriteString(p.Text + "\n")
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
return ""
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// withSystemText adds text to a chat request's system message: appended to
|
|
// the first one when it leads, or as a new one at the start. One system
|
|
// message, first, because some models' templates refuse anything else.
|
|
func withSystemText(body []byte, text string) ([]byte, error) {
|
|
var top map[string]json.RawMessage
|
|
if err := json.Unmarshal(body, &top); err != nil {
|
|
return nil, fmt.Errorf("the body is not a JSON object: %w", err)
|
|
}
|
|
var msgs []map[string]json.RawMessage
|
|
if err := json.Unmarshal(top["messages"], &msgs); err != nil {
|
|
return nil, errors.New("the request has no messages")
|
|
}
|
|
added := false
|
|
if len(msgs) > 0 {
|
|
var role string
|
|
_ = json.Unmarshal(msgs[0]["role"], &role)
|
|
if role == "system" || role == "developer" {
|
|
var s string
|
|
if json.Unmarshal(msgs[0]["content"], &s) == nil {
|
|
msgs[0]["content"], _ = json.Marshal(strings.TrimRight(s, "\n") + "\n\n" + text)
|
|
added = true
|
|
} else {
|
|
var parts []json.RawMessage
|
|
if json.Unmarshal(msgs[0]["content"], &parts) == nil {
|
|
part, _ := json.Marshal(map[string]string{"type": "text", "text": "\n\n" + text})
|
|
msgs[0]["content"], _ = json.Marshal(append(parts, part))
|
|
added = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !added {
|
|
content, _ := json.Marshal(text)
|
|
role, _ := json.Marshal("system")
|
|
msgs = append([]map[string]json.RawMessage{{"role": role, "content": content}}, msgs...)
|
|
}
|
|
var err error
|
|
if top["messages"], err = json.Marshal(msgs); err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(top)
|
|
}
|
|
|
|
// kbDefaultTTL is how long a caller's default is remembered: a change to one
|
|
// is in force within it, and a chat request does not cost a query.
|
|
const kbDefaultTTL = 30 * time.Second
|
|
|
|
type kbDefaultEntry struct {
|
|
at time.Time
|
|
names []string
|
|
}
|
|
|
|
// kbDefaults is the default collections in force for a caller.
|
|
func (r *Router) kbDefaults(ctx context.Context, p auth.Principal, project string) []string {
|
|
key := p.KeyID + "|" + p.ProjectID + "|" + project
|
|
r.kbDefMu.Lock()
|
|
e, ok := r.kbDefCache[key]
|
|
r.kbDefMu.Unlock()
|
|
if ok && time.Since(e.at) < kbDefaultTTL {
|
|
return e.names
|
|
}
|
|
var names []string
|
|
if eff, err := r.kbEffective(ctx, p, project); err == nil {
|
|
names, err = r.store.KBDefaultFor(ctx, eff)
|
|
if err != nil {
|
|
r.log.Warn("could not read the knowledge-base defaults", "err", err)
|
|
}
|
|
}
|
|
r.kbDefMu.Lock()
|
|
if r.kbDefCache == nil || len(r.kbDefCache) > 10000 {
|
|
r.kbDefCache = map[string]kbDefaultEntry{}
|
|
}
|
|
r.kbDefCache[key] = kbDefaultEntry{at: time.Now(), names: names}
|
|
r.kbDefMu.Unlock()
|
|
return names
|
|
}
|
|
|
|
// withCacheSalt sets vLLM's cache_salt to one derived from key — the
|
|
// collections a request's passages came from — so requests that can see the
|
|
// same passages still share a cache, and nobody else's do: not another
|
|
// organization's, nor another team's of the same one.
|
|
func withCacheSalt(body []byte, key string) []byte {
|
|
var top map[string]json.RawMessage
|
|
if json.Unmarshal(body, &top) != nil {
|
|
return body
|
|
}
|
|
sum := sha256.Sum256([]byte("llmgw-kb\x00" + key))
|
|
top["cache_salt"], _ = json.Marshal(hex.EncodeToString(sum[:16]))
|
|
out, err := json.Marshal(top)
|
|
if err != nil {
|
|
return body
|
|
}
|
|
return out
|
|
}
|