One streamed request per run, instead of start / poll / collect - #141
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
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>
083621b to
2e764ca
Compare
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>
83e8ace to
06fb6c6
Compare
|
🧹 Preview for PR #141 torn down. |
A tool run took three legs:
POST /startfor a job id, the browser polling NDIF's/response/{id}until COMPLETED, thenPOST /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.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.pyturns those updates intostatusframes, calls the tool'sto_data_objwhen the saved values arrive, and emits onedataframe.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:
What it buys
config.tshas no NDIF URL and needs none — one origin, no second host to reach through a tunnel or an ingress. The hardcodedlocalhost:5001goes with it; NDIF's API has been on 8001 since long before 0.8.causal_mediationcarried 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_idcorrelation 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
response_model, so payloads are encoded by hand — throughjsonable_encoder, because a payload is often a plain dict with pydantic models nested inside it (a generation'scompletionis a list ofToken). Plainjson.dumpsrefuses those, which is how this first failed.proxy-read-timeout, not overridden indeploy/preview/values.yaml). If that bites, the annotation is a better lever than a comment frame;stream_backend's docstring says so.deployApiused to poll on a 20-minute ceiling and now just holds the stream open, which is the flow most exposed to this.errorframe, 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 isresults. 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
Parisfor "The Eiffel Tower is in the city of"; generation returns "…is in the city of Paris, and the E".tsc --noEmitandeslintreport 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