20 // probeName is the agent name of a probe run. The cache key comes from
24 // probeQuotaLimit is how many points the used percent of a quota window
25 // can grow in a probe run before the run stops.
26 probeQuotaLimit = 15.0
28 // probeLineBytes is the most bytes of one line of a probe log.
29 probeLineBytes = 1 << 20
32 // probe runs "onechat probe LOG COUNT". It appends the first COUNT messages of
33 // LOG, one JSON object {kind, text} per line, to a new chat in probe/ in the
34 // data directory, and builds its tree with the real compactor. After each
35 // message it waits for the builds, as a turn does. It stops at the first
36 // failed call, or when the used percent of a quota window grows by more than
37 // probeQuotaLimit points. At the end it reports the calls, the cached share,
38 // the quota, the retries, and the line sizes.
39 func probe(ctx context.Context, args []string, out io.Writer) error {
41 return errors.New("usage: onechat probe LOG COUNT")
44 count, err := strconv.Atoi(args[1])
45 if err != nil || count <= 0 {
46 return fmt.Errorf("COUNT %q is not a positive number", args[1])
49 entries, err := readProbeLog(args[0], count)
59 config, err := loadConfig(dir)
64 address, auth, err := modelEndpoint(config, dir)
69 root := filepath.Join(dir, "probe", time.Now().Format("20060102-150405"))
70 err = os.MkdirAll(root, 0o700)
72 return fmt.Errorf("create probe directory: %w", err)
75 usagePath := filepath.Join(root, "usage.jsonl")
76 client := newClient(address, auth, "onechat-"+probeName, usagePath)
77 memory, err := openMemory(ctx, root, time.Now, newCompactor(client, probeName, config.Compactor))
82 _, err = fmt.Fprintf(out, "probe: %d messages into %s with %s at %s effort\n", len(entries), root, config.Compactor.Model, config.Compactor.Effort)
84 return errors.Join(err, memory.Close())
87 appended, stop := runProbe(ctx, memory, entries, usagePath)
88 sizes, err := modelLines(memory)
89 err = errors.Join(err, memory.Close())
94 records, err := readUsageFile(usagePath)
99 return writeProbeReport(out, appended, stop, records, sizes)
102 // runProbe appends the entries and waits for the builds after each one. It
103 // returns the number of messages appended and why the run stopped early, or
104 // "" when it did not.
105 func runProbe(ctx context.Context, memory *Memory, entries []probeEntry, usagePath string) (int, string) {
106 for n, entry := range entries {
107 _, err := memory.Append(entry.Kind, entry.Text)
109 err = memory.Wait(ctx)
113 return n, err.Error()
116 records, err := readUsageFile(usagePath)
118 return n + 1, err.Error()
121 reason := probeStop(records)
127 return len(entries), ""
130 // probeEntry is one message of a probe log.
131 type probeEntry struct {
132 Kind string `json:"kind"`
133 Text string `json:"text"`
136 // readProbeLog reads the first count messages of the probe log at path.
137 func readProbeLog(path string, count int) (entries []probeEntry, err error) {
138 file, err := os.Open(path)
140 return nil, fmt.Errorf("open probe log: %w", err)
144 err = errors.Join(err, file.Close())
147 scanner := bufio.NewScanner(file)
148 scanner.Buffer(make([]byte, 64*1024), probeLineBytes)
149 for len(entries) < count && scanner.Scan() {
151 err = json.Unmarshal(scanner.Bytes(), &entry)
153 return nil, fmt.Errorf("probe log line %d: %w", len(entries)+1, err)
156 entries = append(entries, entry)
161 return nil, fmt.Errorf("read probe log: %w", err)
164 if len(entries) < count {
165 return nil, fmt.Errorf("probe log has %d messages, fewer than %d", len(entries), count)
171 // probeStop returns why a probe run must stop: a failed call, or a quota window
172 // whose used percent grew by more than probeQuotaLimit points since the first
173 // call. It returns "" when the run can go on.
174 func probeStop(records []usageRecord) string {
175 for _, record := range records {
176 if record.Error != "" {
177 return "call failed: " + record.Error
181 if len(records) == 0 {
185 first := usedPercents(records[0])
186 for name, used := range usedPercents(records[len(records)-1]) {
187 start, found := first[name]
188 if found && used-start > probeQuotaLimit {
189 return fmt.Sprintf("%s grew from %g to %g", name, start, used)
196 // modelLines returns the sizes of the nodes that the model wrote, in tree
197 // order. A node that fits its source with no model call is not one of them.
198 func modelLines(memory *Memory) ([]int, error) {
200 defer memory.mutex.Unlock()
202 keys, err := memory.tree.Keys()
208 for _, key := range keys {
209 node, _, err := memory.tree.Node(key.level, key.index)
214 model, err := builtByModel(memory, key)
220 sizes = append(sizes, node.Size)
227 // builtByModel tells if the node key needed a model call: a leaf whose source
228 // is over nodeBytes, or a merge whose children do not fit in one node.
229 func builtByModel(memory *Memory, key nodeKey) (bool, error) {
231 message, err := memory.log.Message(key.index)
236 _, free := freeLeaf(message)
241 left, right, err := memory.children(key)
246 _, free := freeMerge(left, right)
251 // readUsageFile reads the records of usage.jsonl at path.
252 func readUsageFile(path string) (records []usageRecord, err error) {
253 file, err := os.Open(path)
254 if errors.Is(err, os.ErrNotExist) {
259 return nil, fmt.Errorf("open usage file: %w", err)
263 err = errors.Join(err, file.Close())
266 scanner := bufio.NewScanner(file)
267 scanner.Buffer(make([]byte, 64*1024), probeLineBytes)
269 var record usageRecord
270 err = json.Unmarshal(scanner.Bytes(), &record)
272 return nil, fmt.Errorf("decode usage: %w", err)
275 records = append(records, record)
280 return nil, fmt.Errorf("read usage file: %w", err)
286 // usedPercents returns the used percent of each quota window of record, by
288 func usedPercents(record usageRecord) map[string]float64 {
289 used := make(map[string]float64)
290 for name, value := range record.Quota {
291 if !strings.HasSuffix(name, "-used-percent") {
295 percent, err := strconv.ParseFloat(value, 64)
304 // writeProbeReport writes the result of a probe run.
305 func writeProbeReport(out io.Writer, appended int, stop string, records []usageRecord, sizes []int) error {
306 var input, cached, output, failed int
307 for _, record := range records {
308 input += record.InputTokens
309 cached += record.CachedTokens
310 output += record.OutputTokens
311 if record.Error != "" {
318 lines = append(lines, "stopped early: "+stop)
321 lines = append(lines,
322 fmt.Sprintf("messages: %d", appended),
323 fmt.Sprintf("calls: %d (%d failed), %.2f per message", len(records), failed, ratio(len(records), appended)),
324 fmt.Sprintf("model lines: %d, retries: %d", len(sizes), len(records)-failed-len(sizes)),
325 fmt.Sprintf("tokens: %d input, %d cached (%.1f%%), %d output", input, cached, 100*ratio(cached, input), output),
329 lines = append(lines, sizeText(sizes))
332 if len(records) > 0 {
333 lines = append(lines,
334 "quota at the first call: "+quotaText(records[0]),
335 "quota at the last call: "+quotaText(records[len(records)-1]),
339 _, err := io.WriteString(out, strings.Join(lines, "\n")+"\n")
344 // sizeText describes the sizes of the model lines.
345 func sizeText(sizes []int) string {
346 sorted := slices.Sorted(slices.Values(sizes))
348 for _, size := range sorted {
350 if size > nodeBytes {
355 return fmt.Sprintf("line bytes: min %d, median %d, mean %.0f, max %d, %d over %d", sorted[0], sorted[len(sorted)/2], ratio(total, len(sorted)), sorted[len(sorted)-1], over, nodeBytes)
358 // quotaText returns the quota headers of record, sorted by name.
359 func quotaText(record usageRecord) string {
360 names := slices.Sorted(maps.Keys(record.Quota))
361 parts := make([]string, len(names))
362 for n, name := range names {
363 parts[n] = strings.TrimPrefix(name, "x-codex-") + "=" + record.Quota[name]
366 return strings.Join(parts, " ")
369 // ratio returns a / b, or 0 when b is 0.
370 func ratio(a, b int) float64 {
375 return float64(a) / float64(b)