Plan §7.5 tier 2, opt-in per profile with kv_events. The agent starts vLLM publishing KV cache events to a loopback port it listens on (pure-Go zmq4, msgpack in both of vLLM's encodings), with sha256_cbor hashing and a fixed PYTHONHASHSEED; it mirrors each engine's blocks and sends net changes up the existing stream, with a full copy on every new one and a reset on gaps, restarts and clears. The control plane hands them to the router, for the sending node's own replicas only. The router tokenizes through a replica's /tokenize, hashes the tokens as vLLM does (checked against cbor2/hashlib golden values), and scores replicas by the share of leading blocks held, falling back to the approximate tree. New metrics beside llmgw_prefix_decisions_total. Untested against a running vLLM. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
147 lines
5.0 KiB
Go
147 lines
5.0 KiB
Go
package kvindex
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
)
|
|
|
|
// Why tokenizing goes to the engine rather than happening here.
|
|
//
|
|
// Block hashes are over token ids, so the router needs the exact ids the
|
|
// engine will compute. For a chat request those come from the model's chat
|
|
// template — a Jinja program shipped with the tokenizer, which differs per
|
|
// model and takes the tools and template arguments as input — and only then
|
|
// from the tokenizer. A Go tokenizer for tokenizer.json would get the second
|
|
// half right and still need a Jinja interpreter faithful to transformers' for
|
|
// the first; a mismatch anywhere is an index that never matches, silently.
|
|
// vLLM's /tokenize runs the same code the request will, so it agrees by
|
|
// construction.
|
|
//
|
|
// The price is a round trip to a replica on the management network before
|
|
// the request is placed, and the template and tokenizer run twice on the
|
|
// engine — once here, once for the request. Against a fake engine on
|
|
// loopback the router's side of it — the call, decoding 2048 token ids and
|
|
// hashing 128 blocks — measured about 0.2ms (BenchmarkTokenize in the router
|
|
// package). Against a real vLLM add its templating and tokenizing, which has
|
|
// not been measured here; it is expected to be milliseconds for prompts of a
|
|
// few thousand tokens. TokenizeTimeout
|
|
// bounds it: a slow answer means routing as tier 1 would, not a slow request.
|
|
|
|
// ErrUnsupported says a request cannot be indexed: its blocks would be hashed
|
|
// with more than their tokens, or it is not a shape /tokenize takes.
|
|
var ErrUnsupported = errors.New("request not indexable")
|
|
|
|
// TokenizeBody builds the /tokenize request for an inference request, or
|
|
// says why there is none.
|
|
//
|
|
// Only what changes the token ids is carried. A request the engine would
|
|
// hash with extra keys — a cache salt, an image — gets ErrUnsupported: its
|
|
// blocks hash differently from its tokens alone, and asking the index about
|
|
// them would only ever miss.
|
|
func TokenizeBody(path string, body []byte, model string) ([]byte, error) {
|
|
var fields map[string]json.RawMessage
|
|
if err := json.Unmarshal(body, &fields); err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrUnsupported, err)
|
|
}
|
|
if _, ok := fields["cache_salt"]; ok {
|
|
return nil, fmt.Errorf("%w: cache_salt", ErrUnsupported)
|
|
}
|
|
out := map[string]json.RawMessage{}
|
|
modelJSON, _ := json.Marshal(model)
|
|
out["model"] = modelJSON
|
|
|
|
switch path {
|
|
case "/v1/chat/completions":
|
|
msgs, ok := fields["messages"]
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: no messages", ErrUnsupported)
|
|
}
|
|
if !textOnly(msgs) {
|
|
return nil, fmt.Errorf("%w: content other than text", ErrUnsupported)
|
|
}
|
|
out["messages"] = msgs
|
|
for _, k := range []string{"tools", "add_generation_prompt", "continue_final_message",
|
|
"chat_template", "chat_template_kwargs", "add_special_tokens"} {
|
|
if v, ok := fields[k]; ok && string(v) != "null" {
|
|
out[k] = v
|
|
}
|
|
}
|
|
case "/v1/completions":
|
|
var s string
|
|
if err := json.Unmarshal(fields["prompt"], &s); err != nil {
|
|
// A list of prompts, or of token ids: a batch the index would have
|
|
// to place as one, which it cannot.
|
|
return nil, fmt.Errorf("%w: prompt is not a single string", ErrUnsupported)
|
|
}
|
|
out["prompt"] = fields["prompt"]
|
|
if v, ok := fields["add_special_tokens"]; ok {
|
|
out["add_special_tokens"] = v
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("%w: %s", ErrUnsupported, path)
|
|
}
|
|
return json.Marshal(out)
|
|
}
|
|
|
|
// textOnly reports whether every message's content is a string or a list of
|
|
// text parts. An image or audio part is hashed by the engine with its
|
|
// identity as an extra key.
|
|
func textOnly(raw json.RawMessage) bool {
|
|
var msgs []struct {
|
|
Content json.RawMessage `json:"content"`
|
|
}
|
|
if err := json.Unmarshal(raw, &msgs); err != nil {
|
|
return false
|
|
}
|
|
for _, m := range msgs {
|
|
if len(m.Content) == 0 || m.Content[0] != '[' {
|
|
continue
|
|
}
|
|
var parts []struct {
|
|
Type string `json:"type"`
|
|
}
|
|
if err := json.Unmarshal(m.Content, &parts); err != nil {
|
|
return false
|
|
}
|
|
for _, p := range parts {
|
|
if p.Type != "text" {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Tokenize asks an engine for a request's token ids.
|
|
func Tokenize(ctx context.Context, client *http.Client, baseURL, apiKey string, body []byte) ([]int, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/tokenize", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if apiKey != "" {
|
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode != http.StatusOK {
|
|
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
return nil, fmt.Errorf("tokenize: %s: %s", resp.Status, bytes.TrimSpace(msg))
|
|
}
|
|
var out struct {
|
|
Tokens []int `json:"tokens"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<20)).Decode(&out); err != nil {
|
|
return nil, fmt.Errorf("tokenize: %w", err)
|
|
}
|
|
return out.Tokens, nil
|
|
}
|