Skip to content

feat(plugin): document streaming function frames in OpenAPI [ASTD-350] - #1024

Merged
marcusds merged 3 commits into
mainfrom
astd-350-streaming-function-response-schema/mschwab
Aug 4, 2026
Merged

feat(plugin): document streaming function frames in OpenAPI [ASTD-350]#1024
marcusds merged 3 commits into
mainfrom
astd-350-streaming-function-response-schema/mschwab

Conversation

@marcusds

@marcusds marcusds commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Streaming function routes were registered with response_model=None, so FastAPI emitted an empty 200 schema under the wrong media type:

responses:
  '200':
    content:
      application/json:   # the route returns application/x-ndjson
        schema: {}

Generated clients therefore returned unknown, leaving every consumer to re-declare the frame union and its type guards by hand. ASTD-332 hand-rolled ~60 lines of exactly that. Same class of bug as ASTD-349 — the spec not describing what the service actually returns.

Closes ASTD-350.

What changed

A NemoFunction can now declare frame_schema — the frame type it yields — and the route factory turns that into an application/x-ndjson response. The anonymizer preview function declares its union:

frame_schema: ClassVar[Any] = PreviewFrame

The spec's 200 becomes the full kind-discriminated union, and the generated TypeScript client's return type goes from unknown to:

LogFrame | PreviewDatasetFrame | TraceDatasetFrame | FailedRecordsFrame | Heartbeat | Done | Error

with all seven emitted as SDK schemas.

Two FastAPI details worth knowing

Both cost me an experiment, so they're documented in the code for the next streaming function.

NdjsonFrameResponse subclasses JSONResponse despite never being instantiated. fastapi/openapi/utils.py:393 hardcodes the 200 schema to {"type": "string"} for any response class that isn't a JSONResponse subclass, silently discarding the declared model. Subclassing it is what gets the union documented, while the overridden media_type still files it under application/x-ndjson. Nothing is ever constructed from it — streaming handlers build their own StreamingResponse, and FastAPI returns a Response untouched.

The schema rides on response_model, not responses[200]["model"]. The latter deep-merges onto that same {"type": "string"} default, yielding a schema that claims to be both a string and a union. Runtime is unaffected either way: FastAPI skips response validation when a handler returns a Response, which the streaming branch always does.

Blast radius

Opt-in. Functions that declare no frame_schema keep their exact previous registration. Regenerating all nine plugin specs changed only the anonymizer's — data designer's streaming preview is untouched until it opts in.

web/packages/sdk/generated/ is gitignored, so the SDK changes here are reproduced by pnpm --filter @nemo/sdk gen:all-force rather than committed. The Python SDK regen via Stainless is a separate step and isn't included.

Testing

  • 5 new route tests: the media type, the union and its discriminator, frame models landing in components, functions without a frame_schema being left alone, and streamed output being unchanged
  • 1244 Python tests pass across nemo_platform_plugin, anonymizer and data designer
  • ty and ruff clean; Studio typechecks against the regenerated SDK

Follow-on

Once this lands, #1005 can drop its hand-written PreviewFrame union and guards from previewApi.ts and import the generated types. The streaming reader stays hand-written either way — orval doesn't generate incremental readers.

Summary by CodeRabbit

  • New Features

    • Added structured schemas for newline-delimited JSON (NDJSON) streaming responses.
    • Streaming endpoints can now document their frame types in OpenAPI.
    • Preview responses now identify progress, logs, errors, failed records, datasets, traces, and completion status.
  • Bug Fixes

    • Preserved existing response behavior for functions without frame schemas.
    • Preserved streamed output while improving API documentation.

@github-actions github-actions Bot added the feat label Jul 31, 2026
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor
Suite Lines Covered Line Rate Branch Rate
Unit Tests 30406/38435 79.1% 63.8%
Integration Tests 18006/37104 48.5% 21.0%

Streaming function routes registered with `response_model=None`, so FastAPI
emitted an empty 200 schema under `application/json` — untyped, and the wrong
media type for a route that returns `application/x-ndjson`. Generated clients
got back `unknown`, leaving every consumer to re-declare the frame union and
its type guards by hand.

A `NemoFunction` can now declare `frame_schema`, the frame type it yields, and
the route factory turns that into an `application/x-ndjson` response. The
anonymizer `preview` function declares its union, so the SDK now generates
`LogFrame`, `PreviewDatasetFrame`, `TraceDatasetFrame`, `FailedRecordsFrame`,
`Heartbeat`, `Done` and `Error`, and the client returns their discriminated
union instead of `unknown`.

Two details worth knowing for the next streaming function:

`NdjsonFrameResponse` subclasses `JSONResponse` despite never being
instantiated. FastAPI hardcodes the 200 schema to `{"type": "string"}` for any
response class that isn't a `JSONResponse` subclass, which silently discards
the declared model — subclassing it is what gets the union documented while
`media_type` still files it under `application/x-ndjson`.

The schema rides on `response_model` rather than `responses[200]["model"]`.
The latter deep-merges onto that same `{"type": "string"}` default, leaving a
schema that claims to be both a string and a union. Runtime is unchanged
either way: FastAPI skips response validation when a handler returns a
`Response`, which the streaming branch always does.

