Skip to content

Name the session's cause on each model request in external mode - #516

Merged
pufit merged 6 commits into
alex/attr-01-cause-tasksfrom
alex/attr-02-inference-principal
Oct 17, 2026
Merged

pufit merged 6 commits into
alex/attr-01-cause-tasksfrom
alex/attr-02-inference-principal

Conversation

@alex-clickhouse

@alex-clickhouse alex-clickhouse commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

What

In external mode, every model request that Nerve sends names the principal that caused the work, in one X-Nerve-Inference-Principal header. A proxy in front of the model API can use the header to attribute usage to a person. Nerve does not prove the claim; the proxy checks it.

How

  • Agent sessions. The Claude CLI reads custom headers from ANTHROPIC_CUSTOM_HEADERS in its environment.
    • When Nerve starts the CLI process of a session, it adds one line with the session's cause (Record each session's cause and who changes a task #515), and keeps the other inherited lines.
    • The cause never changes, so the process keeps the header for its whole life. A process that starts again after a model change or a crash gets the same header.
    • A session with no cause sends no header. This is channel input that has no identity mapping.
  • Nerve's own model calls name the system actor. These are titles, prompt rewrite, source condensing, image description, setup checks, the model catalogue, memory on Bedrock, and the nerve doctor model check. They go through the client factories in NerveConfig. A CLI command such as nerve sync or nerve doctor reads the stored system actor read-only from nerve.db.
  • An inherited principal line in Nerve's environment is removed in both modes, for the CLI and for Nerve's own clients. Local mode sends no header.
  • Code: new module nerve/inference_principal.py. Docs are in docs/config.md and docs/accounts.md.

Why

Inference is the main cost of a hosted agent. With the session's cause on each request, usage can be attributed to the person who started the work, and autonomous work to the agent's own principal.

Approximation

In a session that several people use, all model usage is attributed to the person who started the session. The agent's other work in the session follows the same rule.

Not covered

memU's OpenAI-compatible calls and Codex sessions.

Testing

  • tests/test_inference_principal.py:
    • A session that Alice started sends Alice on every request, also on Bob's turns.
    • A restarted engine sends the stored cause.
    • A cron session sends the system actor, and a session with no cause sends nothing.
    • Restarts after a model change keep the header.
    • The real nerve doctor and nerve sync paths are checked against a local stub.
  • End to end with the real CLI against a local API stub: every request carried exactly one header with the right principal, and no new process started when another person took a turn.
  • Full suite on this branch: 5826 passed, 38 skipped.

Stack

  1. Record each session's cause and who changes a task #515 Actor and cause plumbing; task attribution (base main)
  2. Name the session's cause on each model request in external mode #516 (this PR): Model requests name the session's cause
  3. Record who changes a session, and who deletes it #517 Session change attribution
  4. Record who answers notifications and reviews plans #518 Notification answers, plan reviews and approval decisions
  5. Record who starts, kills and decides workflow runs and review loops #519 Workflow runs and review loops
  6. Record who schedules wakeups and starts cron runs, and run them for that person #520 Schedules: run-now, run-later and wakeups

Independent: #514 (system actor takes the seeded system principal ID).

🤖 Generated with Claude Code

alex-clickhouse and others added 6 commits October 17, 2026 08:23
In external mode, a proxy in front of the model API can attribute model
usage to the person who caused it, if each request names that person.
The cause of all agent work in a session is the session's cause: the
person who started it, the system actor for a session that Nerve started
for itself, or none when it is unknown.

The CLI process of a session now gets ANTHROPIC_CUSTOM_HEADERS with one
"X-Nerve-Inference-Principal: <actor ID>" line for the session's cause.
Other inherited header lines stay; an inherited principal line is
replaced. A NULL cause and local mode send no header.

The CLI reads the variable from its environment, which is fixed when the
process starts. The session's cause never changes, so the process keeps
its header for its whole life: a turn of another person in the session
names the session's cause too, and a client rebuilt after a model change
or a crash names the same principal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nerve also calls the model API directly: session titles, prompt rewrite,
source condensing, memory, setup checks and the model catalogue. In
external mode these requests now carry X-Nerve-Inference-Principal with
the system actor ID, so a proxy in front of the model API attributes them
to the agent's own principal instead of to no principal.

The client factories in NerveConfig add the header as a default header.
They read the system actor from the database that the server opened
(nerve.db.global_system_actor_id), so a re-keyed system actor gives its
new ID and a process that opened no database sends no header. memU's
Bedrock adapter gets the same header. memU's OpenAI-compatible clients
are built inside memU from a configuration that takes no headers, so
their requests stay without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Operators of a proxy in front of the model API need to know what the
header says, when Nerve sends it, which requests have none, and that
Nerve does not prove the claim. The external mode section of
docs/config.md describes it: the model requests of a session name the
session's cause for the life of the session, and Nerve's own model calls
name the system actor. docs/accounts.md links to it and says that the
shared-session approximation of the session cause applies to model usage
too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both the Claude CLI and the Anthropic Python SDK add the lines of
ANTHROPIC_CUSTOM_HEADERS from the environment to every request. In local
mode Nerve left that value alone, so a principal line in Nerve's own
environment reached the model API, although local mode sends no
principal. In external mode, Nerve's own clients sent such a line beside
the system actor's header when its name had other letter case, which a
proxy reads as a repeated header.

The CLI environment now drops every principal line (any case) in both
modes and keeps the other custom headers. Nerve's own clients set
anthropic.omit for each inherited principal name, and in external mode
add the system actor's header once. Tests check the environment that the
SDK gives the CLI process and the headers that a direct request carries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`nerve doctor` sends a real model request to check the API, but it opens
no server database, so the request named no principal in external mode
and its cost was stored without one.

The Anthropic client factories take an optional principal_id; None keeps
the system actor of the server's database. The doctor reads the stored
system actor read-only from nerve.db (new read_system_actor_id) when
NERVE_AUTH_MODE in its shell is external, and passes it. In local mode,
with an invalid mode or without a database, the probe names none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nerve's own model clients took the system actor only from the database
that the server opens. A CLI command such as `nerve sync` or
`nerve migrate-openclaw` opens its own connection instead, so in
external mode its source condensing and memory requests named no
principal.

When the server's database is not open in the process, the client
factories now read the stored system actor read-only from nerve.db (the
lookup that the doctor probe uses). Tests send the condensing request of
`nerve sync` through its own connection to a local stand-in of the
Messages API, and pin that an inherited line already naming the
principal gives no override.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alex-clickhouse
alex-clickhouse force-pushed the alex/attr-02-inference-principal branch from 1aebcdf to 2029c13 Compare October 17, 2026 11:42
@alex-clickhouse alex-clickhouse changed the title Name the turn's cause on each model request in external mode Name the session's cause on each model request in external mode Oct 17, 2026
@alex-clickhouse
alex-clickhouse marked this pull request as ready for review October 17, 2026 12:06
@pufit
pufit merged commit 8d7c5d9 into main Oct 17, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants