Status succeeded
Event issue:assigned on abrander/zoo#45
Agent greg greg

Log

Thinking
First, let's start by exploring the project to find where DisallowUnknownFields is used.
Tool 🔧 grep_search {"query": "DisallowUnknownFields"}
{"query": "DisallowUnknownFields"}
/project/internal/llm/streamer.go:70:	decoder.DisallowUnknownFields()
/project/internal/llm/types.go:210:// the streamer decodes with DisallowUnknownFields, so this must match
Tool 🔧 list_files {"path": "."}
{"path": "."}
drwxrwxr-x  5 1000 1000  4096 Aug 24 19:10 .
drwxr-xr-x  1 root root  4096 Aug 24 19:10 ..
drwxrwxr-x  8 1000 1000  4096 Aug 24 19:10 .git
-rw-rw-r--  1 1000 1000    22 Aug 24 19:10 .gitignore
drwxrwxr-x  3 1000 1000  4096 Aug 24 19:10 cmd
-rw-rw-r--  1 1000 1000  2628 Aug 24 19:10 go.mod
-rw-rw-r--  1 1000 1000 16578 Aug 24 19:10 go.sum
drwxrwxr-x 11 1000 1000  4096 Aug 24 19:10 internal
-rw-rw-r--  1 1000 1000  1564 Aug 24 19:10 zoo.hcl.example
Thinking
Let's take a look at the related files.
Tool 🔧 read_file {"path": "internal/llm/streamer.go", "start_line": 1, "end_line": 120}
{"path": "internal/llm/streamer.go", "start_line": 1, "end_line": 120}
{"path":"internal/llm/streamer.go","content":"package llm\r\n\r\nimport (\r\n\t\"bufio\"\r\n\t\"bytes\"\r\n\t\"encoding/json\"\r\n\t\"fmt\"\r\n\t\"io\"\r\n\t\"net/http\"\r\n)\r\n\r\ntype Streamer struct {\r\n\tcompletion ChatCompletion\r\n\tresp       *http.Response\r\n\tscanner    *bufio.Scanner\r\n\teof        bool\r\n}\r\n\r\nfunc NewStreamer(resp *http.Response) (*Streamer, error) {\r\n\tif resp.StatusCode != http.StatusOK {\r\n\t\tbody, _ := io.ReadAll(resp.Body)\r\n\t\tresp.Body.Close()\r\n\t\treturn nil, fmt.Errorf(\"status: %d: %s\", resp.StatusCode, body)\r\n\t}\r\n\r\n\treturn \u0026Streamer{\r\n\t\tresp:    resp,\r\n\t\tscanner: bufio.NewScanner(resp.Body),\r\n\t}, nil\r\n}\r\n\r\n// Get returns the accumulated completion after merging in the next SSE\r\n// chunk. Callers should keep calling Get until it returns io.EOF.\r\nfunc (s *Streamer) Get() (*ChatCompletion, error) {\r\n\tif s.eof {\r\n\t\treturn nil, io.EOF\r\n\t}\r\n\r\n\tprefix := []byte(\"data: \")\r\n\r\n\tvar buf []byte\r\n\r\n\tfor s.scanner.Scan() {\r\n\t\tif err := s.scanner.Err(); err != nil {\r\n\t\t\treturn nil, err\r\n\t\t}\r\n\r\n\t\tbuf = bytes.TrimSpace(s.scanner.Bytes())\r\n\r\n\t\tif len(buf) == 0 {\r\n\t\t\tcontinue\r\n\t\t}\r\n\r\n\t\tif bytes.HasPrefix(buf, prefix) {\r\n\t\t\tbreak\r\n\t\t}\r\n\t}\r\n\r\n\tbuf = bytes.TrimPrefix(buf, prefix)\r\n\r\n\tif bytes.Equal(buf, []byte(\"[DONE]\")) {\r\n\t\ts.resp.Body.Close()\r\n\t\ts.eof = true\r\n\t\treturn nil, io.EOF\r\n\t}\r\n\r\n\tvar completion ChatCompletion\r\n\r\n\tdecoder := json.NewDecoder(bytes.NewReader(buf))\r\n\tdecoder.DisallowUnknownFields()\r\n\r\n\tif err := decoder.Decode(\u0026completion); err != nil {\r\n\t\treturn nil, fmt.Errorf(\"decode completion chunk: %w: %s\", err, buf)\r\n\t}\r\n\r\n\ts.completion.Merge(\u0026completion)\r\n\r\n\treturn \u0026s.completion, nil\r\n}\r","start_line":1,"end_line":79,"total_lines":79,"truncated":false}
Tool 🔧 read_file {"path": "internal/llm/types.go", "start_line": 190, "end_line": 230}
{"path": "internal/llm/types.go", "start_line": 190, "end_line": 230}
{"path":"internal/llm/types.go","content":"}\r\n\r\nfunc (c *Choice) String() string {\r\n\treturn fmt.Sprintf(\"Index: %d, Role: %s, FinishReason: %v, Delta: %s, Message: %s\",\r\n\t\tc.Index, c.Role, c.FinishReason, c.Delta.String(), c.Message.String())\r\n}\r\n\r\nfunc (c *Choice) Merge(next *Choice) {\r\n\tc.Message.Merge(\u0026next.Delta)\r\n\r\n\tif next.FinishReason != \"\" {\r\n\t\tc.FinishReason = next.FinishReason\r\n\t}\r\n\r\n\tif next.Role != \"\" {\r\n\t\tc.Role = next.Role\r\n\t}\r\n}\r\n\r\n// Timings mirrors llama.cpp's /chat/completions timing block exactly;\r\n// the streamer decodes with DisallowUnknownFields, so this must match\r\n// the server's JSON shape field-for-field.\r\ntype Timings struct {\r\n\tCacheN              int     `json:\"cache_n\"`\r\n\tDraftN              int     `json:\"draft_n\"`\r\n\tDraftNAccepted      int     `json:\"draft_n_accepted\"`\r\n\tPromptN             int     `json:\"prompt_n\"`\r\n\tPromptMs            float64 `json:\"prompt_ms\"`\r\n\tPromptPerTokenMs    float64 `json:\"prompt_per_token_ms\"`\r\n\tPromptPerSecond     float64 `json:\"prompt_per_second\"`\r\n\tPredictedN          int     `json:\"predicted_n\"`\r\n\tPredictedMs         float64 `json:\"predicted_ms\"`\r\n\tPredictedPerTokenMs float64 `json:\"predicted_per_token_ms\"`\r\n\tPredictedPerSecond  float64 `json:\"predicted_per_second\"`\r\n}\r\n\r\ntype ChatCompletion struct {\r\n\tChoices           []Choice `json:\"choices\"`\r\n\tCreated           int64    `json:\"created\"`\r\n\tId                string   `json:\"id\"`\r\n\tModel             string   `json:\"model\"`\r","start_line":190,"end_line":230,"total_lines":267,"truncated":true}
Thinking
Let's check the entirety of types.go to understand the structure, and also check whether there are any tests.
Tool 🔧 read_file {"path": "internal/llm/types.go", "start_line": 1, "end_line": 190}
{"path": "internal/llm/types.go", "start_line": 1, "end_line": 190}
{"path":"internal/llm/types.go","content":"// Package llm is an OpenAI-compatible chat completions client with\r\n// streaming and tool-call support, ported from ../a's llamacpp package.\r\npackage llm\r\n\r\nimport \"fmt\"\r\n\r\ntype Message struct {\r\n\tContent          string     `json:\"content\"`\r\n\tRole             string     `json:\"role\"`\r\n\tReasoningContent string     `json:\"reasoning_content,omitempty\"`\r\n\tToolCalls        []ToolCall `json:\"tool_calls,omitempty\"`\r\n\tToolCallID       string     `json:\"tool_call_id,omitempty\"`\r\n\tName             string     `json:\"name,omitempty\"`\r\n}\r\n\r\nfunc (m *Message) String() string {\r\n\tstr := fmt.Sprintf(\"Role: %s, Content: %s ReasoningContent: %s\", m.Role, m.Content, m.ReasoningContent)\r\n\r\n\tfor _, call := range m.ToolCalls {\r\n\t\tstr += fmt.Sprintf(\"\\n  ToolCall: %s\", call.String())\r\n\t}\r\n\r\n\treturn str\r\n}\r\n\r\n// Merge folds a streamed delta into the accumulated message.\r\nfunc (m *Message) Merge(next *Message) {\r\n\tm.Content += next.Content\r\n\r\n\tif next.Role != \"\" {\r\n\t\tm.Role = next.Role\r\n\t}\r\n\r\n\tm.ReasoningContent += next.ReasoningContent\r\n\r\n\tminLength := 0\r\n\tfor _, choice := range next.ToolCalls {\r\n\t\tif choice.Index+1 \u003e minLength {\r\n\t\t\tminLength = choice.Index + 1\r\n\t\t}\r\n\t}\r\n\r\n\tif len(m.ToolCalls) \u003c minLength {\r\n\t\tnewToolCalls := make([]ToolCall, minLength)\r\n\t\tcopy(newToolCalls, m.ToolCalls)\r\n\t\tm.ToolCalls = newToolCalls\r\n\t}\r\n\r\n\tfor _, nextChoice := range next.ToolCalls {\r\n\t\tm.ToolCalls[nextChoice.Index].Merge(\u0026nextChoice)\r\n\t}\r\n}\r\n\r\ntype FunctionCall struct {\r\n\tName      string `json:\"name\"`\r\n\tArguments string `json:\"arguments\"`\r\n}\r\n\r\ntype ToolCall struct {\r\n\tType         string       `json:\"type\"`\r\n\tId           string       `json:\"id\"`\r\n\tIndex        int          `json:\"index\"`\r\n\tName         string       `json:\"name\"`\r\n\tFunctionCall FunctionCall `json:\"function\"`\r\n}\r\n\r\nfunc (t *ToolCall) String() string {\r\n\treturn fmt.Sprintf(\"Type: %s, Id: %s, Index: %d, Name: %s, FunctionCall: {Name: %s, Arguments: %s}\",\r\n\t\tt.Type, t.Id, t.Index, t.Name, t.FunctionCall.Name, t.FunctionCall.Arguments)\r\n}\r\n\r\nfunc (t *ToolCall) Merge(next *ToolCall) {\r\n\tif next.Type != \"\" {\r\n\t\tt.Type = next.Type\r\n\t}\r\n\r\n\tif next.Id != \"\" {\r\n\t\tt.Id = next.Id\r\n\t}\r\n\r\n\tif next.Name != \"\" {\r\n\t\tt.Name = next.Name\r\n\t}\r\n\r\n\tt.Index = next.Index\r\n\r\n\tif next.FunctionCall.Name != \"\" {\r\n\t\tt.FunctionCall.Name = next.FunctionCall.Name\r\n\t}\r\n\r\n\tt.FunctionCall.Arguments += next.FunctionCall.Arguments\r\n}\r\n\r\ntype Property struct {\r\n\tType        string    `json:\"type\"`\r\n\tDescription string    `json:\"description,omitempty\"`\r\n\tEnum        []string  `json:\"enum,omitempty\"`\r\n\tItems       *Property `json:\"items,omitempty\"`\r\n}\r\n\r\ntype Parameters struct {\r\n\tType       string              `json:\"type\"` // object\r\n\tProperties map[string]Property `json:\"properties,omitempty\"`\r\n\tRequired   []string            `json:\"required,omitempty\"`\r\n}\r\n\r\ntype Function struct {\r\n\tName        string     `json:\"name\"`\r\n\tDescription string     `json:\"description,omitempty\"`\r\n\tParameters  Parameters `json:\"parameters,omitempty\"`\r\n\tStrict      bool       `json:\"strict\"`\r\n}\r\n\r\ntype Tool struct {\r\n\tType     string   `json:\"type\"`\r\n\tFunction Function `json:\"function\"`\r\n}\r\n\r\nfunc NewTool(name, description string) Tool {\r\n\treturn Tool{\r\n\t\tType: \"function\",\r\n\t\tFunction: Function{\r\n\t\t\tName:        name,\r\n\t\t\tDescription: description,\r\n\t\t\tStrict:      true,\r\n\t\t},\r\n\t}\r\n}\r\n\r\nfunc (t *Tool) AddProperty(name string, property Property, required bool) *Tool {\r\n\tif t.Function.Parameters.Properties == nil {\r\n\t\tt.Function.Parameters.Properties = make(map[string]Property)\r\n\t} else if _, exists := t.Function.Parameters.Properties[name]; exists {\r\n\t\tpanic(\"property already exists: \" + name)\r\n\t}\r\n\r\n\tt.Function.Parameters.Properties[name] = property\r\n\r\n\tif required {\r\n\t\tt.Function.Parameters.Required = append(t.Function.Parameters.Required, name)\r\n\t}\r\n\r\n\treturn t\r\n}\r\n\r\nfunc (t *Tool) AddStringProperty(name, description string, required bool) *Tool {\r\n\treturn t.AddProperty(name, Property{Type: \"string\", Description: description}, required)\r\n}\r\n\r\nfunc (t *Tool) AddBooleanProperty(name, description string, required bool) *Tool {\r\n\treturn t.AddProperty(name, Property{Type: \"boolean\", Description: description}, required)\r\n}\r\n\r\nfunc (t *Tool) AddIntegerProperty(name, description string, required bool) *Tool {\r\n\treturn t.AddProperty(name, Property{Type: \"integer\", Description: description}, required)\r\n}\r\n\r\nfunc (t *Tool) AddEnumProperty(name, description string, values []string, required bool) *Tool {\r\n\treturn t.AddProperty(name, Property{Type: \"string\", Description: description, Enum: values}, required)\r\n}\r\n\r\nfunc (t *Tool) AddArrayProperty(name, itemType, description string, required bool) *Tool {\r\n\treturn t.AddProperty(name, Property{\r\n\t\tType:        \"array\",\r\n\t\tDescription: description,\r\n\t\tItems:       \u0026Property{Type: itemType},\r\n\t}, required)\r\n}\r\n\r\nfunc (t *Tool) AddStringArrayProperty(name, description string, required bool) *Tool {\r\n\treturn t.AddArrayProperty(name, \"string\", description, required)\r\n}\r\n\r\ntype ChatCompletionRequest struct {\r\n\tModel       string    `json:\"model\"`\r\n\tMessages    []Message `json:\"messages,omitempty\"`\r\n\tStream      bool      `json:\"stream\"`\r\n\tTools       []Tool    `json:\"tools,omitempty\"`\r\n\tSeed        int       `json:\"seed,omitempty\"`\r\n\tTemperature float64   `json:\"temperature,omitempty\"`\r\n\tTopP        float64   `json:\"top_p,omitempty\"`\r\n}\r\n\r\ntype Choice struct {\r\n\tDelta        Message `json:\"delta\"`\r\n\tFinishReason string  `json:\"finish_reason,omitempty\"`\r\n\tIndex        int     `json:\"index\"`\r\n\tMessage      Message `json:\"message\"`\r\n\tRole         string  `json:\"role\"`\r\n}\r","start_line":1,"end_line":190,"total_lines":267,"truncated":true}
Tool 🔧 read_file {"path": "internal/llm/types.go", "start_line": 230, "end_line": 267}
{"path": "internal/llm/types.go", "start_line": 230, "end_line": 267}
{"path":"internal/llm/types.go","content":"\tModel             string   `json:\"model\"`\r\n\tSystemFingerprint string   `json:\"system_fingerprint\"`\r\n\tObject            string   `json:\"object\"`\r\n\tTimings           Timings  `json:\"timings\"`\r\n}\r\n\r\nfunc (c *ChatCompletion) String() string {\r\n\ts := fmt.Sprintf(\"Id: %s, Model: %s, Created: %d, SystemFingerprint: %s, Object: %s\\n\",\r\n\t\tc.Id, c.Model, c.Created, c.SystemFingerprint, c.Object)\r\n\r\n\tfor _, choice := range c.Choices {\r\n\t\ts += fmt.Sprintf(\"  Choice: %s\\n\", choice.String())\r\n\t}\r\n\r\n\treturn s\r\n}\r\n\r\n// Merge folds a streamed chunk into the accumulated completion.\r\nfunc (c *ChatCompletion) Merge(next *ChatCompletion) {\r\n\tminLength := 0\r\n\tfor _, choice := range next.Choices {\r\n\t\tif choice.Index+1 \u003e minLength {\r\n\t\t\tminLength = choice.Index + 1\r\n\t\t}\r\n\t}\r\n\r\n\tif len(c.Choices) \u003c minLength {\r\n\t\tnewChoices := make([]Choice, minLength)\r\n\t\tcopy(newChoices, c.Choices)\r\n\t\tc.Choices = newChoices\r\n\t}\r\n\r\n\tfor _, nextChoice := range next.Choices {\r\n\t\tc.Choices[nextChoice.Index].Merge(\u0026nextChoice)\r\n\t}\r\n\r\n\tc.Timings = next.Timings\r\n}\r","start_line":230,"end_line":267,"total_lines":267,"truncated":false}
Tool 🔧 list_files {"path": "internal", "recursive": true}
{"path": "internal", "recursive": true}
/project/internal/agentrun
/project/internal/agentrun/clone.go
/project/internal/agentrun/context.go
/project/internal/agentrun/docker.go
/project/internal/agentrun/docker_integration_test.go
/project/internal/agentrun/forgejo.go
/project/internal/agentrun/loop.go
/project/internal/agentrun/roster.go
/project/internal/agentrun/roster_test.go
/project/internal/agentrun/run.go
/project/internal/agentrun/seed_test.go
/project/internal/agentrun/system.md
/project/internal/config
/project/internal/config/config.go
/project/internal/config/config_test.go
/project/internal/forgejo
/project/internal/forgejo/client.go
/project/internal/forgejo/client_test.go
/project/internal/forgejo/event.go
/project/internal/forgejo/poll.go
/project/internal/forgejo/watcher.go
/project/internal/forgejo/webhook.go
/project/internal/forgejo/webhook_test.go
/project/internal/livelog
/project/internal/livelog/hub.go
/project/internal/livelog/hub_test.go
/project/internal/llm
/project/internal/llm/client.go
/project/internal/llm/streamer.go
/project/internal/llm/streamer_test.go
/project/internal/llm/types.go
/project/internal/scheduler
/project/internal/scheduler/scheduler.go
/project/internal/scheduler/scheduler_test.go
/project/internal/store
/project/internal/store/schema.sql
/project/internal/store/store.go
/project/internal/store/store_test.go
/project/internal/tools
/project/internal/tools/bash.go
/project/internal/tools/context.go
/project/internal/tools/edit-file.go
/project/internal/tools/forgejo.go
/project/internal/tools/git.go
/project/internal/tools/grep_search.go
/project/internal/tools/list_files.go
/project/internal/tools/move_file.go
/project/internal/tools/read_file.go
/project/internal/tools/registry.go
/project/internal/tools/remove_file.go
/project/internal/tools/shell.go
/project/internal/tools/tools_test.go
/project/internal/tools/write_file.go
/project/internal/web
/project/internal/web/templates.go
/project/internal/web/web.go
/project/internal/web/web_test.go
Thinking
Let's check streamer_test.go to understand the test coverage, and also check whether there are any other places where the JSON decoder is used (there might be other decoders that use DisallowUnknownFields — the grep only found 2 matches, one in streamer.go and one in a comment in types.go). Let's take a look at streamer_test.go.
Tool 🔧 read_file {"path": "internal/llm/streamer_test.go", "start_line": 1, "end_line": 200}
{"path": "internal/llm/streamer_test.go", "start_line": 1, "end_line": 200}
{"path":"internal/llm/streamer_test.go","content":"package llm\r\n\r\nimport (\r\n\t\"encoding/json\"\r\n\t\"io\"\r\n\t\"net/http\"\r\n\t\"strings\"\r\n\t\"testing\"\r\n)\r\n\r\nfunc sseChunk(t *testing.T, delta Message, finish string) string {\r\n\tt.Helper()\r\n\r\n\tbody, err := json.Marshal(ChatCompletion{\r\n\t\tChoices: []Choice{{Delta: delta, FinishReason: finish}},\r\n\t})\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\treturn \"data: \" + string(body) + \"\\n\\n\"\r\n}\r\n\r\nfunc newTestResponse(body string) *http.Response {\r\n\treturn \u0026http.Response{\r\n\t\tStatusCode: http.StatusOK,\r\n\t\tBody:       io.NopCloser(strings.NewReader(body)),\r\n\t}\r\n}\r\n\r\nfunc TestStreamerMergesChunksAndStops(t *testing.T) {\r\n\tsse := sseChunk(t, Message{Content: \"Hel\", Role: \"assistant\"}, \"\") +\r\n\t\tsseChunk(t, Message{Content: \"lo\"}, \"stop\") +\r\n\t\t\"data: [DONE]\\n\\n\"\r\n\r\n\ts, err := NewStreamer(newTestResponse(sse))\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\tvar last *ChatCompletion\r\n\tfor {\r\n\t\tc, err := s.Get()\r\n\t\tif err == io.EOF {\r\n\t\t\tbreak\r\n\t\t}\r\n\t\tif err != nil {\r\n\t\t\tt.Fatal(err)\r\n\t\t}\r\n\t\tlast = c\r\n\t}\r\n\r\n\tif last == nil {\r\n\t\tt.Fatal(\"expected at least one completion\")\r\n\t}\r\n\tif len(last.Choices) != 1 {\r\n\t\tt.Fatalf(\"expected 1 choice, got %d\", len(last.Choices))\r\n\t}\r\n\tif last.Choices[0].Message.Content != \"Hello\" {\r\n\t\tt.Fatalf(\"expected merged content %q, got %q\", \"Hello\", last.Choices[0].Message.Content)\r\n\t}\r\n\tif last.Choices[0].FinishReason != \"stop\" {\r\n\t\tt.Fatalf(\"expected finish_reason stop, got %q\", last.Choices[0].FinishReason)\r\n\t}\r\n\tif last.Choices[0].Message.Role != \"assistant\" {\r\n\t\tt.Fatalf(\"expected role to stick from first chunk, got %q\", last.Choices[0].Message.Role)\r\n\t}\r\n}\r\n\r\nfunc TestStreamerNonOKStatus(t *testing.T) {\r\n\tresp := newTestResponse(\"boom\")\r\n\tresp.StatusCode = http.StatusInternalServerError\r\n\r\n\t_, err := NewStreamer(resp)\r\n\tif err == nil {\r\n\t\tt.Fatal(\"expected error for non-200 response\")\r\n\t}\r\n}\r\n\r\nfunc TestToolCallMerge(t *testing.T) {\r\n\tsse := sseChunk(t, Message{ToolCalls: []ToolCall{{Index: 0, Id: \"call1\", FunctionCall: FunctionCall{Name: \"bash\", Arguments: `{\"command\":`}}}}, \"\") +\r\n\t\tsseChunk(t, Message{ToolCalls: []ToolCall{{Index: 0, FunctionCall: FunctionCall{Arguments: `\"ls\"}`}}}}, \"tool_calls\") +\r\n\t\t\"data: [DONE]\\n\\n\"\r\n\r\n\ts, err := NewStreamer(newTestResponse(sse))\r\n\tif err != nil {\r\n\t\tt.Fatal(err)\r\n\t}\r\n\r\n\tvar last *ChatCompletion\r\n\tfor {\r\n\t\tc, err := s.Get()\r\n\t\tif err == io.EOF {\r\n\t\t\tbreak\r\n\t\t}\r\n\t\tif err != nil {\r\n\t\t\tt.Fatal(err)\r\n\t\t}\r\n\t\tlast = c\r\n\t}\r\n\r\n\tcalls := last.Choices[0].Message.ToolCalls\r\n\tif len(calls) != 1 {\r\n\t\tt.Fatalf(\"expected 1 tool call, got %d\", len(calls))\r\n\t}\r\n\tif calls[0].Id != \"call1\" || calls[0].FunctionCall.Name != \"bash\" {\r\n\t\tt.Fatalf(\"unexpected tool call: %+v\", calls[0])\r\n\t}\r\n\tif calls[0].FunctionCall.Arguments != `{\"command\":\"ls\"}` {\r\n\t\tt.Fatalf(\"expected merged arguments, got %q\", calls[0].FunctionCall.Arguments)\r\n\t}\r\n}\r","start_line":1,"end_line":112,"total_lines":112,"truncated":false}
Thinking
The task is simple: remove the use of `DisallowUnknownFields` to improve compatibility (so that the client doesn't break when the server adds new fields to its JSON response). Changes: 1. `internal/llm/streamer.go`: Remove the `decoder.DisallowUnknownFields()` line. In fact, if we're just going to remove that line, we can simplify it further: `decoder := json.NewDecoder(bytes.NewReader(buf))` — we could just use `json.Unmarshal(buf, &completion)` directly. That's cleaner. But a minimal, surgical change would be to just remove the `DisallowUnknownFields()` call. Using `json.Unmarshal` is cleaner and equivalent. Let me think about which is more surgical. The issue says "stop using DisallowUnknownFields". The minimal change is to remove that one line. But in that case, `decoder := json.NewDecoder(bytes.NewReader(buf))` followed by `decoder.Decode(&completion)` remains. That's fine, and it's the most surgical. Alternatively, using `json.Unmarshal` is simpler. I think using `json.Unmarshal` is cleaner and equivalent. But as a senior developer, either is fine. Let's go with the minimal change: just remove the DisallowUnknownFields line. Hmm, but then the decoder variable is still used. That's fine. Actually, let's go with the cleanest approach: replace the decoder with `json.Unmarshal(buf, &completion)`. That reduces the code. But "surgical" — either is fine. Let's go with removing the DisallowUnknownFields line and keeping the decoder... no, actually, if we're just going to decode a byte slice into a struct, `json.Unmarshal` is the idiomatic and simplest choice. Let's do that. 2. `internal/llm/types.go`: Update the comment on `Timings` that says "the streamer decodes with DisallowUnknownFields, so this must match the server's JSON shape field-for-field." This comment is now stale/incorrect. Update it to reflect that unknown fields are now tolerated. For example: "Timings mirrors llama.cpp's /chat/completions timing block." Also, let's add a test that verifies unknown fields are tolerated (to lock in the compatibility behavior). This is a good idea: add a test where a chunk contains an extra field and the streamer doesn't error out. Let's add `TestStreamerToleratesUnknownFields` to streamer_test.go. Let me check whether there are any other places where JSON decoding with strictness is used... grep only found those two. Let me also check whether `client.go` has any decoding.
Tool 🔧 read_file {"path": "internal/llm/client.go", "start_line": 1, "end_line": 100}
{"path": "internal/llm/client.go", "start_line": 1, "end_line": 100}
{"path":"internal/llm/client.go","content":"package llm\r\n\r\nimport (\r\n\t\"bytes\"\r\n\t\"context\"\r\n\t\"encoding/json\"\r\n\t\"net/http\"\r\n\r\n\t\"github.com/abrander/zoo/internal/config\"\r\n)\r\n\r\ntype Client struct {\r\n\tendpoint string\r\n\ttoken    string\r\n\tmodel    string\r\n\r\n\thttpClient *http.Client\r\n}\r\n\r\n// NewClient builds a Client from a configured llm block.\r\nfunc NewClient(cfg config.LLM) *Client {\r\n\treturn \u0026Client{\r\n\t\tendpoint:   cfg.OpenAI,\r\n\t\ttoken:      cfg.Token,\r\n\t\tmodel:      cfg.Model,\r\n\t\thttpClient: http.DefaultClient,\r\n\t}\r\n}\r\n\r\nfunc (c *Client) StreamChatCompletion(ctx context.Context, req *ChatCompletionRequest) (*Streamer, error) {\r\n\treq2 := *req\r\n\r\n\tif req2.Model == \"\" {\r\n\t\treq2.Model = c.model\r\n\t}\r\n\r\n\treq2.Stream = true\r\n\treq2.Seed = 5\r\n\treq2.Temperature = 0.6\r\n\treq2.TopP = 0.95\r\n\r\n\tbody, err := json.Marshal(req2)\r\n\tif err != nil {\r\n\t\treturn nil, err\r\n\t}\r\n\r\n\t// Bound to ctx so a stalled connection or silent server (headers or\r\n\t// stream body never arriving) is unblocked by the run's deadline\r\n\t// instead of hanging the goroutine forever — http.DefaultClient has\r\n\t// no timeout of its own.\r\n\tr, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+\"/chat/completions\", bytes.NewBuffer(body))\r\n\tif err != nil {\r\n\t\treturn nil, err\r\n\t}\r\n\r\n\tr.Header.Set(\"Authorization\", \"Bearer \"+c.token)\r\n\tr.Header.Set(\"Content-Type\", \"application/json\")\r\n\r\n\tresp, err := c.httpClient.Do(r)\r\n\tif err != nil {\r\n\t\treturn nil, err\r\n\t}\r\n\r\n\treturn NewStreamer(resp)\r\n}\r","start_line":1,"end_line":65,"total_lines":65,"truncated":false}
Thinking
client.go is fine — it's just marshaling. The only place using strict decoding is streamer.go. Plan: 1. `internal/llm/streamer.go`: Remove `decoder.DisallowUnknownFields()` and simplify to `json.Unmarshal(buf, &completion)`. 2. `internal/llm/types.go`: Update the stale comment on `Timings`. 3. Add a test `TestStreamerToleratesUnknownFields` to `streamer_test.go`. 4. Run `go build ./...` and `go test ./...`. 5. Commit, push, open a PR, and comment on the issue. Let me check the git branch status.
The only strict decoder is in `streamer.go`. I'll remove `DisallowUnknownFields`, fix the now-stale comment in `types.go`, and add a regression test that pins down the lenient behavior.
Tool 🔧 edit_file {"path": "internal/llm/streamer.go", "find": "\tvar completion ChatCompletion\n\n\tdecoder := json.NewDecoder(bytes.NewReader(buf))\n\tdecoder.DisallowUnknownFields()\n\n\tif err := decoder.Decode(&completion); err != nil {\n\t\treturn nil, fmt.Errorf(\"decode completion chunk: %w: %s\", err, buf)\n\t}", "replace": "\tvar completion ChatCompletion\n\n\t// Unknown fields are tolerated on purpose: servers (e.g. newer\n\t// llama.cpp builds) may add fields we don't know about yet, and\n\t// failing on them would break compatibility.\n\tif err := json.Unmarshal(buf, &completion); err != nil {\n\t\treturn nil, fmt.Errorf(\"decode completion chunk: %w: %s\", err, buf)\n\t}"}
{"path": "internal/llm/streamer.go", "find": "\tvar completion ChatCompletion\n\n\tdecoder := json.NewDecoder(bytes.NewReader(buf))\n\tdecoder.DisallowUnknownFields()\n\n\tif err := decoder.Decode(&completion); err != nil {\n\t\treturn nil, fmt.Errorf(\"decode completion chunk: %w: %s\", err, buf)\n\t}", "replace": "\tvar completion ChatCompletion\n\n\t// Unknown fields are tolerated on purpose: servers (e.g. newer\n\t// llama.cpp builds) may add fields we don't know about yet, and\n\t// failing on them would break compatibility.\n\tif err := json.Unmarshal(buf, &completion); err != nil {\n\t\treturn nil, fmt.Errorf(\"decode completion chunk: %w: %s\", err, buf)\n\t}"}
1 occurrences replaced
Tool 🔧 edit_file {"path": "internal/llm/types.go", "find": "// Timings mirrors llama.cpp's /chat/completions timing block exactly;\n// the streamer decodes with DisallowUnknownFields, so this must match\n// the server's JSON shape field-for-field.", "replace": "// Timings mirrors llama.cpp's /chat/completions timing block. The\n// streamer tolerates unknown fields, so extra fields from the server\n// are simply ignored."}
{"path": "internal/llm/types.go", "find": "// Timings mirrors llama.cpp's /chat/completions timing block exactly;\n// the streamer decodes with DisallowUnknownFields, so this must match\n// the server's JSON shape field-for-field.", "replace": "// Timings mirrors llama.cpp's /chat/completions timing block. The\n// streamer tolerates unknown fields, so extra fields from the server\n// are simply ignored."}
1 occurrences replaced
Thinking
Next, I'll add a regression test to streamer_test.go.
Tool 🔧 edit_file {"path": "internal/llm/streamer_test.go", "find": "func TestToolCallMerge(t *testing.T) {", "replace": "func TestStreamerToleratesUnknownFields(t *testing.T) {\n\t// Servers may add fields we don't know about; decoding must not fail.\n\tchunk := `data: {\"id\":\"x\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"hi\",\"new_field\":\"boom\"},\"finish_reason\":null,\"extra\":\"y\"}],\"timings\":{\"prompt_n\":1,\"brand_new_timing\":42},\"surprise\":true}\n\n`\n\n\ts, err := NewStreamer(newTestResponse(chunk))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tc, err := s.Get()\n\tif err != nil {\n\t\tt.Fatalf(\"expected unknown fields to be tolerated, got error: %v\", err)\n\t}\n\tif c.Choices[0].Message.Content != \"hi\" {\n\t\tt.Fatalf(\"expected content %q, got %q\", \"hi\", c.Choices[0].Message.Content)\n\t}\n}\n\nfunc TestToolCallMerge(t *testing.T) {"}
{"path": "internal/llm/streamer_test.go", "find": "func TestToolCallMerge(t *testing.T) {", "replace": "func TestStreamerToleratesUnknownFields(t *testing.T) {\n\t// Servers may add fields we don't know about; decoding must not fail.\n\tchunk := `data: {\"id\":\"x\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"hi\",\"new_field\":\"boom\"},\"finish_reason\":null,\"extra\":\"y\"}],\"timings\":{\"prompt_n\":1,\"brand_new_timing\":42},\"surprise\":true}\n\n`\n\n\ts, err := NewStreamer(newTestResponse(chunk))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tc, err := s.Get()\n\tif err != nil {\n\t\tt.Fatalf(\"expected unknown fields to be tolerated, got error: %v\", err)\n\t}\n\tif c.Choices[0].Message.Content != \"hi\" {\n\t\tt.Fatalf(\"expected content %q, got %q\", \"hi\", c.Choices[0].Message.Content)\n\t}\n}\n\nfunc TestToolCallMerge(t *testing.T) {"}
1 occurrences replaced
Thinking
Wait, in my test chunk, the SSE line has to be a single line. I wrote it like this: ``` chunk := `data: {"id":"x",...} ` ``` This is fine — one data line, then a blank line. The scanner reads lines, and the first line starts with "data: " → break. Good. But wait: `"finish_reason":null` — FinishReason is a string, and in Go, decoding null into a string is fine (it's left as the zero value). Good. Also `"timings":{"prompt_n":1,"brand_new_timing":42}` — Timings has PromptN int `json:"prompt_n"`. Good. Alright, let's run the build and tests.
Tool 🔧 bash {"command": "cd /project && go build ./... && go test ./internal/llm/ -v -run TestStreamer 2>&1 | tail -30"}
{"command": "cd /project && go build ./... && go test ./internal/llm/ -v -run TestStreamer 2>&1 | tail -30"}
go: downloading github.com/docker/docker v28.5.2+incompatible
go: downloading github.com/hashicorp/hcl/v2 v2.24.0
go: downloading github.com/google/uuid v1.6.0
go: downloading modernc.org/sqlite v1.56.0
go: downloading codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0
go: downloading github.com/docker/go-connections v0.8.1
go: downloading github.com/docker/go-units v0.5.0
go: downloading github.com/moby/docker-image-spec v1.3.1
go: downloading github.com/opencontainers/image-spec v1.1.1
go: downloading github.com/containerd/errdefs v1.0.0
go: downloading github.com/containerd/errdefs/pkg v0.3.0
go: downloading github.com/distribution/reference v0.6.0
go: downloading github.com/opencontainers/go-digest v1.0.0
go: downloading github.com/pkg/errors v0.9.1
go: downloading go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0
go: downloading go.opentelemetry.io/otel/trace v1.45.0
go: downloading go.opentelemetry.io/otel v1.45.0
go: downloading github.com/42wim/httpsig v1.2.3
go: downloading github.com/go-fed/httpsig v1.1.0
go: downloading github.com/hashicorp/go-version v1.7.0
go: downloading golang.org/x/crypto v0.39.0
go: downloading github.com/felixge/httpsnoop v1.1.0
go: downloading go.opentelemetry.io/otel/metric v1.45.0
go: downloading github.com/agext/levenshtein v1.2.1
go: downloading github.com/apparentlymart/go-textseg/v15 v15.0.0
go: downloading github.com/mitchellh/go-wordwrap v1.0.1
go: downloading github.com/zclconf/go-cty v1.16.3
go: downloading github.com/go-logr/logr v1.4.4
go: downloading github.com/google/go-cmp v0.7.0
go: downloading golang.org/x/sys v0.47.0
go: downloading github.com/go-logr/stdr v1.2.2
go: downloading go.opentelemetry.io/auto/sdk v1.2.1
go: downloading github.com/cespare/xxhash/v2 v2.3.0
go: downloading golang.org/x/text v0.40.0
go: downloading modernc.org/libc v1.74.4
go: downloading github.com/dustin/go-humanize v1.0.1
go: downloading modernc.org/mathutil v1.7.1
go: downloading modernc.org/memory v1.11.0
go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec
=== RUN   TestStreamerMergesChunksAndStops
--- PASS: TestStreamerMergesChunksAndStops (0.00s)
=== RUN   TestStreamerNonOKStatus
--- PASS: TestStreamerNonOKStatus (0.00s)
=== RUN   TestStreamerToleratesUnknownFields
--- PASS: TestStreamerToleratesUnknownFields (0.00s)
PASS
ok  	github.com/abrander/zoo/internal/llm	0.003s
Thinking
The build succeeded, and the new tests passed too. Just to be safe, I'll run the full test suite and verify that no references to DisallowUnknownFields remain.
Build passes and the new regression test is green. Running the full suite to be safe:
Tool 🔧 bash {"command": "cd /project && go vet ./internal/llm/ && go test ./... 2>&1 | tail -20 && grep -rn \"DisallowUnknownFields\" . --include=\"*.go\" || echo \"no DisallowUnknownFields references left\"", "timeout": 300}
{"command": "cd /project && go vet ./internal/llm/ && go test ./... 2>&1 | tail -20 && grep -rn \"DisallowUnknownFields\" . --include=\"*.go\" || echo \"no DisallowUnknownFields references left\"", "timeout": 300}
?   	github.com/abrander/zoo/cmd/zoo	[no test files]
ok  	github.com/abrander/zoo/internal/agentrun	0.004s
ok  	github.com/abrander/zoo/internal/config	0.004s
ok  	github.com/abrander/zoo/internal/forgejo	0.005s
ok  	github.com/abrander/zoo/internal/livelog	0.052s
ok  	github.com/abrander/zoo/internal/llm	0.003s
ok  	github.com/abrander/zoo/internal/scheduler	2.209s
ok  	github.com/abrander/zoo/internal/store	1.869s
ok  	github.com/abrander/zoo/internal/tools	0.003s
ok  	github.com/abrander/zoo/internal/web	0.810s
no DisallowUnknownFields references left
Thinking
All tests pass, and no references to DisallowUnknownFields remain. Next, I'll commit and push, then open a PR and comment on the issue.
All green. Committing and pushing:
Tool 🔧 git {"subcommand": "add", "args": ["-A"]}
{"subcommand": "add", "args": ["-A"]}

        
Thinking
Let's check the git status and the diff to make sure everything is fine.
Tool 🔧 git {"subcommand": "diff", "args": ["--cached"]}
{"subcommand": "diff", "args": ["--cached"]}
diff --git a/internal/llm/streamer.go b/internal/llm/streamer.go
index b28b2eb..5bb1f7d 100644
--- a/internal/llm/streamer.go
+++ b/internal/llm/streamer.go
@@ -66,10 +66,10 @@ func (s *Streamer) Get() (*ChatCompletion, error) {
 
 	var completion ChatCompletion
 
-	decoder := json.NewDecoder(bytes.NewReader(buf))
-	decoder.DisallowUnknownFields()
-
-	if err := decoder.Decode(&completion); err != nil {
+	// Unknown fields are tolerated on purpose: servers (e.g. newer
+	// llama.cpp builds) may add fields we don't know about yet, and
+	// failing on them would break compatibility.
+	if err := json.Unmarshal(buf, &completion); err != nil {
 		return nil, fmt.Errorf("decode completion chunk: %w: %s", err, buf)
 	}
 
diff --git a/internal/llm/streamer_test.go b/internal/llm/streamer_test.go
index f712a84..41c793e 100644
--- a/internal/llm/streamer_test.go
+++ b/internal/llm/streamer_test.go
@@ -77,6 +77,26 @@ func TestStreamerNonOKStatus(t *testing.T) {
 	}
 }
 
+func TestStreamerToleratesUnknownFields(t *testing.T) {
+	// Servers may add fields we don't know about; decoding must not fail.
+	chunk := `data: {"id":"x","model":"m","choices":[{"index":0,"delta":{"role":"assistant","content":"hi","new_field":"boom"},"finish_reason":null,"extra":"y"}],"timings":{"prompt_n":1,"brand_new_timing":42},"surprise":true}
+
+`
+
+	s, err := NewStreamer(newTestResponse(chunk))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	c, err := s.Get()
+	if err != nil {
+		t.Fatalf("expected unknown fields to be tolerated, got error: %v", err)
+	}
+	if c.Choices[0].Message.Content != "hi" {
+		t.Fatalf("expected content %q, got %q", "hi", c.Choices[0].Message.Content)
+	}
+}
+
 func TestToolCallMerge(t *testing.T) {
 	sse := sseChunk(t, Message{ToolCalls: []ToolCall{{Index: 0, Id: "call1", FunctionCall: FunctionCall{Name: "bash", Arguments: `{"command":`}}}}, "") +
 		sseChunk(t, Message{ToolCalls: []ToolCall{{Index: 0, FunctionCall: FunctionCall{Arguments: `"ls"}`}}}}, "tool_calls") +
diff --git a/internal/llm/types.go b/internal/llm/types.go
index 276fe90..5516978 100644
--- a/internal/llm/types.go
+++ b/internal/llm/types.go
@@ -206,9 +206,9 @@ func (c *Choice) Merge(next *Choice) {
 	}
 }
 
-// Timings mirrors llama.cpp's /chat/completions timing block exactly;
-// the streamer decodes with DisallowUnknownFields, so this must match
-// the server's JSON shape field-for-field.
+// Timings mirrors llama.cpp's /chat/completions timing block. The
+// streamer tolerates unknown fields, so extra fields from the server
+// are simply ignored.
 type Timings struct {
 	CacheN              int     `json:"cache_n"`
 	DraftN              int     `json:"draft_n"`
Thinking
The diff looks clean. One thing to check: streamer.go still imports "bytes" — is it still being used? Yes: `bytes.TrimSpace`, `bytes.HasPrefix`, `bytes.TrimPrefix`, `bytes.Equal`. No problem. Committing and pushing.
Tool 🔧 git {"subcommand": "commit", "args": ["-m", "llm: stop using DisallowUnknownFields when decoding stream chunks"]}
{"subcommand": "commit", "args": ["-m", "llm: stop using DisallowUnknownFields when decoding stream chunks"]}
[issue-45-greg dd4a669] llm: stop using DisallowUnknownFields when decoding stream chunks
 3 files changed, 27 insertions(+), 7 deletions(-)
Thinking
Let's check whether the "bytes" import is still needed in streamer.go. It's used in TrimSpace, HasPrefix, TrimPrefix, and Equal. Yes, it's still being used. Good. Let's push the branch.
The `bytes` import is still used elsewhere in streamer.go (TrimSpace, HasPrefix, TrimPrefix, Equal), so no import cleanup needed. Pushing the branch:
Tool 🔧 git {"subcommand": "push", "args": ["origin", "issue-45-greg"]}
{"subcommand": "push", "args": ["origin", "issue-45-greg"]}
To https://code.stdio.dk/abrander/zoo.git
 * [new branch]      issue-45-greg -> issue-45-greg
Thinking
Next, I'll open a pull request and leave a comment on the issue.
Tool 🔧 open_pull_request {"head": "issue-45-greg", "base": "main", "title": "llm: remove DisallowUnknownFields usage", "body": "Closes #45.\n\nThe SSE streamer in `internal/llm` decoded each completion chunk with a strict JSON decoder (`DisallowUnknownFields`), so any field the server added that we didn't model would fail the whole stream. To be more compatible, unknown fields are now simply ignored.\n\nChanges:\n- `internal/llm/streamer.go`: drop `decoder.DisallowUnknownFields()`; decode with `json.Unmarshal` (equivalent, no strictness) and document why unknown fields are tolerated.\n- `internal/llm/types.go`: update the now-stale `Timings` comment that claimed the shape must match the server field-for-field.\n- `internal/llm/streamer_test.go`: add `TestStreamerToleratesUnknownFields` regression test covering unknown fields at the top level, in `choices`, `delta`, and `timings`.\n\n`go build ./...`, `go vet`, and the full test suite pass."}
{"head": "issue-45-greg", "base": "main", "title": "llm: remove DisallowUnknownFields usage", "body": "Closes #45.\n\nThe SSE streamer in `internal/llm` decoded each completion chunk with a strict JSON decoder (`DisallowUnknownFields`), so any field the server added that we didn't model would fail the whole stream. To be more compatible, unknown fields are now simply ignored.\n\nChanges:\n- `internal/llm/streamer.go`: drop `decoder.DisallowUnknownFields()`; decode with `json.Unmarshal` (equivalent, no strictness) and document why unknown fields are tolerated.\n- `internal/llm/types.go`: update the now-stale `Timings` comment that claimed the shape must match the server field-for-field.\n- `internal/llm/streamer_test.go`: add `TestStreamerToleratesUnknownFields` regression test covering unknown fields at the top level, in `choices`, `delta`, and `timings`.\n\n`go build ./...`, `go vet`, and the full test suite pass."}
Pull request opened
Thinking
Let's leave a comment on the issue summarizing what I did.
Tool 🔧 comment {"body": "Done — removed the `DisallowUnknownFields` usage so the client stays compatible with servers that add fields we don't model yet.\n\nWhat changed (branch `issue-45-greg`, PR opened):\n- `internal/llm/streamer.go` — the SSE streamer was decoding each chunk with a strict decoder, so any unknown field would fail the whole stream. It now uses `json.Unmarshal`, which ignores unknown fields.\n- `internal/llm/types.go` — updated the stale `Timings` comment that claimed the shape had to match the server field-for-field.\n- `internal/llm/streamer_test.go` — added a `TestStreamerToleratesUnknownFields` regression test covering unknown fields at the top level, in `choices`, `delta`, and `timings`.\n\n`go build`, `go vet`, and the full test suite all pass."}
{"body": "Done — removed the `DisallowUnknownFields` usage so the client stays compatible with servers that add fields we don't model yet.\n\nWhat changed (branch `issue-45-greg`, PR opened):\n- `internal/llm/streamer.go` — the SSE streamer was decoding each chunk with a strict decoder, so any unknown field would fail the whole stream. It now uses `json.Unmarshal`, which ignores unknown fields.\n- `internal/llm/types.go` — updated the stale `Timings` comment that claimed the shape had to match the server field-for-field.\n- `internal/llm/streamer_test.go` — added a `TestStreamerToleratesUnknownFields` regression test covering unknown fields at the top level, in `choices`, `delta`, and `timings`.\n\n`go build`, `go vet`, and the full test suite all pass."}
Comment posted
Thinking
Everything is complete: 1. ✅ Removed the use of DisallowUnknownFields in streamer.go 2. ✅ Updated the outdated comment in types.go 3. ✅ Added regression tests 4. ✅ Build, vet, and all tests passed 5. ✅ Committed and pushed the branch 6. ✅ Opened a PR 7. ✅ Left a comment on the issue Should I close the issue? The issue says "remove the use of DisallowUnknownFields" — the PR is open and awaiting review. Normally, you don't close the issue until the PR is merged. Since the PR has been opened and is awaiting review, I'll leave the issue open. The PR references #45 with "Closes #45", so it will be automatically closed upon merging. Done. I'll write up the final summary.
All done. Summary of the work on issue #45: **Changes** (branch `issue-45-greg`, PR opened against `main`): 1. **`internal/llm/streamer.go`** — removed `decoder.DisallowUnknownFields()`; the SSE streamer now decodes chunks with `json.Unmarshal`, so extra fields from the server are ignored instead of failing the whole stream. Added a comment explaining the intentional leniency. 2. **`internal/llm/types.go`** — updated the stale `Timings` comment that claimed the struct had to match the server's JSON field-for-field. 3. **`internal/llm/streamer_test.go`** — added `TestStreamerToleratesUnknownFields`, a regression test with unknown fields at the top level, in `choices`, `delta`, and `timings`. **Verification**: `go build ./...`, `go vet`, and the full test suite pass; a repo-wide grep confirms no `DisallowUnknownFields` references remain. The PR is open for review and will auto-close #45 when merged.