Functions that declare no `frame_schema` keep their existing registration, so
this is inert for every other function today.

Signed-off-by: mschwab <mschwab@nvidia.com>
@marcusds
marcusds force-pushed the astd-350-streaming-function-response-schema/mschwab branch from 7ba1c2b to f67ff47 Compare August 4, 2026 18:02
@marcusds
marcusds marked this pull request as ready for review August 4, 2026 18:03
@marcusds
marcusds requested review from a team as code owners August 4, 2026 18:03
@marcusds
marcusds enabled auto-merge August 4, 2026 18:05
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: f81b54b7-576b-4738-a97f-96fd825bf973

📥 Commits

Reviewing files that changed from the base of the PR and between cdb14f6 and c06398b.

📒 Files selected for processing (5)
  • packages/nemo_platform_plugin/src/nemo_platform_plugin/functions/frames.py
  • packages/nemo_platform_plugin/tests/test_functions_frames.py
  • packages/nemo_platform_plugin/tests/test_functions_routes.py
  • plugins/nemo-anonymizer/openapi/openapi.yaml
  • plugins/nemo-anonymizer/src/nemo_anonymizer_plugin/functions/preview.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/nemo_platform_plugin/tests/test_functions_routes.py
  • plugins/nemo-anonymizer/openapi/openapi.yaml

📝 Walkthrough

Walkthrough

Changes

NDJSON frame schema support

Layer / File(s) Summary
Frame schema and discriminator contract
packages/nemo_platform_plugin/src/nemo_platform_plugin/function.py, packages/nemo_platform_plugin/src/nemo_platform_plugin/functions/frames.py, packages/nemo_platform_plugin/tests/test_functions_frames.py
NemoFunction now accepts an optional frame_schema. FrameModel marks kind as required in generated schemas while keeping nullable fields optional.
Route OpenAPI integration
packages/nemo_platform_plugin/src/nemo_platform_plugin/functions/routes.py, packages/nemo_platform_plugin/tests/test_functions_routes.py
Framed functions use NdjsonFrameResponse and document discriminated NDJSON responses. Schema-less functions retain unconstrained responses. Tests cover OpenAPI metadata and streamed output.
Preview stream contract
plugins/nemo-anonymizer/src/nemo_anonymizer_plugin/functions/preview.py, plugins/nemo-anonymizer/openapi/openapi.yaml
PreviewFunction exposes PreviewFrame. The preview endpoint documents log, dataset, failure, heartbeat, completion, and error frame variants.

Sequence Diagram(s)

sequenceDiagram
  participant PreviewFunction
  participant RouteRegistration
  participant OpenAPI
  PreviewFunction->>RouteRegistration: expose PreviewFrame as frame_schema
  RouteRegistration->>OpenAPI: register NDJSON response schema
  OpenAPI-->>RouteRegistration: publish discriminated frame variants
Loading

Suggested reviewers: anastasia-nesterenko, mckornfield

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes documenting streaming function frames in OpenAPI.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch astd-350-streaming-function-response-schema/mschwab

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@plugins/nemo-anonymizer/openapi/openapi.yaml`:
- Around line 439-457: Make the kind field required in all seven frame source
models referenced by the oneOf schema, removing constructor defaults and
updating every constructor call to pass the appropriate discriminator value
explicitly. Then regenerate the OpenAPI document with
script/generate-openapi-spec.sh so each schema requires kind and
generated-client narrowing remains unambiguous.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 7c243464-48f7-43d8-9c92-2ea1a224acf6

📥 Commits

Reviewing files that changed from the base of the PR and between 38cbd37 and cdb14f6.

📒 Files selected for processing (5)
  • packages/nemo_platform_plugin/src/nemo_platform_plugin/function.py
  • packages/nemo_platform_plugin/src/nemo_platform_plugin/functions/routes.py
  • packages/nemo_platform_plugin/tests/test_functions_routes.py
  • plugins/nemo-anonymizer/openapi/openapi.yaml
  • plugins/nemo-anonymizer/src/nemo_anonymizer_plugin/functions/preview.py

Comment thread plugins/nemo-anonymizer/openapi/openapi.yaml
…[ASTD-350]

`kind` carries a default on every frame, so it was optional in the generated
schema — and an optional discriminator does not discriminate. Clients got
`kind?: 'log'`, which misrepresents the wire (the server always emits it) and
undercuts the narrowing this whole change exists to provide.

A `FrameModel` base promotes `kind` to required via `__get_pydantic_json_schema__`,
leaving constructors untouched: `Done()` still works.

Deliberately narrower than pydantic's `json_schema_serialization_defaults_required`,
which promotes every defaulted field. That would catch `Error.details` and
`TraceDatasetFrame.original_text_column` too — and since the spec pipeline
collapses their `anyOf: [string, null]` down to `string`, they would end up
documented as required *and* non-nullable while the server still sends null.
Verified: FastAPI emits the nullable union correctly, so that collapsing is a
pre-existing pipeline issue worth its own ticket rather than one to trip over here.

Signed-off-by: mschwab <mschwab@nvidia.com>
@marcusds
marcusds added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit f45c965 Aug 4, 2026
54 checks passed
@marcusds
marcusds deleted the astd-350-streaming-function-response-schema/mschwab branch August 4, 2026 20:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants