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:
2026-09-25 03:46:05 -07:00
parent 816614d6dd
commit 463cea18de
7 changed files with 317 additions and 5 deletions
+88
View File
@@ -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()
}
+70
View File
@@ -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")
}
}