Add the conversation fingerprint and the v1 config keys
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
// Package fingerprint identifies a chat-completions conversation without a
|
||||
// session id: the system prompt and the first user message never change from
|
||||
// turn to turn, so hashing them pins the conversation.
|
||||
package fingerprint
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// maxPart is the number of bytes of each input string that contributes to the
|
||||
// key: 4 KiB keeps a huge first message from slowing every turn.
|
||||
const maxPart = 4096
|
||||
|
||||
// Of returns the lowercase hex SHA-256 of the system prompt and the first user
|
||||
// message of a chat-completions body (first 4 KiB of each, joined with "\n"),
|
||||
// or "" when the body is not a JSON object with a "messages" array containing a
|
||||
// user message.
|
||||
func Of(body []byte) string {
|
||||
var doc struct {
|
||||
Messages []json.RawMessage `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &doc); err != nil || doc.Messages == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var system, user string
|
||||
seenSystem, seenUser := false, false
|
||||
for _, raw := range doc.Messages {
|
||||
var msg struct {
|
||||
Role string `json:"role"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
return ""
|
||||
}
|
||||
switch msg.Role {
|
||||
case "system":
|
||||
if !seenSystem {
|
||||
seenSystem = true
|
||||
system = contentText(msg.Content)
|
||||
}
|
||||
case "user":
|
||||
if !seenUser {
|
||||
seenUser = true
|
||||
user = contentText(msg.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !seenUser {
|
||||
return ""
|
||||
}
|
||||
|
||||
if len(system) > maxPart {
|
||||
system = system[:maxPart]
|
||||
}
|
||||
if len(user) > maxPart {
|
||||
user = user[:maxPart]
|
||||
}
|
||||
sum := sha256.Sum256([]byte(system + "\n" + user))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// contentText renders a message content value: a JSON string is returned as-is,
|
||||
// an array of parts is the concatenation of its text parts (other types
|
||||
// ignored), and anything else is "".
|
||||
func contentText(raw json.RawMessage) string {
|
||||
var text string
|
||||
if err := json.Unmarshal(raw, &text); err == nil {
|
||||
return text
|
||||
}
|
||||
var parts []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parts); err != nil {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, p := range parts {
|
||||
if p.Type == "text" {
|
||||
b.WriteString(p.Text)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package fingerprint_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.wntrmute.dev/kyle/crossbar/internal/fingerprint"
|
||||
)
|
||||
|
||||
const conv1 = `{"model":"m","messages":[{"role":"system","content":"You are the project A assistant."},{"role":"user","content":"Add a config loader."},{"role":"assistant","content":"Sure."},{"role":"user","content":"Now tests."}]}`
|
||||
const conv1later = `{"model":"m","messages":[{"role":"system","content":"You are the project A assistant."},{"role":"user","content":"Add a config loader."},{"role":"assistant","content":"Sure."},{"role":"user","content":"Now tests."},{"role":"assistant","content":"Done."},{"role":"user","content":"And docs."}]}`
|
||||
const conv2 = `{"model":"m","messages":[{"role":"system","content":"You are the project A assistant."},{"role":"user","content":"Fix the flaky test."}]}`
|
||||
const conv3 = `{"model":"m","messages":[{"role":"system","content":"You are the project B assistant."},{"role":"user","content":"Add a config loader."}]}`
|
||||
|
||||
func TestSameConversationSameKey(t *testing.T) {
|
||||
a := fingerprint.Of([]byte(conv1))
|
||||
b := fingerprint.Of([]byte(conv1later))
|
||||
if a == "" || a != b {
|
||||
t.Errorf("later turns of one conversation must keep the key: %q vs %q", a, b)
|
||||
}
|
||||
if len(a) != 64 || strings.Trim(a, "0123456789abcdef") != "" {
|
||||
t.Errorf("key must be lowercase hex sha256 (64 chars), got %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDifferentConversationsDifferentKeys(t *testing.T) {
|
||||
a, b, c := fingerprint.Of([]byte(conv1)), fingerprint.Of([]byte(conv2)), fingerprint.Of([]byte(conv3))
|
||||
if a == b {
|
||||
t.Errorf("different first user message must change the key")
|
||||
}
|
||||
if a == c {
|
||||
t.Errorf("different system prompt must change the key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoUserMessageIsEmpty(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
`{"model":"m","messages":[{"role":"system","content":"only a system prompt"}]}`,
|
||||
`{"model":"m","messages":[]}`,
|
||||
`{"model":"m"}`,
|
||||
`{"input":"an embeddings request"}`,
|
||||
`not json at all`,
|
||||
``,
|
||||
} {
|
||||
if got := fingerprint.Of([]byte(body)); got != "" {
|
||||
t.Errorf("Of(%q) = %q, want empty", body, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyTheFirstFourKiBCount(t *testing.T) {
|
||||
long := strings.Repeat("x", 5000)
|
||||
a := `{"messages":[{"role":"user","content":"` + long + `A"}]}`
|
||||
b := `{"messages":[{"role":"user","content":"` + long + `B"}]}`
|
||||
if fingerprint.Of([]byte(a)) != fingerprint.Of([]byte(b)) {
|
||||
t.Errorf("bytes after the first 4 KiB of a message must not change the key")
|
||||
}
|
||||
c := `{"messages":[{"role":"user","content":"A` + long + `"}]}`
|
||||
if fingerprint.Of([]byte(a)) == fingerprint.Of([]byte(c)) {
|
||||
t.Errorf("bytes inside the first 4 KiB must change the key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentPartsAreFlattened(t *testing.T) {
|
||||
plain := `{"messages":[{"role":"user","content":"hello world"}]}`
|
||||
parts := `{"messages":[{"role":"user","content":[{"type":"text","text":"hello world"}]}]}`
|
||||
if fingerprint.Of([]byte(plain)) != fingerprint.Of([]byte(parts)) {
|
||||
t.Errorf("a content array of text parts must fingerprint like the joined text")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user