Skip to content

One streamed request per run, instead of start / poll / collect - #141

Merged
JadenFiotto-Kaufman merged 4 commits into
0.8from
sse-remote-backend
Aug 17, 2026
Merged

One streamed request per run, instead of start / poll / collect#141
JadenFiotto-Kaufman merged 4 commits into
0.8from
sse-remote-backend

Conversation

@JadenFiotto-Kaufman

@JadenFiotto-Kaufman JadenFiotto-Kaufman commented Aug 13, 2026

Copy link
Copy Markdown
Member

A tool run took three legs: POST /start for a job id, the browser polling NDIF's /response/{id} until COMPLETED, then POST /results/{id} to collect and shape it. It is now one POST that stays open and answers in Server-Sent Events — each NDIF status as it lands, then the finished payload.

event: status   {"status":"QUEUED","description":"Added to Queue at position 1."}
event: status   {"status":"DISPATCHED", ...}
event: status   {"status":"RUNNING", ...}
event: status   {"status":"COMPLETED", ...}
event: data     {"meta":{...},"layers":[...],"topk":[...]}

How

nnsight 0.8 ships AsyncRemoteBackend, which submits on the trace's exit and then hands back the raw status updates instead of consuming them. That is the whole mechanism. _api/sse.py turns those updates into status frames, calls the tool's to_data_obj when the saved values arrive, and emits one data frame.

There is no custom backend subclass. An earlier attempt at this — feat/sse-remote-backend, written against 0.7 — needed 102 lines of one, because 0.7 had no async path.

The three tool routes (logit lens, j-lens, activation patching) differ only in which tool and which arguments, so they now say exactly that and nothing else:

@router.post("/run")
async def run_logit_lens(req, state, user_email):
    return stream_tool(state, logit_lens, state[req.model], req.prompt, top_k=req.topk)

What it buys

  • The browser no longer talks to NDIF at all. config.ts has no NDIF URL and needs none — one origin, no second host to reach through a tunnel or an ingress. The hardcoded localhost:5001 goes with it; NDIF's API has been on 8001 since long before 0.8.
  • Status is pushed, not sampled — QUEUED position and the RUNNING→COMPLETED transition land when they happen rather than up to a second later.
  • No window between legs. A collect step could find its model gone; causal_mediation carried a 503 for exactly that case. One connection, no window.

What it costs

A run lives and dies with its connection. Polling a job id survived a reload; this does not. NDIF still finishes the job and the result is still written by the caller's mutation, so what is lost is the ability to rejoin a run in progress. Runs are seconds to a couple of minutes.

Telemetry's job_id correlation is gone too — it belonged to the poll-and-collect flow and the async backend never surfaces one. Telemetry is currently disabled (# TelemetryClient.init(self)); if it comes back and the correlation matters, take the id from the first status update.

