From 9e82fd3fdb6ed3d78f996bc425ce82765f659348 Mon Sep 17 00:00:00 2001 From: RapidPoseidon Date: Wed, 8 Jul 2026 13:52:38 +0000 Subject: [PATCH 1/5] feat(job): expose estimated and actual cost on jobs Add cost properties to the SDK's job objects, backed by the existing cost-estimate and billing endpoints in the generated client: - RapidataJob.estimated_cost / RapidataJobDefinition.estimated_cost: approximate cost estimates. Both endpoints return HTTP 409 while the job's first batch of rapids is still being created and priced, so the properties poll until the estimate is available (bounded by a timeout) and distinguish that transient state from genuine errors. - RapidataJob.actual_cost: the billed cost, derived by paging the customer's billing groups and matching the job's AudienceJob group; None when the job is not billed yet. New CostEstimate / ActualCost dataclasses keep the public surface thin instead of leaking the generated models, and are re-exported from the package roots. A BillingService wrapper exposes the billing API on OpenAPIService, mirroring the other service accessors. Co-Authored-By: Claude Opus 4.8 Co-Authored-By: lino --- src/rapidata/__init__.py | 2 + src/rapidata/rapidata_client/__init__.py | 8 +- src/rapidata/rapidata_client/job/__init__.py | 1 + src/rapidata/rapidata_client/job/cost.py | 115 ++++++++++++++++++ .../rapidata_client/job/rapidata_job.py | 77 ++++++++++++ .../job/rapidata_job_definition.py | 35 ++++++ src/rapidata/service/openapi_service.py | 9 ++ src/rapidata/service/services/__init__.py | 2 + .../service/services/billing_service.py | 21 ++++ 9 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 src/rapidata/rapidata_client/job/cost.py create mode 100644 src/rapidata/service/services/billing_service.py diff --git a/src/rapidata/__init__.py b/src/rapidata/__init__.py index 5042fa6d5..60db92c9d 100644 --- a/src/rapidata/__init__.py +++ b/src/rapidata/__init__.py @@ -11,6 +11,8 @@ RapidataJob, RapidataJobDefinition, RapidataJobManager, + CostEstimate, + ActualCost, RapidataSignal, RapidataSignalManager, ValidationSetManager, diff --git a/src/rapidata/rapidata_client/__init__.py b/src/rapidata/rapidata_client/__init__.py index c375713ba..98670a409 100644 --- a/src/rapidata/rapidata_client/__init__.py +++ b/src/rapidata/rapidata_client/__init__.py @@ -6,7 +6,13 @@ RapidataFilteredAudience, ) from .order import RapidataOrderManager, RapidataOrder -from .job import RapidataJob, RapidataJobDefinition, RapidataJobManager +from .job import ( + RapidataJob, + RapidataJobDefinition, + RapidataJobManager, + CostEstimate, + ActualCost, +) from .signal import RapidataSignal, RapidataSignalManager from .validation import ValidationSetManager, RapidataValidationSet, Box from .results import RapidataResults diff --git a/src/rapidata/rapidata_client/job/__init__.py b/src/rapidata/rapidata_client/job/__init__.py index b18a8b1cb..c059a6cad 100644 --- a/src/rapidata/rapidata_client/job/__init__.py +++ b/src/rapidata/rapidata_client/job/__init__.py @@ -1,3 +1,4 @@ from .rapidata_job_definition import RapidataJobDefinition from .rapidata_job_manager import RapidataJobManager from .rapidata_job import RapidataJob +from .cost import CostEstimate, ActualCost diff --git a/src/rapidata/rapidata_client/job/cost.py b/src/rapidata/rapidata_client/job/cost.py new file mode 100644 index 000000000..5686ded8d --- /dev/null +++ b/src/rapidata/rapidata_client/job/cost.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from dataclasses import dataclass +from time import monotonic, sleep +from typing import Callable, TypeVar, TYPE_CHECKING + +from rapidata.rapidata_client.api.rapidata_api_client import ( + suppress_rapidata_error_logging, +) +from rapidata.rapidata_client.config import logger +from rapidata.rapidata_client.exceptions.rapidata_error import RapidataError + +if TYPE_CHECKING: + from rapidata.api_client.models.get_job_cost_estimate_endpoint_output import ( + GetJobCostEstimateEndpointOutput, + ) + from rapidata.api_client.models.get_job_definition_cost_estimate_endpoint_output import ( + GetJobDefinitionCostEstimateEndpointOutput, + ) + +T = TypeVar("T") + +# The estimate endpoints answer 409 while the first batch of rapids is still +# being created and priced; once that batch exists the estimate is available. +_ESTIMATE_NOT_READY_STATUS = 409 + +# The first batch is ~5000 rapids, so the estimate is usually ready within a +# couple of minutes; poll up to this long before giving up. +DEFAULT_ESTIMATE_TIMEOUT = 300.0 +DEFAULT_ESTIMATE_POLL_INTERVAL = 5.0 + + +@dataclass(frozen=True) +class CostEstimate: + """An approximate cost estimate for a job or job definition. + + The estimate is not exact: the backend prices a sample of the rapids that + have been created so far (the job's first batch) and scales that per-response + cost up to the total number of responses the job requires. The real cost can + differ once every rapid has been priced. + + Attributes: + estimated_cost: The estimated total cost of running to completion. + cost_per_response: The representative per-response cost the estimate is based on. + datapoint_count: The number of datapoints in the dataset. + required_responses: The total number of responses the referee requires to complete. + """ + + estimated_cost: float + cost_per_response: float + datapoint_count: int + required_responses: int + + @classmethod + def _from_model( + cls, + model: ( + GetJobCostEstimateEndpointOutput + | GetJobDefinitionCostEstimateEndpointOutput + ), + ) -> CostEstimate: + return cls( + estimated_cost=model.estimated_cost, + cost_per_response=model.cost_per_response, + datapoint_count=model.datapoint_count, + required_responses=model.required_responses, + ) + + +@dataclass(frozen=True) +class ActualCost: + """The billed cost of a job, derived from its billing group. + + Attributes: + net_cost: The billed cost after any discount. + gross_cost: The cost before any discount. + response_count: The number of responses attributed to the job. + """ + + net_cost: float + gross_cost: float + response_count: int + + +def _poll_for_cost_estimate( + fetch: Callable[[], T], + *, + timeout: float, + interval: float, +) -> T: + """Call ``fetch`` until it returns, polling while the estimate is not ready. + + The estimate endpoints return HTTP 409 while the job's first batch of rapids + is still being created and priced. That transient status is retried until the + estimate becomes available; any other error is raised immediately. + + Raises: + TimeoutError: If the estimate is still not available after ``timeout`` seconds. + """ + deadline = monotonic() + timeout + while True: + try: + with suppress_rapidata_error_logging(): + return fetch() + except RapidataError as e: + if e.status_code != _ESTIMATE_NOT_READY_STATUS: + raise + if monotonic() >= deadline: + raise TimeoutError( + "Cost estimate was not available after " + f"{timeout:.0f}s. The job's rapids may still be getting " + "created and priced - try again shortly." + ) from e + logger.debug("Cost estimate not ready yet (409), polling...") + sleep(interval) diff --git a/src/rapidata/rapidata_client/job/rapidata_job.py b/src/rapidata/rapidata_client/job/rapidata_job.py index 68636c7c7..f5424af0e 100644 --- a/src/rapidata/rapidata_client/job/rapidata_job.py +++ b/src/rapidata/rapidata_client/job/rapidata_job.py @@ -19,6 +19,13 @@ from rapidata.rapidata_client.api.rapidata_api_client import ( suppress_rapidata_error_logging, ) +from rapidata.rapidata_client.job.cost import ( + ActualCost, + CostEstimate, + DEFAULT_ESTIMATE_POLL_INTERVAL, + DEFAULT_ESTIMATE_TIMEOUT, + _poll_for_cost_estimate, +) if TYPE_CHECKING: from rapidata.rapidata_client.results.rapidata_results import RapidataResults @@ -60,6 +67,7 @@ def __init__( self.definition_id = definition_id self.__pipeline_id = pipeline_id self.__completed_at = None + self.__estimated_cost: CostEstimate | None = None self.job_details_page = f"https://app.{self._openapi_service.environment}/audiences/{self.audience_id}/job/{self.id}" logger.debug("RapidataJob initialized") @@ -152,6 +160,75 @@ def pipeline_id(self) -> str: ).pipeline_id return self.__pipeline_id + @property + def estimated_cost(self) -> CostEstimate: + """An approximate cost estimate for running this job to completion. + + The estimate is not exact: the backend prices a sample of the rapids + created so far (the job's first batch) and scales that per-response cost + up to the total number of responses the job requires. The real cost can + differ once every rapid has been priced. + + Right after a job is created the estimate is not yet available (the first + batch of rapids is still being created and priced); this call polls until + it becomes available. + + Raises: + TimeoutError: If the estimate is still not available after a few minutes. + """ + if self.__estimated_cost is None: + with tracer.start_as_current_span("RapidataJob.estimated_cost"): + model = _poll_for_cost_estimate( + lambda: self._openapi_service.order.job_api.job_job_id_cost_estimate_get( + self.id + ), + timeout=DEFAULT_ESTIMATE_TIMEOUT, + interval=DEFAULT_ESTIMATE_POLL_INTERVAL, + ) + self.__estimated_cost = CostEstimate._from_model(model) + return self.__estimated_cost + + @property + def actual_cost(self) -> ActualCost | None: + """The billed cost of this job, or None if it has not been billed yet. + + Derived from the customer's billing groups; returns None while no billing + group exists for this job (e.g. the job has not produced billable + responses yet). Unlike :attr:`estimated_cost`, this reflects the real + amount charged. + """ + with tracer.start_as_current_span("RapidataJob.actual_cost"): + from rapidata.api_client.models.i_billing_group_model_audience_job_billing_group_model import ( + IBillingGroupModelAudienceJobBillingGroupModel, + ) + + page = 1 + page_size = 100 + while True: + result = ( + self._openapi_service.billing.billing_api.billing_costs_groups_get( + page=page, + page_size=page_size, + ) + ) + for group in result.items: + instance = group.actual_instance + if ( + isinstance( + instance, IBillingGroupModelAudienceJobBillingGroupModel + ) + and instance.job_id == self.id + ): + return ActualCost( + net_cost=instance.net_cost, + gross_cost=instance.gross_cost, + response_count=instance.response_count, + ) + + if page * page_size >= result.total: + return None + page += 1 + def get_status(self) -> str: """ Gets the status of the job. diff --git a/src/rapidata/rapidata_client/job/rapidata_job_definition.py b/src/rapidata/rapidata_client/job/rapidata_job_definition.py index 4697f003b..769f6cda5 100644 --- a/src/rapidata/rapidata_client/job/rapidata_job_definition.py +++ b/src/rapidata/rapidata_client/job/rapidata_job_definition.py @@ -6,6 +6,12 @@ from typing import Literal from rapidata.service.openapi_service import OpenAPIService from rapidata.rapidata_client.config import tracer, logger, managed_print +from rapidata.rapidata_client.job.cost import ( + CostEstimate, + DEFAULT_ESTIMATE_POLL_INTERVAL, + DEFAULT_ESTIMATE_TIMEOUT, + _poll_for_cost_estimate, +) import webbrowser import urllib.parse from colorama import Fore @@ -24,6 +30,35 @@ def __init__( self._job_details_page = ( f"https://app.{self._openapi_service.environment}/definitions/{self.id}" ) + self.__estimated_cost: CostEstimate | None = None + + @property + def estimated_cost(self) -> CostEstimate: + """An approximate cost estimate for running this definition to completion. + + The estimate is not exact: the backend prices a sample of the rapids + created so far (the definition's first batch) and scales that per-response + cost up to the total number of responses the referee requires. The real + cost can differ once every rapid has been priced. + + Right after a definition is previewed the estimate is not yet available + (the first batch of rapids is still being created and priced); this call + polls until it becomes available. + + Raises: + TimeoutError: If the estimate is still not available after a few minutes. + """ + if self.__estimated_cost is None: + with tracer.start_as_current_span("RapidataJobDefinition.estimated_cost"): + model = _poll_for_cost_estimate( + lambda: self._openapi_service.order.job_api.job_definition_definition_id_cost_estimate_get( + self.id + ), + timeout=DEFAULT_ESTIMATE_TIMEOUT, + interval=DEFAULT_ESTIMATE_POLL_INTERVAL, + ) + self.__estimated_cost = CostEstimate._from_model(model) + return self.__estimated_cost def preview(self) -> RapidataJobDefinition: """Will open the browser where you can preview the job definition before giving it to an audience.""" diff --git a/src/rapidata/service/openapi_service.py b/src/rapidata/service/openapi_service.py index 9cdec414d..8124bbc4b 100644 --- a/src/rapidata/service/openapi_service.py +++ b/src/rapidata/service/openapi_service.py @@ -13,6 +13,7 @@ if TYPE_CHECKING: from rapidata.service.services.asset_service import AssetService + from rapidata.service.services.billing_service import BillingService from rapidata.service.services.order_service import OrderService from rapidata.service.services.flow_service import FlowService from rapidata.service.services.audience_service import AudienceService @@ -69,6 +70,7 @@ def __init__( logger.debug("RapidataApiClient initialized") self._asset: AssetService | None = None + self._billing: BillingService | None = None self._order: OrderService | None = None self._flow: FlowService | None = None self._audience: AudienceService | None = None @@ -153,6 +155,13 @@ def asset(self) -> AssetService: self._asset = AssetService(self.api_client) return self._asset + @property + def billing(self) -> BillingService: + if self._billing is None: + from rapidata.service.services.billing_service import BillingService + self._billing = BillingService(self.api_client) + return self._billing + @property def order(self) -> OrderService: if self._order is None: diff --git a/src/rapidata/service/services/__init__.py b/src/rapidata/service/services/__init__.py index 0cf244dd3..d0dfc3c22 100644 --- a/src/rapidata/service/services/__init__.py +++ b/src/rapidata/service/services/__init__.py @@ -1,5 +1,6 @@ from rapidata.service.services.asset_service import AssetService from rapidata.service.services.audience_service import AudienceService +from rapidata.service.services.billing_service import BillingService from rapidata.service.services.campaign_service import CampaignService from rapidata.service.services.dataset_service import DatasetService from rapidata.service.services.flow_service import FlowService @@ -14,6 +15,7 @@ __all__ = [ "AssetService", "AudienceService", + "BillingService", "CampaignService", "DatasetService", "FlowService", diff --git a/src/rapidata/service/services/billing_service.py b/src/rapidata/service/services/billing_service.py new file mode 100644 index 000000000..52a70cf71 --- /dev/null +++ b/src/rapidata/service/services/billing_service.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from rapidata.api_client.api.billing_api import BillingApi + from rapidata.rapidata_client.api.rapidata_api_client import RapidataApiClient + + +class BillingService: + def __init__(self, api_client: RapidataApiClient) -> None: + self._api_client = api_client + self._billing_api: BillingApi | None = None + + @property + def billing_api(self) -> BillingApi: + if self._billing_api is None: + from rapidata.api_client.api.billing_api import BillingApi + + self._billing_api = BillingApi(self._api_client) + return self._billing_api From 9fbdacb2ecb939d7e6d3d6a5edd6ea2c7ecb5e52 Mon Sep 17 00:00:00 2001 From: RapidPoseidon Date: Wed, 8 Jul 2026 14:47:08 +0000 Subject: [PATCH 2/5] perf(job): scope actual_cost billing scan to the job's lifetime The billing costs-groups endpoint ranks a customer's groups by cost descending and caps page size at 100, so an unscoped scan for a cheap job paged through nearly the customer's entire billing history to reach its group at the bottom of the ranking (tens of sequential requests). Pass start_date=created_at so the backend (billable_hour >= toStartOfHour(startDate)) drops every group older than the job. A job's responses are only billed at or after creation, so no cost is missed. Also terminate on items-seen rather than an assumed page size, so a server-side page cap can't end the scan early. Co-Authored-By: Claude Opus 4.8 Co-Authored-By: lino --- src/rapidata/rapidata_client/job/rapidata_job.py | 12 +++++++++++- uv.lock | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/rapidata/rapidata_client/job/rapidata_job.py b/src/rapidata/rapidata_client/job/rapidata_job.py index f5424af0e..272d4266e 100644 --- a/src/rapidata/rapidata_client/job/rapidata_job.py +++ b/src/rapidata/rapidata_client/job/rapidata_job.py @@ -202,11 +202,20 @@ def actual_cost(self) -> ActualCost | None: IBillingGroupModelAudienceJobBillingGroupModel, ) + # The endpoint ranks the customer's billing groups by cost descending + # and caps the page size at 100, so an unscoped scan of a cheap job + # pages through the customer's whole history to reach its group at the + # bottom of the ranking. Scoping to costs since the job was created + # drops every older group: the backend filters billable_hour by the + # start hour, and a job's responses are only billed at or after + # creation, so this narrows the scan without dropping any of its cost. page = 1 page_size = 100 + seen = 0 while True: result = ( self._openapi_service.billing.billing_api.billing_costs_groups_get( + start_date=self.created_at, page=page, page_size=page_size, ) @@ -225,7 +234,8 @@ def actual_cost(self) -> ActualCost | None: response_count=instance.response_count, ) - if page * page_size >= result.total: + seen += len(result.items) + if not result.items or seen >= result.total: return None page += 1 diff --git a/uv.lock b/uv.lock index a77505178..0f823f568 100644 --- a/uv.lock +++ b/uv.lock @@ -2583,7 +2583,7 @@ wheels = [ [[package]] name = "rapidata" -version = "3.15.5" +version = "3.15.6" source = { editable = "." } dependencies = [ { name = "authlib" }, From 056d53a2c5d29f1eeac5c7baedf96d0930373955 Mon Sep 17 00:00:00 2001 From: RapidPoseidon Date: Wed, 8 Jul 2026 15:35:04 +0000 Subject: [PATCH 3/5] refactor(job): drop billed cost, keep only the estimate Remove RapidataJob.actual_cost, the ActualCost dataclass and the BillingService wiring - the billed-cost lookup was slow and its value changed between reads, which is surprising for a property. Keep the estimated_cost property (fast, stable once priced) and slim CostEstimate to estimated_cost / datapoint_count / required_responses. Rewrite the docstrings for first-time SDK readers, dropping internal implementation detail. Co-Authored-By: Claude Opus 4.8 Co-Authored-By: lino --- src/rapidata/__init__.py | 1 - src/rapidata/rapidata_client/__init__.py | 1 - src/rapidata/rapidata_client/job/__init__.py | 2 +- src/rapidata/rapidata_client/job/cost.py | 53 +++++---------- .../rapidata_client/job/rapidata_job.py | 65 ++----------------- .../job/rapidata_job_definition.py | 13 ++-- src/rapidata/service/openapi_service.py | 9 --- src/rapidata/service/services/__init__.py | 2 - .../service/services/billing_service.py | 21 ------ 9 files changed, 24 insertions(+), 143 deletions(-) delete mode 100644 src/rapidata/service/services/billing_service.py diff --git a/src/rapidata/__init__.py b/src/rapidata/__init__.py index 60db92c9d..898a8fa3e 100644 --- a/src/rapidata/__init__.py +++ b/src/rapidata/__init__.py @@ -12,7 +12,6 @@ RapidataJobDefinition, RapidataJobManager, CostEstimate, - ActualCost, RapidataSignal, RapidataSignalManager, ValidationSetManager, diff --git a/src/rapidata/rapidata_client/__init__.py b/src/rapidata/rapidata_client/__init__.py index 98670a409..19419f5c6 100644 --- a/src/rapidata/rapidata_client/__init__.py +++ b/src/rapidata/rapidata_client/__init__.py @@ -11,7 +11,6 @@ RapidataJobDefinition, RapidataJobManager, CostEstimate, - ActualCost, ) from .signal import RapidataSignal, RapidataSignalManager from .validation import ValidationSetManager, RapidataValidationSet, Box diff --git a/src/rapidata/rapidata_client/job/__init__.py b/src/rapidata/rapidata_client/job/__init__.py index c059a6cad..bf703542a 100644 --- a/src/rapidata/rapidata_client/job/__init__.py +++ b/src/rapidata/rapidata_client/job/__init__.py @@ -1,4 +1,4 @@ from .rapidata_job_definition import RapidataJobDefinition from .rapidata_job_manager import RapidataJobManager from .rapidata_job import RapidataJob -from .cost import CostEstimate, ActualCost +from .cost import CostEstimate diff --git a/src/rapidata/rapidata_client/job/cost.py b/src/rapidata/rapidata_client/job/cost.py index 5686ded8d..02875e522 100644 --- a/src/rapidata/rapidata_client/job/cost.py +++ b/src/rapidata/rapidata_client/job/cost.py @@ -20,34 +20,28 @@ T = TypeVar("T") -# The estimate endpoints answer 409 while the first batch of rapids is still -# being created and priced; once that batch exists the estimate is available. +# The estimate is not priced instantly after a job is created; the endpoint +# answers 409 until it becomes available. _ESTIMATE_NOT_READY_STATUS = 409 - -# The first batch is ~5000 rapids, so the estimate is usually ready within a -# couple of minutes; poll up to this long before giving up. DEFAULT_ESTIMATE_TIMEOUT = 300.0 DEFAULT_ESTIMATE_POLL_INTERVAL = 5.0 @dataclass(frozen=True) class CostEstimate: - """An approximate cost estimate for a job or job definition. + """An approximate estimate of what a job will cost to run to completion. - The estimate is not exact: the backend prices a sample of the rapids that - have been created so far (the job's first batch) and scales that per-response - cost up to the total number of responses the job requires. The real cost can - differ once every rapid has been priced. + This is an estimate, not the final bill: it is based on a sample of the + job's tasks and scaled to the total number of responses requested, so the + amount you are actually charged can differ. Attributes: - estimated_cost: The estimated total cost of running to completion. - cost_per_response: The representative per-response cost the estimate is based on. - datapoint_count: The number of datapoints in the dataset. - required_responses: The total number of responses the referee requires to complete. + estimated_cost: The estimated total cost of running the job to completion. + datapoint_count: The number of datapoints the job will label. + required_responses: The total number of responses the job collects to complete. """ estimated_cost: float - cost_per_response: float datapoint_count: int required_responses: int @@ -61,38 +55,22 @@ def _from_model( ) -> CostEstimate: return cls( estimated_cost=model.estimated_cost, - cost_per_response=model.cost_per_response, datapoint_count=model.datapoint_count, required_responses=model.required_responses, ) -@dataclass(frozen=True) -class ActualCost: - """The billed cost of a job, derived from its billing group. - - Attributes: - net_cost: The billed cost after any discount. - gross_cost: The cost before any discount. - response_count: The number of responses attributed to the job. - """ - - net_cost: float - gross_cost: float - response_count: int - - def _poll_for_cost_estimate( fetch: Callable[[], T], *, timeout: float, interval: float, ) -> T: - """Call ``fetch`` until it returns, polling while the estimate is not ready. + """Call ``fetch`` until it returns, retrying while the estimate is not ready. - The estimate endpoints return HTTP 409 while the job's first batch of rapids - is still being created and priced. That transient status is retried until the - estimate becomes available; any other error is raised immediately. + The estimate endpoints return HTTP 409 until the estimate has been priced. + That transient status is retried until it becomes available; any other error + is raised immediately. Raises: TimeoutError: If the estimate is still not available after ``timeout`` seconds. @@ -107,9 +85,8 @@ def _poll_for_cost_estimate( raise if monotonic() >= deadline: raise TimeoutError( - "Cost estimate was not available after " - f"{timeout:.0f}s. The job's rapids may still be getting " - "created and priced - try again shortly." + f"Cost estimate was not available after {timeout:.0f}s - " + "try again shortly." ) from e logger.debug("Cost estimate not ready yet (409), polling...") sleep(interval) diff --git a/src/rapidata/rapidata_client/job/rapidata_job.py b/src/rapidata/rapidata_client/job/rapidata_job.py index 272d4266e..129454f9b 100644 --- a/src/rapidata/rapidata_client/job/rapidata_job.py +++ b/src/rapidata/rapidata_client/job/rapidata_job.py @@ -20,7 +20,6 @@ suppress_rapidata_error_logging, ) from rapidata.rapidata_client.job.cost import ( - ActualCost, CostEstimate, DEFAULT_ESTIMATE_POLL_INTERVAL, DEFAULT_ESTIMATE_TIMEOUT, @@ -162,16 +161,11 @@ def pipeline_id(self) -> str: @property def estimated_cost(self) -> CostEstimate: - """An approximate cost estimate for running this job to completion. + """An approximate estimate of what this job will cost to run to completion. - The estimate is not exact: the backend prices a sample of the rapids - created so far (the job's first batch) and scales that per-response cost - up to the total number of responses the job requires. The real cost can - differ once every rapid has been priced. - - Right after a job is created the estimate is not yet available (the first - batch of rapids is still being created and priced); this call polls until - it becomes available. + This is an estimate, not the final bill - see :class:`CostEstimate`. The + estimate is priced shortly after the job is created; this call waits for + it to become available. Raises: TimeoutError: If the estimate is still not available after a few minutes. @@ -188,57 +182,6 @@ def estimated_cost(self) -> CostEstimate: self.__estimated_cost = CostEstimate._from_model(model) return self.__estimated_cost - @property - def actual_cost(self) -> ActualCost | None: - """The billed cost of this job, or None if it has not been billed yet. - - Derived from the customer's billing groups; returns None while no billing - group exists for this job (e.g. the job has not produced billable - responses yet). Unlike :attr:`estimated_cost`, this reflects the real - amount charged. - """ - with tracer.start_as_current_span("RapidataJob.actual_cost"): - from rapidata.api_client.models.i_billing_group_model_audience_job_billing_group_model import ( - IBillingGroupModelAudienceJobBillingGroupModel, - ) - - # The endpoint ranks the customer's billing groups by cost descending - # and caps the page size at 100, so an unscoped scan of a cheap job - # pages through the customer's whole history to reach its group at the - # bottom of the ranking. Scoping to costs since the job was created - # drops every older group: the backend filters billable_hour by the - # start hour, and a job's responses are only billed at or after - # creation, so this narrows the scan without dropping any of its cost. - page = 1 - page_size = 100 - seen = 0 - while True: - result = ( - self._openapi_service.billing.billing_api.billing_costs_groups_get( - start_date=self.created_at, - page=page, - page_size=page_size, - ) - ) - for group in result.items: - instance = group.actual_instance - if ( - isinstance( - instance, IBillingGroupModelAudienceJobBillingGroupModel - ) - and instance.job_id == self.id - ): - return ActualCost( - net_cost=instance.net_cost, - gross_cost=instance.gross_cost, - response_count=instance.response_count, - ) - - seen += len(result.items) - if not result.items or seen >= result.total: - return None - page += 1 - def get_status(self) -> str: """ Gets the status of the job. diff --git a/src/rapidata/rapidata_client/job/rapidata_job_definition.py b/src/rapidata/rapidata_client/job/rapidata_job_definition.py index 769f6cda5..f17f0f346 100644 --- a/src/rapidata/rapidata_client/job/rapidata_job_definition.py +++ b/src/rapidata/rapidata_client/job/rapidata_job_definition.py @@ -34,16 +34,11 @@ def __init__( @property def estimated_cost(self) -> CostEstimate: - """An approximate cost estimate for running this definition to completion. + """An approximate estimate of what this job will cost to run to completion. - The estimate is not exact: the backend prices a sample of the rapids - created so far (the definition's first batch) and scales that per-response - cost up to the total number of responses the referee requires. The real - cost can differ once every rapid has been priced. - - Right after a definition is previewed the estimate is not yet available - (the first batch of rapids is still being created and priced); this call - polls until it becomes available. + This is an estimate, not the final bill - see :class:`CostEstimate`. The + estimate is priced shortly after the definition is created; this call + waits for it to become available. Raises: TimeoutError: If the estimate is still not available after a few minutes. diff --git a/src/rapidata/service/openapi_service.py b/src/rapidata/service/openapi_service.py index 8124bbc4b..9cdec414d 100644 --- a/src/rapidata/service/openapi_service.py +++ b/src/rapidata/service/openapi_service.py @@ -13,7 +13,6 @@ if TYPE_CHECKING: from rapidata.service.services.asset_service import AssetService - from rapidata.service.services.billing_service import BillingService from rapidata.service.services.order_service import OrderService from rapidata.service.services.flow_service import FlowService from rapidata.service.services.audience_service import AudienceService @@ -70,7 +69,6 @@ def __init__( logger.debug("RapidataApiClient initialized") self._asset: AssetService | None = None - self._billing: BillingService | None = None self._order: OrderService | None = None self._flow: FlowService | None = None self._audience: AudienceService | None = None @@ -155,13 +153,6 @@ def asset(self) -> AssetService: self._asset = AssetService(self.api_client) return self._asset - @property - def billing(self) -> BillingService: - if self._billing is None: - from rapidata.service.services.billing_service import BillingService - self._billing = BillingService(self.api_client) - return self._billing - @property def order(self) -> OrderService: if self._order is None: diff --git a/src/rapidata/service/services/__init__.py b/src/rapidata/service/services/__init__.py index d0dfc3c22..0cf244dd3 100644 --- a/src/rapidata/service/services/__init__.py +++ b/src/rapidata/service/services/__init__.py @@ -1,6 +1,5 @@ from rapidata.service.services.asset_service import AssetService from rapidata.service.services.audience_service import AudienceService -from rapidata.service.services.billing_service import BillingService from rapidata.service.services.campaign_service import CampaignService from rapidata.service.services.dataset_service import DatasetService from rapidata.service.services.flow_service import FlowService @@ -15,7 +14,6 @@ __all__ = [ "AssetService", "AudienceService", - "BillingService", "CampaignService", "DatasetService", "FlowService", diff --git a/src/rapidata/service/services/billing_service.py b/src/rapidata/service/services/billing_service.py deleted file mode 100644 index 52a70cf71..000000000 --- a/src/rapidata/service/services/billing_service.py +++ /dev/null @@ -1,21 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from rapidata.api_client.api.billing_api import BillingApi - from rapidata.rapidata_client.api.rapidata_api_client import RapidataApiClient - - -class BillingService: - def __init__(self, api_client: RapidataApiClient) -> None: - self._api_client = api_client - self._billing_api: BillingApi | None = None - - @property - def billing_api(self) -> BillingApi: - if self._billing_api is None: - from rapidata.api_client.api.billing_api import BillingApi - - self._billing_api = BillingApi(self._api_client) - return self._billing_api From 98a51b9d47ed28dde6205a853c9d4acc0936d6a8 Mon Sep 17 00:00:00 2001 From: RapidPoseidon Date: Wed, 8 Jul 2026 15:47:26 +0000 Subject: [PATCH 4/5] fix(job): poll estimated_cost when the endpoint returns an empty body Immediately after a definition/job is created the estimate is not yet priced. Besides the documented 409, the endpoint can signal this with a success + empty body, which the generated client returns as None. That None flowed into CostEstimate._from_model and raised AttributeError: 'NoneType' object has no attribute 'estimated_cost'. Treat a None result as "not ready" and keep polling until the estimate is available (or the timeout elapses), same as the 409 case. Co-Authored-By: Claude Opus 4.8 Co-Authored-By: lino --- src/rapidata/rapidata_client/job/cost.py | 32 ++++++++++++++---------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/rapidata/rapidata_client/job/cost.py b/src/rapidata/rapidata_client/job/cost.py index 02875e522..b2b9e5f35 100644 --- a/src/rapidata/rapidata_client/job/cost.py +++ b/src/rapidata/rapidata_client/job/cost.py @@ -61,16 +61,17 @@ def _from_model( def _poll_for_cost_estimate( - fetch: Callable[[], T], + fetch: Callable[[], T | None], *, timeout: float, interval: float, ) -> T: - """Call ``fetch`` until it returns, retrying while the estimate is not ready. + """Call ``fetch`` until the estimate is available, retrying while it is not. - The estimate endpoints return HTTP 409 until the estimate has been priced. - That transient status is retried until it becomes available; any other error - is raised immediately. + Until the estimate has been priced the endpoint signals "not ready" in two + ways: an HTTP 409, or a success with an empty body (which the generated + client returns as ``None``). Both are retried until a result is available; + any other error is raised immediately. Raises: TimeoutError: If the estimate is still not available after ``timeout`` seconds. @@ -79,14 +80,19 @@ def _poll_for_cost_estimate( while True: try: with suppress_rapidata_error_logging(): - return fetch() + result = fetch() except RapidataError as e: if e.status_code != _ESTIMATE_NOT_READY_STATUS: raise - if monotonic() >= deadline: - raise TimeoutError( - f"Cost estimate was not available after {timeout:.0f}s - " - "try again shortly." - ) from e - logger.debug("Cost estimate not ready yet (409), polling...") - sleep(interval) + result = None + + if result is not None: + return result + + if monotonic() >= deadline: + raise TimeoutError( + f"Cost estimate was not available after {timeout:.0f}s - " + "try again shortly." + ) + logger.debug("Cost estimate not ready yet, polling...") + sleep(interval) From 153fefcebc17992f2c2f67219e258f16c4925871 Mon Sep 17 00:00:00 2001 From: RapidPoseidon Date: Wed, 8 Jul 2026 15:54:50 +0000 Subject: [PATCH 5/5] docs(job): add a cost estimates guide Document estimated_cost on job definitions and jobs: how to read it, the CostEstimate fields, and the caveat that it is an estimate rather than the final bill. Register the page in the Guides nav. Co-Authored-By: Claude Opus 4.8 Co-Authored-By: lino --- docs/cost_estimates.md | 48 ++++++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 2 ++ 2 files changed, 50 insertions(+) create mode 100644 docs/cost_estimates.md diff --git a/docs/cost_estimates.md b/docs/cost_estimates.md new file mode 100644 index 000000000..7a8983af3 --- /dev/null +++ b/docs/cost_estimates.md @@ -0,0 +1,48 @@ +# Cost Estimates + +Before you commit to a large labeling run, you can ask Rapidata what a job is expected to cost. Every job definition and every running job exposes an `estimated_cost` property that returns a `CostEstimate`. + +## Getting an Estimate + +You can read the estimate straight from a job definition, before assigning it to an audience — useful for checking the cost of a run before you launch it: + +```py +from rapidata import RapidataClient + +client = RapidataClient() + +job_definition = client.job.create_compare_job_definition( + name="Example Image Prompt Alignment", + instruction="Which image matches the description better?", + datapoints=[ + ["https://assets.rapidata.ai/midjourney-5.2_37_3.jpg", + "https://assets.rapidata.ai/flux-1-pro_37_0.jpg"] + ], + contexts=["A small blue book sitting on a large red book."], +) + +estimate = job_definition.estimated_cost # (1)! +print(f"About {estimate.estimated_cost} for {estimate.required_responses} responses") +``` + +1. The estimate is priced shortly after the definition is created. Reading it right away blocks for a moment until it is ready, then returns. + +The same property is available on a job once it is running: + +```py +job = audience.assign_job(job_definition) +print(job.estimated_cost.estimated_cost) +``` + +## What the Estimate Contains + +A `CostEstimate` has three fields: + +| Field | Description | +|---|---| +| `estimated_cost` | The estimated total cost of running the job to completion, in your account's billing currency. | +| `datapoint_count` | The number of datapoints the job will label. | +| `required_responses` | The total number of responses the job collects to complete. | + +!!! note + This is an **estimate, not the final bill**. It is based on a sample of the job's tasks scaled to the number of responses requested, so the amount you are actually charged can differ. Features like [Early Stopping](confidence_stopping.md) can also lower the final cost by collecting fewer responses than the maximum. diff --git a/mkdocs.yml b/mkdocs.yml index 81079e358..f11b9a85d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -65,6 +65,7 @@ plugins: - understanding_the_results.md - error_handling.md - confidence_stopping.md + - cost_estimates.md - human_prompting.md - config.md Examples: @@ -128,6 +129,7 @@ nav: - Parameter Reference: job_definition_parameters.md - Understanding Results: understanding_the_results.md - Early Stopping: confidence_stopping.md + - Cost Estimates: cost_estimates.md - Instruction Design: human_prompting.md - Error Handling: error_handling.md - Logging & Config: config.md