Skip to content

Auxiliary model calls write usage rows under the caller's session_id but the one-shot's own (turn_seq, step) — always (1, 0) #889

Description

@ianTPE

Auxiliary model calls write their usage rows under the caller's session_id but the one-shot's own (turn_seq, step), which is always (1, 0)

I hit this reconciling token usage across session_llm_requests on my deployment
and want to check the intended semantics before calling it a defect, because the
answer changes what a consumer should do with the rows.

What the data looks like

Of 1,249 (session_id, turn_seq, step) groups on this deployment, 195 hold more
than one row — 119 groups with 2 rows, 14 with 3, 4 with 4 in the eval scopes
alone, 478 rows in total across all scopes. The rows within a group carry
identical prompt_hash and different usage_json.

The collisions are not spread across the key space:

turn_seq | step | dup_groups | rows
       1 |    0 |        192 |  478
    NULL |   -1 |          3 |    7

192 of the 195 sit on exactly (1, 0). And they are uniformly tiny — mean output
2 tokens, mean cacheWrite 751 — which is the shape of a classifier, not of a turn.

An example group, four rows ~10–17s apart in one session:

model         step  cacheWrite  output  prompt_hash
claude-opus-5    0         726       3  6a5c97f1…5ca25ea
claude-opus-5    0         647       3  6a5c97f1…5ca25ea
claude-opus-5    0         648       1  6a5c97f1…5ca25ea
claude-opus-5    0         727       3  6a5c97f1…5ca25ea

Where it comes from

single() in src/harness/claude-harness.ts:853 is the one-shot helper behind
shouldRespond, compactHistory, screenSecurity, judge, oneShot and
generateTitle. It fabricates a throwaway session and a local emit:

const session = { id: `oneshot-${randomBytes(8).toString("hex")}` } as HarnessTurnInput["session"];
...
emit: async (entry) => {
  const saved = { ...entry, sessionId: session.id, seq: emitted.length + 1, createdAt: Date.now() } as SessionEntry;
  emitted.push(saved);
  return saved;
},
recordModelCall: observe?.recordModelCall ?? (() => {}),
...(observe?.recordLlmRequest ? { recordLlmRequest: observe.recordLlmRequest } : {}),

emitted starts empty every call, so userEntry.seq is always 1. The recorder
at :577 then writes turnSeq: userEntry.seq and step: recordedSteps++, and
recordedSteps is likewise per-invocation, so it is always 0.

But recordLlmRequest is not the one-shot's own. It is the orchestrator's
callback, closed over the real session — passed through unchanged for
screenSecurity at src/core/orchestrator/security-screen.ts:50:

recordModelCall: (rec) => { ... },
...(recordLlmRequest ? { recordLlmRequest } : {}),

So the row lands with the caller's session_id and the one-shot's
(turn_seq, step)
. Every screened input in a session writes (1, 0), and they
collide with each other and with the real turn 1's first step.

Note the asymmetry: shouldRespond and compactHistory pass only
recordModelCall, so they leave no row at all (that is #609's section 2).
screenSecurity passes recordLlmRequest, so it leaves rows — under the wrong
coordinates. The two auxiliary paths are inconsistent with each other.

Why I am asking rather than asserting

Two readings, with opposite consequences for anyone aggregating:

  1. (session_id, turn_seq, step) is meant to identify one model call. Then
    these are mis-keyed rows, GET /v1/admin/sessions/:id/llm reports screen calls
    as if they were steps of turn 1, and a consumer that sums by key double-counts.
  2. The key was never meant to be unique. Then the rows are all real, separately
    billed calls, and a consumer that deduplicates on the key — which is the obvious
    thing to do on seeing them — silently undercounts real spend.

I cannot tell which from outside, and the two readings imply opposite fixes. On my
deployment the aggregate difference is small, but the direction of the error is
determined by the answer, not by its size.

Suggestion, if reading 1 is intended

Give the one-shot path coordinates of its own rather than inheriting the caller's
session with the one-shot's counters — either a distinct turn_seq (or a kind
column marking screen / detect / compact / title), or route auxiliary calls
through a recorder that writes them as auxiliary rather than as turn steps. Making
shouldRespond and compactHistory record the same way would close #609's blind
spot at the same time: the gate's decision is currently the only model call on the
turn path with no durable usage row anywhere.

Related: #609 (the same auxiliary calls, seen from the outcome-recording side) and
#586 (usage_json is the record budget enforcement should be reading).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions