Skip to content

feat(fal): declare endpoint billing components in deploy metadata - #1204

Merged
dimi-fal merged 9 commits into
mainfrom
dimi/billing-estimate-mode
Oct 14, 2026
Merged

dimi-fal merged 9 commits into
mainfrom
dimi/billing-estimate-mode

Conversation

@dimi-fal

@dimi-fal dimi-fal commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Lets an endpoint declare the billable components it reports, so they can be published with the deployed revision without overriding build_metadata in every app.

class MyApp(fal.App):
    @fal.endpoint("/edit", billing=EDIT_BILLING)
    async def edit(self, input: EditInput) -> EditOutput: ...

Changes

  • billing= on fal.endpoint takes any object (not a class) with a declaration() method.

  • fal.BillingComponents (the TypedDict published per endpoint) and fal.BillingDeclaration (the protocol) are exported, so a declaration can be typed and mypy checks its version, schema_id and app:

    class Schema:
        def declaration(self) -> fal.BillingComponents: ...
  • App.build_metadata() publishes the declarations under metadata["billing_components"][path], separately from the OpenAPI spec. Apps without billing declarations publish exactly what they did before.

  • App.get_billing_components() returns the declarations per path.

  • Each declaration is checked before publishing: exactly version (1), schema_id and an object JSON Schema app, and it must survive a JSON round trip unchanged. Deploy metadata travels as a protobuf Struct and then JSONB, so Decimal, sets, NaN and infinity are refused, as are tuples and non-string keys, which JSON would silently change. The schema's detailed shape and vocabulary (metrics, dimension values) are not checked here: the registry builds them, and the platform validates them when a revision is registered.

  • WMA apps (fal.wma.App) build their metadata from App.build_metadata(), so their declarations are published too; asyncapi is rendered from that same OpenAPI document, with one app instance.

  • Request handling and the OpenAPI spec are unchanged. billing= is rejected on websocket endpoints: they have no response headers to report components through. Session and websocket billing is tracked in BIL-676.

Validation

  • New tests/unit/test_billing_declarations.py plus test_app.py, test_request_validation_errors.py and the WMA contract, example and SDK tests: 315 passed, including the six serialization cases. The registry's real declarations (xai-tts, the estimator tests) pass the check.
  • A WMA example app builds one instance for build_metadata, and its published asyncapi equals asyncapi().
  • tests/unit/wma: 554 passed. TestAioiceOrderlyTeardown::test_stock_close_emits_the_retry_race fails only when run with the whole suite (order-dependent); it passes alone and within its file.
  • Ruff format/lint passed via pre-commit. The pre-commit mypy hook failed to start locally (typed_ast environment issue), so the pinned mypy 1.3.0 was run with the hook's arguments: no errors in the changed files. A typed caller returning fal.BillingComponents passes, and a wrong version is reported.

🤖 Generated with Claude Code


Note

Medium Risk
Changes deploy metadata shape and decorator validation, but request paths and OpenAPI stay unchanged; bad declarations fail at metadata build time rather than at runtime billing.

Overview
Endpoints can now declare billable component schemas via a new billing= argument on fal.endpoint, without touching OpenAPI or per-app build_metadata overrides.

Declarations are objects with a declaration() method returning fal.BillingComponents (version, schema_id, object JSON Schema app). App.build_metadata() adds billing_components keyed by path when any endpoint declares billing; apps without billing behave as before. check_declaration rejects malformed or non–JSON-round-trip-safe payloads before publish. Websocket routes cannot use billing=; the declaration lives on the endpoint function, not the hashed route signature.

fal.wma.App now builds metadata through super().build_metadata() (so WMA apps pick up billing too) and renders AsyncAPI from the same OpenAPI document via _render_asyncapi. BillingComponents and BillingDeclaration are exported from fal.

Unit tests cover metadata shape, OpenAPI stability, validation edge cases, and WMA integration.

Reviewed by Cursor Bugbot for commit aae74a6. Bugbot is set up for automated code reviews on this repo. Configure here.

@linear-code

linear-code Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