Details worth a look

  • Encoding. The routes lost their response_model, so payloads are encoded by hand — through jsonable_encoder, because a payload is often a plain dict with pydantic models nested inside it (a generation's completion is a list of Token). Plain json.dumps refuses those, which is how this first failed.
  • No heartbeat. Nothing fills the silence between updates. A cold 70B deploy can sit quiet for minutes, and the nginx ingress in front of the preview deployments cuts a connection idle for 60s (proxy-read-timeout, not overridden in deploy/preview/values.yaml). If that bites, the annotation is a better lever than a comment frame; stream_backend's docstring says so. deployApi used to poll on a 20-minute ceiling and now just holds the stream open, which is the flow most exposed to this.
  • Errors split by when they happen. Before the stream opens — a 403 for a model the caller cannot use — they stay ordinary HTTP failures. After, they can only be an error frame, because the status line is long gone.
  • saves["results"]. NDIF keys returned values by the name of the variable the tool saved, which for every nnsightful tool is results. That is a real coupling to the tool's internals; it is the same one the old collect routes had (backend()["results"]).

Testing

Verified end to end against the self-hosted 0.8 NDIF: the logit lens streams the four statuses above and returns Paris for "The Eiffel Tower is in the city of"; generation returns "…is in the city of Paris, and the E". tsc --noEmit and eslint report the same errors as the base commit — none new (34 before, 34 after; the diff is line numbers only).

Not exercised against a live model: activation patching, j-lens, causal mediation, and lens v1, which share the same two helpers but have not been run.

🤖 Generated with Claude Code

A tool run took three legs: POST /start for a job id, the browser polling
NDIF's /response/{id} until COMPLETED, then POST /results/{id} to collect and
shape it. It is now one POST that stays open and answers in Server-Sent
Events -- each NDIF status as it lands, then the finished payload.

nnsight 0.8 ships AsyncRemoteBackend, which submits on the trace's exit and
then hands back the raw status updates instead of consuming them. That is the
whole mechanism: _api/sse.py turns those updates into `status` frames, calls
the tool's to_data_obj when the saved values arrive, and emits one `data`
frame. There is no custom backend subclass -- an earlier attempt at this
(feat/sse-remote-backend, against 0.7) needed 102 lines of one because 0.7 had
no async path.

What this buys, beyond the round trips:

* The browser no longer talks to NDIF at all, so config.ts has no NDIF URL and
  needs none -- one origin, and no second host to reach through a tunnel or an
  ingress. The hardcoded localhost:5001 goes with it; NDIF's API has been on
  8001 since long before 0.8.
* Status is pushed rather than sampled, so QUEUED position and the
  RUNNING->COMPLETED transition land when they happen rather than up to a
  second later.
* A collect step could find its model gone -- causal_mediation carried a 503
  for exactly that window. One connection, no window.

What it costs is that a run lives and dies with its connection: polling a job
id survived a reload, and this does not. NDIF still finishes the job, and the
result is still written by the caller's mutation, so what is lost is the
ability to rejoin a run in progress. Runs are seconds to a couple of minutes.

The routes lost their response_model, so the payload is encoded by hand --
through jsonable_encoder, because a payload is often a plain dict with pydantic
models nested inside it (a generation's `completion` is a list of Token).

Long silences are covered by a comment frame every 15s. A cold 70B deploy can
sit quiet for minutes, and an idle connection is what proxies reap; the
warmup path in deployApi used to poll on a 20-minute ceiling and now just holds
the stream open.

Errors split by when they happen. Before the stream opens -- a 403 for a model
the caller cannot use -- they stay ordinary HTTP failures. After, they can only
be an `error` frame, because the status line is long gone.

Verified against the self-hosted 0.8 NDIF: the logit lens streams QUEUED ->
DISPATCHED -> RUNNING -> COMPLETED and returns Paris for "The Eiffel Tower is
in the city of", and generation returns its completion. Typecheck and lint show
the same errors as before the change, none of them new.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
workbench Ready Ready Preview Aug 13, 2026 8:28pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 446280e9-709b-4b27-b2f0-7c855e1c9591

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

process is called with the dict of saved values and hands back the payload;
every one of the seven is a plain def or lambda, so the isawaitable branch was
never taken. It came across from the earlier 0.7 attempt at this, and I wrote a
comment justifying it -- that a route might await a tokenizer call or a second
request -- which described nothing that exists.

The type said Union[BaseModel, dict, list, Any], which is Any with decoration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Simpler without it, and the one deployment it would have protected is the
nginx-ingress preview path (60s proxy-read-timeout, not overridden). Prod does
not run behind Modal, so the 300s function ceiling that a heartbeat could not
have fixed anyway is moot.

If an idle stream does start getting cut, the annotation is the better lever
than a comment frame here; the docstring says where.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three notes from review.

No leading underscores on what this change added: _jsonify -> jsonify,
_stream_trace -> stream_trace, and lens's _stream is gone entirely. The
underscored names still in these files (_refresh_catalog, _format_lens,
_run_causal_mediation) predate it and are left alone.

stream_error was never called. It came across from the earlier 0.7 attempt at
this, like the await that went in the last commit.

And the local-vs-remote branch was written out four times -- in stream_tool and
in all three routes that do not use it -- each wrapping the same
StreamingResponse with the same media type and headers. That is now one
`stream(result, process)`, which dispatches on whether it was handed an
AsyncRemoteBackend or the saved values themselves. Dispatching on the object
rather than on state.remote keeps it in step with what the trace actually did,
since that flag is what decided the shape. Routes no longer import
MEDIA_TYPE, HEADERS, StreamingResponse or the two frame generators.

The 403 was also written twice, so it moves to auth.require_model_access next
to the predicate it wraps; models.py still logs the denial, which is the only
thing it did differently.

A lens v1 route is now its access check and one line. Same four statuses and
the same Paris from hakone; the frontend is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@JadenFiotto-Kaufman
JadenFiotto-Kaufman merged commit 4508bdb into 0.8 Aug 17, 2026
3 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

🧹 Preview for PR #141 torn down.

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.

1 participant