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:
@@ -5,6 +5,7 @@ owner fills in the Model column. The reviewer adds findings under "Reviews" once
|
|||||||
|
|
||||||
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
||||||
|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| v1/02-fingerprint-config | 2026-09-25 | done | 1 | pass | Switched the existing `TestBadFiles` unknown-key example from `lease_idle` to `bogus_key`, and updated `testdata/bad-unknown-key.toml` to match: this task makes `lease_idle` a valid key, so the old example was stale. `config_test.go` and that testdata are not `_files`-protected, so the edit was permitted even though the task's file list named only `config.go` and `implementer-log.md`; the unknown-key rejection is still covered. | fingerprint.go truncates each input to its first 4096 bytes and uses a presence flag so an empty first system prompt is not overwritten by a later one; `Duration.UnmarshalText` matches `^[0-9]+d$` (regexp) before falling to `time.ParseDuration`. | ? |
|
||||||
| v1/01-store | 2026-09-25 | done | 1 | pass | none | Gate passed on the first run once the owner gofmt'd the three previously-un-clean _files plan-tests under docs/plans/v1/_files/; the blocker in the stopped row no longer applies. | ? |
|
| v1/01-store | 2026-09-25 | done | 1 | pass | none | Gate passed on the first run once the owner gofmt'd the three previously-un-clean _files plan-tests under docs/plans/v1/_files/; the blocker in the stopped row no longer applies. | ? |
|
||||||
| v1/01-store | 2026-09-25 | stopped | 2 | fail | none | Store implemented in `internal/store/store.go` + `schema.go`; `go test -race -count=1 ./internal/store/` is ok and `go vet`/`check-lines` pass. `make gate` cannot print `gate: ok` here: its `gofmt -l .` step flags three committed plan-tests under `docs/plans/v1/_files/` (admin, choose, proxy) that are not gofmt-clean under Go 1.26.7 (formatted by a gofmt that aligns one-line function bodies two columns wider; same diff on a pristine master). They live under `docs/plans/` (must not edit) and the gate covers them; the check cannot be scoped down without weakening it. Code left uncommitted for review. | llama.cpp/ornith-1.5-35b-a3b |
|
| v1/01-store | 2026-09-25 | stopped | 2 | fail | none | Store implemented in `internal/store/store.go` + `schema.go`; `go test -race -count=1 ./internal/store/` is ok and `go vet`/`check-lines` pass. `make gate` cannot print `gate: ok` here: its `gofmt -l .` step flags three committed plan-tests under `docs/plans/v1/_files/` (admin, choose, proxy) that are not gofmt-clean under Go 1.26.7 (formatted by a gofmt that aligns one-line function bodies two columns wider; same diff on a pristine master). They live under `docs/plans/` (must not edit) and the gate covers them; the check cannot be scoped down without weakening it. Code left uncommitted for review. | llama.cpp/ornith-1.5-35b-a3b |
|
||||||
| v0/01-module-gate-config | 2026-09-25 | done | 1 | pass | none | `go mod download` fetched the module (network available); gate passed on the first run. | llama.cpp/ornith-1.5-35b-a3b |
|
| v0/01-module-gate-config | 2026-09-25 | done | 1 | pass | none | `go mod download` fetched the module (network available); gate passed on the first run. | llama.cpp/ornith-1.5-35b-a3b |
|
||||||
|
|||||||
@@ -15,18 +15,25 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
"github.com/BurntSushi/toml"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Duration is a time.Duration that TOML reads from a string such as "60s" or
|
// Duration is a time.Duration that TOML reads from a string such as "60s",
|
||||||
// "30m".
|
// "30m", or "7d" (an integer number of days).
|
||||||
type Duration struct{ time.Duration }
|
type Duration struct{ time.Duration }
|
||||||
|
|
||||||
// UnmarshalText implements encoding.TextUnmarshaler via time.ParseDuration.
|
// UnmarshalText implements encoding.TextUnmarshaler. It accepts the "Nd" form
|
||||||
|
// — an integer number of days, so "7d" is 7 × 24h — in addition to
|
||||||
|
// time.ParseDuration syntax.
|
||||||
func (d *Duration) UnmarshalText(text []byte) error {
|
func (d *Duration) UnmarshalText(text []byte) error {
|
||||||
|
if days, ok := parseDays(text); ok {
|
||||||
|
d.Duration = days
|
||||||
|
return nil
|
||||||
|
}
|
||||||
dt, err := time.ParseDuration(string(text))
|
dt, err := time.ParseDuration(string(text))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -35,6 +42,22 @@ func (d *Duration) UnmarshalText(text []byte) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dayPattern matches a run of digits followed by "d", e.g. "7d".
|
||||||
|
var dayPattern = regexp.MustCompile(`^[0-9]+d$`)
|
||||||
|
|
||||||
|
// parseDays reports whether text is the "Nd" day form and returns that many
|
||||||
|
// hours. The regex guarantees the prefix is a base-10 integer.
|
||||||
|
func parseDays(text []byte) (time.Duration, bool) {
|
||||||
|
if !dayPattern.MatchString(string(text)) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(string(text[:len(text)-1]))
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return time.Duration(n) * 24 * time.Hour, true
|
||||||
|
}
|
||||||
|
|
||||||
// Model is the per-model tuning carried by a host entry.
|
// Model is the per-model tuning carried by a host entry.
|
||||||
type Model struct {
|
type Model struct {
|
||||||
Parallel int `toml:"parallel"`
|
Parallel int `toml:"parallel"`
|
||||||
@@ -58,6 +81,9 @@ type Config struct {
|
|||||||
Listen string `toml:"listen"`
|
Listen string `toml:"listen"`
|
||||||
PollInterval Duration `toml:"poll_interval"`
|
PollInterval Duration `toml:"poll_interval"`
|
||||||
QueueMax int `toml:"queue_max"`
|
QueueMax int `toml:"queue_max"`
|
||||||
|
DB string `toml:"db"`
|
||||||
|
LeaseIdle Duration `toml:"lease_idle"`
|
||||||
|
Retention Duration `toml:"retention"`
|
||||||
Hosts map[string]Host `toml:"hosts"`
|
Hosts map[string]Host `toml:"hosts"`
|
||||||
Routes map[string]Route `toml:"routes"`
|
Routes map[string]Route `toml:"routes"`
|
||||||
}
|
}
|
||||||
@@ -76,6 +102,13 @@ const (
|
|||||||
DefaultPollInterval = 60 * time.Second
|
DefaultPollInterval = 60 * time.Second
|
||||||
DefaultQueueMax = 8
|
DefaultQueueMax = 8
|
||||||
MinPollInterval = time.Second
|
MinPollInterval = time.Second
|
||||||
|
|
||||||
|
DefaultDB = "crossbar.db"
|
||||||
|
DefaultLeaseIdle = 30 * time.Minute
|
||||||
|
DefaultRetention = 180 * 24 * time.Hour
|
||||||
|
|
||||||
|
MinLeaseIdle = time.Minute
|
||||||
|
MinRetention = 24 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
var routeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
var routeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||||
@@ -109,6 +142,15 @@ func Parse(r io.Reader) (*Config, error) {
|
|||||||
return nil, &Error{Field: keys[0], Msg: "unknown key"}
|
return nil, &Error{Field: keys[0], Msg: "unknown key"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !md.IsDefined("db") {
|
||||||
|
c.DB = DefaultDB
|
||||||
|
}
|
||||||
|
if !md.IsDefined("lease_idle") {
|
||||||
|
c.LeaseIdle.Duration = DefaultLeaseIdle
|
||||||
|
}
|
||||||
|
if !md.IsDefined("retention") {
|
||||||
|
c.Retention.Duration = DefaultRetention
|
||||||
|
}
|
||||||
if c.PollInterval.Duration == 0 {
|
if c.PollInterval.Duration == 0 {
|
||||||
c.PollInterval.Duration = DefaultPollInterval
|
c.PollInterval.Duration = DefaultPollInterval
|
||||||
}
|
}
|
||||||
@@ -152,6 +194,15 @@ func (c *Config) validate() *Error {
|
|||||||
if e := c.checkQueue(); e != nil {
|
if e := c.checkQueue(); e != nil {
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
if e := c.checkDB(); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if e := c.checkLeaseIdle(); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if e := c.checkRetention(); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
if e := c.checkHosts(); e != nil {
|
if e := c.checkHosts(); e != nil {
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
@@ -193,6 +244,27 @@ func (c *Config) checkQueue() *Error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Config) checkDB() *Error {
|
||||||
|
if c.DB == "" {
|
||||||
|
return &Error{Field: "db", Msg: "required"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) checkLeaseIdle() *Error {
|
||||||
|
if c.LeaseIdle.Duration < MinLeaseIdle {
|
||||||
|
return &Error{Field: "lease_idle", Msg: "must be at least 1m"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) checkRetention() *Error {
|
||||||
|
if c.Retention.Duration < MinRetention {
|
||||||
|
return &Error{Field: "retention", Msg: "must be at least 1d"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Config) checkHosts() *Error {
|
func (c *Config) checkHosts() *Error {
|
||||||
if len(c.Hosts) == 0 {
|
if len(c.Hosts) == 0 {
|
||||||
return &Error{Field: "hosts", Msg: "at least one required"}
|
return &Error{Field: "hosts", Msg: "at least one required"}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ func TestBadFiles(t *testing.T) {
|
|||||||
{"bad-listen.toml", "listen"},
|
{"bad-listen.toml", "listen"},
|
||||||
{"bad-unknown-host.toml", "routes.r.hosts"},
|
{"bad-unknown-host.toml", "routes.r.hosts"},
|
||||||
{"bad-default-model.toml", "routes.r.default_model"},
|
{"bad-default-model.toml", "routes.r.default_model"},
|
||||||
{"bad-unknown-key.toml", "lease_idle"},
|
{"bad-unknown-key.toml", "bogus_key"},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.file, func(t *testing.T) {
|
t.Run(tc.file, func(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package config_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.wntrmute.dev/kyle/crossbar/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const v1Base = `
|
||||||
|
listen = "127.0.0.1:1"
|
||||||
|
[hosts.a]
|
||||||
|
base_url = "http://a:1"
|
||||||
|
models = { "m" = { } }
|
||||||
|
[routes.r]
|
||||||
|
hosts = ["a"]
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestV1Defaults(t *testing.T) {
|
||||||
|
c, err := config.Parse(strings.NewReader(v1Base))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if c.DB != "crossbar.db" {
|
||||||
|
t.Errorf("DB default = %q", c.DB)
|
||||||
|
}
|
||||||
|
if c.LeaseIdle.Duration != 30*time.Minute {
|
||||||
|
t.Errorf("LeaseIdle default = %v", c.LeaseIdle.Duration)
|
||||||
|
}
|
||||||
|
if c.Retention.Duration != 180*24*time.Hour {
|
||||||
|
t.Errorf("Retention default = %v", c.Retention.Duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestV1Values(t *testing.T) {
|
||||||
|
c, err := config.Parse(strings.NewReader(`
|
||||||
|
db = "/var/lib/crossbar/crossbar.db"
|
||||||
|
lease_idle = "45m"
|
||||||
|
retention = "30d"
|
||||||
|
` + v1Base))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if c.DB != "/var/lib/crossbar/crossbar.db" || c.LeaseIdle.Duration != 45*time.Minute || c.Retention.Duration != 30*24*time.Hour {
|
||||||
|
t.Errorf("got db %q idle %v retention %v", c.DB, c.LeaseIdle.Duration, c.Retention.Duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDurationAcceptsDays(t *testing.T) {
|
||||||
|
var d config.Duration
|
||||||
|
for _, tc := range []struct {
|
||||||
|
in string
|
||||||
|
want time.Duration
|
||||||
|
}{
|
||||||
|
{"1d", 24 * time.Hour}, {"7d", 7 * 24 * time.Hour}, {"90m", 90 * time.Minute}, {"2h30m", 150 * time.Minute},
|
||||||
|
} {
|
||||||
|
if err := d.UnmarshalText([]byte(tc.in)); err != nil || d.Duration != tc.want {
|
||||||
|
t.Errorf("UnmarshalText(%q) = %v %v, want %v", tc.in, d.Duration, err, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"1.5d", "d", "3 days", "1d2h"} {
|
||||||
|
if err := d.UnmarshalText([]byte(bad)); err == nil {
|
||||||
|
t.Errorf("UnmarshalText(%q) must fail", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestV1Validation(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ name, text, field string }{
|
||||||
|
{"empty db", "db = \"\"\n" + v1Base, "db"},
|
||||||
|
{"lease_idle too short", "lease_idle = \"10s\"\n" + v1Base, "lease_idle"},
|
||||||
|
{"retention too short", "retention = \"12h\"\n" + v1Base, "retention"},
|
||||||
|
} {
|
||||||
|
_, err := config.Parse(strings.NewReader(tc.text))
|
||||||
|
e, ok := config.IsError(err)
|
||||||
|
if !ok || e.Field != tc.field {
|
||||||
|
t.Errorf("%s: %v, want *Error on %s", tc.name, err, tc.field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
listen = "127.0.0.1:7777"
|
listen = "127.0.0.1:7777"
|
||||||
lease_idle = "30m"
|
bogus_key = 1
|
||||||
|
|
||||||
[hosts.alpha]
|
[hosts.alpha]
|
||||||
base_url = "http://alpha.example:11434"
|
base_url = "http://alpha.example:11434"
|
||||||
|
|||||||
@@ -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