BIL-530

BIL-671

@dimi-fal
dimi-fal force-pushed the dimi/billing-estimate-mode branch from fc3f169 to d199c41 Compare October 6, 2026 22:54

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Comment thread projects/fal/src/fal/_estimate.py Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread projects/fal/src/fal/_estimate.py Outdated
Comment thread projects/fal/src/fal/api/api.py Outdated
Comment thread projects/fal/src/fal/_estimate.py Outdated
Comment thread projects/fal/src/fal/app.py Outdated
@dimi-fal dimi-fal changed the title feat(fal): declare endpoint billing and serve estimates in estimate mode feat(fal): declare endpoint billing components in deploy metadata Oct 7, 2026
dimi-fal and others added 3 commits October 13, 2026 13:42
Endpoints can declare the billable components they report and pair a pure
estimate function:

    def estimate_edit(input: EditInput) -> UsageEstimate: ...

    @fal.endpoint("/edit", billing=EDIT_BILLING, estimate=estimate_edit)
    def edit(self, input: EditInput) -> EditOutput: ...

- build_metadata publishes declarations under metadata["billing_components"]
  keyed by path, outside the OpenAPI spec.
- A request carrying x-app-fal-estimate runs the estimate instead of the
  handler, after the endpoint's normal body parsing and validation, and
  always returns x-fal-billable-units: 0.
- The estimate must be a plain function taking only the endpoint's input
  model; this is checked when the app is built. Sync and async estimates and
  handlers are supported, and the OpenAPI spec is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drop estimate= and estimate mode: estimates can be served by a separate
app built with the existing App and endpoint APIs, so the SDK only needs to
publish what each endpoint bills.

    @fal.endpoint("/edit", billing=EDIT_BILLING)
    def edit(self, input: EditInput) -> EditOutput: ...

build_metadata publishes the declarations under
metadata["billing_components"] keyed by path, outside the OpenAPI spec.
Request handling and the OpenAPI spec are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
BillingDeclaration.declaration() returned any dict, so a malformed
declaration reached deploy metadata unchecked and failed only when a
pricing formula was later checked against it. The published shape is now a
BillingComponents TypedDict, {version: 1, schema_id, app: <object JSON
Schema>}, and build_metadata() refuses one that does not match.

fal.endpoint() also requires declaration to be callable, and the
declaration is kept off RouteSignature, which collect_routes() hashes, so a
declaration need not be hashable (a plain dataclass works).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dimi-fal
dimi-fal force-pushed the dimi/billing-estimate-mode branch from f4e0c80 to b46cbe2 Compare October 13, 2026 10:45

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b46cbe2. Configure here.

Comment thread projects/fal/src/fal/app.py
dimi-fal and others added 3 commits October 13, 2026 14:11
fal.wma.App.build_metadata rebuilt the metadata without calling super(),
so endpoint billing declarations were dropped from deploy metadata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fal.BillingComponents and fal.BillingDeclaration are public, so apps can
type declaration() as the TypedDict the SDK publishes and have mypy check
version, schema_id and app. endpoint() also refuses a class passed as
billing= (billing=Schema instead of Schema(...)), which previously failed
only when deploy metadata was built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
build_metadata reuses the OpenAPI document App.build_metadata already
built instead of creating a second app instance and building OpenAPI
again for asyncapi(). asyncapi() is unchanged for callers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gothos
Gothos previously approved these changes Oct 13, 2026

@Gothos Gothos 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.

I approve the metadata changes in general. I do feel a stricter check for serialization on the metadata might be better (it just checks that is an object at the moment).

Deploy metadata travels as JSON (a protobuf Struct, then JSONB).
check_declaration now refuses values JSON cannot carry (Decimal, sets,
NaN, infinity) and values it would silently change (tuples, non-string
keys), so a bad declaration fails at deploy with its endpoint named.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dimi-fal
dimi-fal merged commit 7730204 into main Oct 14, 2026
30 of 31 checks passed
@dimi-fal
dimi-fal deleted the dimi/billing-estimate-mode branch October 14, 2026 15:36
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