From 837e98789008cb121e6e600d19e82408bbc3350b Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Sun, 18 Oct 2026 19:59:14 -0700 Subject: [PATCH 01/10] feat(sdk): soft-deprecate discover consensus and enhanced The platform now decides consensus itself and no longer runs AI result enhancement, so both discover request fields are ignored. The regenerated DiscoverParams drops them; passing either still sends it and raises a DeprecationWarning, scoped to discover so contacts consensus is untouched. CLI help for --consensus, --enhanced and --variance says what the server does now. --- .../discolike-cli/src/discolike_cli/bulk.py | 3 ++- .../src/discolike_cli/discover.py | 7 ++++--- .../src/discolike/_generated/requests.py | 18 +----------------- packages/discolike/src/discolike/_models.py | 11 +++++++++-- .../discolike/tests/test_requests_module.py | 13 +++++++++++++ 5 files changed, 29 insertions(+), 23 deletions(-) diff --git a/packages/discolike-cli/src/discolike_cli/bulk.py b/packages/discolike-cli/src/discolike_cli/bulk.py index 43b136d..bd75c92 100644 --- a/packages/discolike-cli/src/discolike_cli/bulk.py +++ b/packages/discolike-cli/src/discolike_cli/bulk.py @@ -158,7 +158,8 @@ def companies_command( employee_range: str | None = typer.Option(None, help="Employee count range filter."), revenue_range: str | None = typer.Option(None, help="Revenue range filter."), variance: str | None = typer.Option( - None, help="Result diversity: LOW, MID_LOW, MEDIUM, MID_HIGH, HIGH, UNRESTRICTED." + None, + help="How strictly the list stops at the end of the ICP's market: LOW stops earliest, HIGH latest, UNRESTRICTED never.", ), exclusion_query_id: list[str] | None = typer.Option(None, help=EXCLUSION_QUERY_ID_HELP), param: list[str] | None = typer.Option(None, "--param", help=PARAM_HELP), diff --git a/packages/discolike-cli/src/discolike_cli/discover.py b/packages/discolike-cli/src/discolike_cli/discover.py index 7d743f8..c206e02 100644 --- a/packages/discolike-cli/src/discolike_cli/discover.py +++ b/packages/discolike-cli/src/discolike_cli/discover.py @@ -105,10 +105,11 @@ def discover_command( max_digital_footprint: int | None = typer.Option(None, help="Maximum digital footprint score."), min_similarity: int | None = typer.Option(None, help="Minimum similarity score to include (0-99)."), variance: str | None = typer.Option( - None, help="Result diversity control: LOW, MID_LOW, MEDIUM, MID_HIGH, HIGH, UNRESTRICTED." + None, + help="How strictly the list stops at the end of the ICP's market: LOW stops earliest, HIGH latest, UNRESTRICTED never.", ), consensus: int | None = typer.Option( - None, help="Number of top results for the consensus search vector (1-20); higher reduces specificity." + None, help="Deprecated, no-op. Discover applies consensus itself to single-domain lookalike searches." ), redirect: bool | None = typer.Option(None, "--redirect/--no-redirect", help=REDIRECT_HELP), exclude_leadgen: bool | None = typer.Option( @@ -118,7 +119,7 @@ def discover_command( None, "--retrieval/--no-retrieval", help="Enable page data retrieval using the Extract API." ), enhanced: bool | None = typer.Option( - None, "--enhanced/--no-enhanced", help="Enable AI-powered result enhancement for improved relevance." + None, "--enhanced/--no-enhanced", help="Deprecated, no-op. Discover no longer adds AI-suggested domains." ), include_search_domains: bool | None = typer.Option( None, diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index 11a159d..a38e76b 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -2071,13 +2071,6 @@ class DiscoverParams(DiscolikeRequest): title="Retrieval", ), ] = False - enhanced: Annotated[ - bool | None, - Field( - description="Enable AI-powered result enhancement for improved relevance.", - title="Enhanced", - ), - ] = False include_search_domains: Annotated[ bool | None, Field( @@ -2094,15 +2087,6 @@ class DiscoverParams(DiscolikeRequest): title="Max Records", ), ] = 100 - consensus: Annotated[ - int | None, - Field( - description="Number of top results for consensus search vector (1-20). Higher values reduce specificity.", - ge=1, - le=20, - title="Consensus", - ), - ] = 1 min_similarity: Annotated[ int | None, Field( @@ -2115,7 +2099,7 @@ class DiscoverParams(DiscolikeRequest): variance: Annotated[ Literal["LOW", "MID_LOW", "MEDIUM", "MID_HIGH", "HIGH", "UNRESTRICTED"] | None, Field( - description="Result diversity control: LOW, MID_LOW, MEDIUM, MID_HIGH, HIGH, UNRESTRICTED.", + description="Where the list stops: LOW earliest (rows ~70% fit), MID_LOW, MEDIUM, MID_HIGH, HIGH latest (~30%). UNRESTRICTED never stops.", title="Variance", ), ] = "UNRESTRICTED" diff --git a/packages/discolike/src/discolike/_models.py b/packages/discolike/src/discolike/_models.py index a806b7f..2f18796 100644 --- a/packages/discolike/src/discolike/_models.py +++ b/packages/discolike/src/discolike/_models.py @@ -30,6 +30,12 @@ "contact search no longer filters on email verification; verify emails instead." ), } +DEPRECATED_MODEL_FIELDS: dict[str, dict[str, str]] = { + "DiscoverParams": { + "consensus": "consensus is deprecated and ignored: discover applies consensus itself to single-domain lookalike searches.", + "enhanced": "enhanced is deprecated and ignored: discover no longer adds AI-suggested domains.", + }, +} class DiscolikeModel(pydantic.BaseModel): @@ -78,8 +84,9 @@ def _validate_geo_shapes(self) -> DiscolikeRequest: @classmethod def _warn_deprecated_fields(cls, data: object) -> object: if isinstance(data, dict): - for name in sorted(DEPRECATED_REQUEST_FIELDS.keys() & data.keys()): - warnings.warn(DEPRECATED_REQUEST_FIELDS[name], DeprecationWarning, stacklevel=2) + deprecated = DEPRECATED_REQUEST_FIELDS | DEPRECATED_MODEL_FIELDS.get(cls.__name__, {}) + for name in sorted(deprecated.keys() & data.keys()): + warnings.warn(deprecated[name], DeprecationWarning, stacklevel=2) return data # Mirrors the platform's text_or_intake rule on ProspectingMessageRequest, which the spec cannot express. diff --git a/packages/discolike/tests/test_requests_module.py b/packages/discolike/tests/test_requests_module.py index 21d52f4..660f93e 100644 --- a/packages/discolike/tests/test_requests_module.py +++ b/packages/discolike/tests/test_requests_module.py @@ -53,6 +53,19 @@ def test_email_validated_is_deprecated_but_still_accepted(model: type[DiscolikeR assert request.to_wire() == {"email_validated": True} +@pytest.mark.parametrize("name", ["consensus", "enhanced"]) +def test_discover_consensus_and_enhanced_are_deprecated_but_still_sent(name: str) -> None: + with pytest.warns(DeprecationWarning, match=f"{name} is deprecated"): + request = DiscoverParams.model_validate({name: 2 if name == "consensus" else True}) + assert name in request.to_wire() + + +def test_contacts_consensus_is_not_deprecated() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert ContactsSearchParams(consensus=2).to_wire() == {"consensus": 2} + + def test_unset_email_validated_does_not_warn() -> None: with warnings.catch_warnings(): warnings.simplefilter("error") From d8d3c52566fd177e105234c76be7323a5148777e Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Sun, 18 Oct 2026 20:24:53 -0700 Subject: [PATCH 02/10] fix(sdk): declare the open question on prospecting runs The dev spec's ProspectingRunResponse gained question, the ProspectingOpenQuestion a needs_input run waits on; without it the contract check against dev fails for every PR. --- .../discolike/src/discolike/resources/prospecting.py | 11 +++++++++++ packages/discolike/tests/test_prospecting.py | 9 +++++++++ 2 files changed, 20 insertions(+) diff --git a/packages/discolike/src/discolike/resources/prospecting.py b/packages/discolike/src/discolike/resources/prospecting.py index 5c34df8..7740d40 100644 --- a/packages/discolike/src/discolike/resources/prospecting.py +++ b/packages/discolike/src/discolike/resources/prospecting.py @@ -103,6 +103,14 @@ class ProspectingInFlight(DiscolikeModel): progress: int | None = None +class ProspectingOpenQuestion(DiscolikeModel): + question_seq: int | None + reason: str + content: str + replies: list[str] = Field(default_factory=list) + estimate: dict[str, Any] | None = None + + class ProspectingRunSummary(DiscolikeModel): run_id: UUID status: ProspectingStatus @@ -145,6 +153,9 @@ class ProspectingRun(DiscolikeModel): actions_used: int = 0 max_actions: int error: str | None = None + question: ProspectingOpenQuestion | None = Field( + default=None, description="What a needs_input run waits on; replies are exact labels, empty for free text." + ) title: str | None = None plan_version: int = 1 approved_plan_version: int | None = None diff --git a/packages/discolike/tests/test_prospecting.py b/packages/discolike/tests/test_prospecting.py index 95a9957..0c48d9a 100644 --- a/packages/discolike/tests/test_prospecting.py +++ b/packages/discolike/tests/test_prospecting.py @@ -735,3 +735,12 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client, pytest.raises(ValidationError): client.prospecting.answer_intake(RUN_ID, {}, idempotency_key="k") assert seen == [] + + +def test_run_parses_the_open_question_of_a_needs_input_run() -> None: + question = {"question_seq": 4, "reason": "plan_review", "content": "Approve the plan?", "replies": ["Approve"]} + run = module.ProspectingRun.model_validate(run_payload() | {"question": question}) + assert run.question == module.ProspectingOpenQuestion( + question_seq=4, reason="plan_review", content="Approve the plan?", replies=["Approve"] + ) + assert module.ProspectingRun.model_validate(run_payload()).question is None From 19ba64d249ead76d0aa6c81e780e42dd4b316322 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Sun, 18 Oct 2026 20:30:16 -0700 Subject: [PATCH 03/10] fix(cli): list every variance level in the --variance help --- packages/discolike-cli/src/discolike_cli/bulk.py | 2 +- packages/discolike-cli/src/discolike_cli/discover.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/discolike-cli/src/discolike_cli/bulk.py b/packages/discolike-cli/src/discolike_cli/bulk.py index bd75c92..0025b52 100644 --- a/packages/discolike-cli/src/discolike_cli/bulk.py +++ b/packages/discolike-cli/src/discolike_cli/bulk.py @@ -159,7 +159,7 @@ def companies_command( revenue_range: str | None = typer.Option(None, help="Revenue range filter."), variance: str | None = typer.Option( None, - help="How strictly the list stops at the end of the ICP's market: LOW stops earliest, HIGH latest, UNRESTRICTED never.", + help="Where the list stops: LOW (earliest), MID_LOW, MEDIUM, MID_HIGH, HIGH (latest), UNRESTRICTED (never).", ), exclusion_query_id: list[str] | None = typer.Option(None, help=EXCLUSION_QUERY_ID_HELP), param: list[str] | None = typer.Option(None, "--param", help=PARAM_HELP), diff --git a/packages/discolike-cli/src/discolike_cli/discover.py b/packages/discolike-cli/src/discolike_cli/discover.py index c206e02..a1537bb 100644 --- a/packages/discolike-cli/src/discolike_cli/discover.py +++ b/packages/discolike-cli/src/discolike_cli/discover.py @@ -106,7 +106,7 @@ def discover_command( min_similarity: int | None = typer.Option(None, help="Minimum similarity score to include (0-99)."), variance: str | None = typer.Option( None, - help="How strictly the list stops at the end of the ICP's market: LOW stops earliest, HIGH latest, UNRESTRICTED never.", + help="Where the list stops: LOW (earliest), MID_LOW, MEDIUM, MID_HIGH, HIGH (latest), UNRESTRICTED (never).", ), consensus: int | None = typer.Option( None, help="Deprecated, no-op. Discover applies consensus itself to single-domain lookalike searches." From a8178fac097e70d7a999eb89512acb37d0090624 Mon Sep 17 00:00:00 2001 From: Yegor Dolgopolov Date: Mon, 12 Oct 2026 23:50:26 -0700 Subject: [PATCH 04/10] Raise InsufficientBalanceError for a 403 INSUFFICIENT_BALANCE --- CHANGELOG.md | 4 ++ README.md | 2 +- .../src/discolike_cli/_output.py | 6 +++ packages/discolike-cli/tests/test_output.py | 14 +++++++ packages/discolike/src/discolike/__init__.py | 2 + .../discolike/src/discolike/_exceptions.py | 39 +++++++++++++++++++ packages/discolike/tests/test_exceptions.py | 39 +++++++++++++++++++ 7 files changed, 105 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c63d9fa..6ffcb70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- SDK/CLI: the API now prices every billed request before it runs and refuses one the remaining balance cannot cover with a 403 whose `detail` is an object (`code: "INSUFFICIENT_BALANCE"`, `message`, `remaining_usd`, `max_affordable_records`, `requested_records`, `upgrade_url`). The SDK raises it as the new `InsufficientBalanceError`, a `PlanAccessError` subclass, so existing `except PlanAccessError` handlers still catch it; its message is the server's `message`, and `max_affordable_records`, `remaining_usd`, `requested_records` and `upgrade_url` are attributes, so a caller can retry with at most `max_affordable_records` records. Any other object `detail` with a `message` now uses that message instead of the raw JSON. The CLI exits 3 with `"code": "insufficient_balance"` and adds `max_affordable_records` and `remaining_usd` to the error JSON. Older SDK versions raise a plain `PlanAccessError` whose message is the JSON body. + ## 0.6.0 (2026-10-12) - SDK/CLI: the API now limits how much work one account can have queued or running: email lookups (2,000 times the plan multiplier) and three DiscoGen, bulk match, contact bulk match and segment tasks. A submit over a limit is answered 429 with `Retry-After: 60`, which the SDK already waits out and retries, so with the default `max_retries=3` the call blocks for up to three minutes and then raises `RateLimitError` carrying the count and the limit in its message. In the SDK, `Discolike(max_retries=0)` gets the error at once; the CLI has no retry option, so its commands wait and then exit 4. No SDK change; older versions behave the same. diff --git a/README.md b/README.md index f179a66..1963798 100644 --- a/README.md +++ b/README.md @@ -370,7 +370,7 @@ except ValidationError as err: ... ``` -`AuthenticationError`, `PlanAccessError`, `NotFoundError`, `ServerError`, and `APIConnectionError` cover the rest. Transient failures are retried automatically (3 attempts by default). +`AuthenticationError`, `PlanAccessError`, `NotFoundError`, `ServerError`, and `APIConnectionError` cover the rest. A request the account's remaining balance cannot cover raises `InsufficientBalanceError` (a `PlanAccessError`), whose `max_affordable_records` is the largest record count that would fit. Transient failures are retried automatically (3 attempts by default). ```python import pydantic diff --git a/packages/discolike-cli/src/discolike_cli/_output.py b/packages/discolike-cli/src/discolike_cli/_output.py index 0bf6a94..27e1456 100644 --- a/packages/discolike-cli/src/discolike_cli/_output.py +++ b/packages/discolike-cli/src/discolike_cli/_output.py @@ -20,6 +20,7 @@ from discolike._exceptions import APIConnectionError from discolike._exceptions import AuthenticationError from discolike._exceptions import DiscolikeError +from discolike._exceptions import InsufficientBalanceError from discolike._exceptions import JobFailedError from discolike._exceptions import JobTimeoutError from discolike._exceptions import NotFoundError @@ -38,6 +39,7 @@ ValidationError: 2, AuthenticationError: 3, PlanAccessError: 3, + InsufficientBalanceError: 3, RateLimitError: 4, APIConnectionError: 5, NotFoundError: 6, @@ -51,6 +53,7 @@ ValidationError: "validation_error", AuthenticationError: "auth_invalid", PlanAccessError: "plan_access", + InsufficientBalanceError: "insufficient_balance", RateLimitError: "rate_limited", APIConnectionError: "network_error", NotFoundError: "not_found", @@ -150,6 +153,9 @@ def fail(exc: DiscolikeError) -> typer.Exit: } if isinstance(exc, RateLimitError) and exc.retry_after is not None: payload["retry_after"] = exc.retry_after + if isinstance(exc, InsufficientBalanceError): + payload["max_affordable_records"] = exc.max_affordable_records + payload["remaining_usd"] = exc.remaining_usd print(json.dumps(payload), file=sys.stderr) return typer.Exit(code=exit_code) diff --git a/packages/discolike-cli/tests/test_output.py b/packages/discolike-cli/tests/test_output.py index 0cbd44a..e4df92f 100644 --- a/packages/discolike-cli/tests/test_output.py +++ b/packages/discolike-cli/tests/test_output.py @@ -11,6 +11,7 @@ from discolike import APIConnectionError from discolike import AuthenticationError from discolike import DiscolikeError +from discolike import InsufficientBalanceError from discolike import JobFailedError from discolike import JobTimeoutError from discolike import NotFoundError @@ -154,6 +155,7 @@ def test_emit_table_flag_falls_back_to_json_when_no_scalar_columns(capsys: pytes (ValidationError("bad input"), 2), (AuthenticationError("no key"), 3), (RateLimitError("slow down", retry_after=3), 4), + (InsufficientBalanceError("too costly", max_affordable_records=10), 3), (ServerError("boom"), 1), ], ) @@ -186,6 +188,18 @@ def test_fail_rate_limit_includes_retry_after(capsys: pytest.CaptureFixture[str] assert payload["retry_after"] == 3.5 +def test_fail_insufficient_balance_includes_max_affordable_records(capsys: pytest.CaptureFixture[str]) -> None: + exc = InsufficientBalanceError( + "too costly", status_code=403, max_affordable_records=60, remaining_usd=3.0, requested_records=250 + ) + result = fail(exc) + assert result.exit_code == 3 + payload = json.loads(capsys.readouterr().err) + assert payload["code"] == "insufficient_balance" + assert payload["max_affordable_records"] == 60 + assert payload["remaining_usd"] == 3.0 + + def test_fail_rate_limit_without_retry_after_omits_key(capsys: pytest.CaptureFixture[str]) -> None: exc = RateLimitError("slow down", status_code=429, retry_after=None) fail(exc) diff --git a/packages/discolike/src/discolike/__init__.py b/packages/discolike/src/discolike/__init__.py index dfe1eaa..c5c1efd 100644 --- a/packages/discolike/src/discolike/__init__.py +++ b/packages/discolike/src/discolike/__init__.py @@ -5,6 +5,7 @@ from discolike._exceptions import APIConnectionError from discolike._exceptions import AuthenticationError from discolike._exceptions import DiscolikeError +from discolike._exceptions import InsufficientBalanceError from discolike._exceptions import JobFailedError from discolike._exceptions import JobTimeoutError from discolike._exceptions import NotFoundError @@ -51,6 +52,7 @@ "EmailJobResult", "EnumerationMatch", "EnumerationOutput", + "InsufficientBalanceError", "Job", "JobFailedError", "JobStatus", diff --git a/packages/discolike/src/discolike/_exceptions.py b/packages/discolike/src/discolike/_exceptions.py index 55f590a..5c55927 100644 --- a/packages/discolike/src/discolike/_exceptions.py +++ b/packages/discolike/src/discolike/_exceptions.py @@ -25,6 +25,30 @@ class AuthenticationError(DiscolikeError): ... class PlanAccessError(DiscolikeError): ... +class InsufficientBalanceError(PlanAccessError): + """A billed request priced above the account's remaining balance (403 ``INSUFFICIENT_BALANCE``). + + Retry with at most ``max_affordable_records`` records, or top up at ``upgrade_url``. + """ + + def __init__( + self, + message: str, + *, + status_code: int | None = None, + payload: Any = None, # noqa: ANN401 -- decoded JSON body, shape is server-defined + max_affordable_records: int | None = None, + remaining_usd: float | None = None, + requested_records: int | None = None, + upgrade_url: str | None = None, + ) -> None: + super().__init__(message, status_code=status_code, payload=payload) + self.max_affordable_records = max_affordable_records + self.remaining_usd = remaining_usd + self.requested_records = requested_records + self.upgrade_url = upgrade_url + + class ValidationError(DiscolikeError): ... @@ -56,6 +80,8 @@ def __init__( self.retry_after = retry_after +INSUFFICIENT_BALANCE_CODE = "INSUFFICIENT_BALANCE" + _STATUS_MAP: dict[int, type[DiscolikeError]] = { 400: ValidationError, 401: AuthenticationError, @@ -75,6 +101,8 @@ def _extract_message(response: httpx2.Response) -> tuple[str, Any]: detail = payload.get("detail") if isinstance(payload, dict) else None if isinstance(detail, str): return detail, payload + if isinstance(detail, dict) and isinstance(detail.get("message"), str): + return detail["message"], payload if isinstance(detail, list): parts = [ f"{'.'.join(str(x) for x in item.get('loc', []))}: {item.get('msg', '')}" @@ -94,5 +122,16 @@ def raise_for_status(response: httpx2.Response) -> None: header = response.headers.get("Retry-After") retry_after = float(header) if header and header.replace(".", "", 1).isdigit() else None raise RateLimitError(message, status_code=429, payload=payload, retry_after=retry_after) + detail = payload.get("detail") if isinstance(payload, dict) else None + if isinstance(detail, dict) and detail.get("code") == INSUFFICIENT_BALANCE_CODE: + raise InsufficientBalanceError( + message, + status_code=response.status_code, + payload=payload, + max_affordable_records=detail.get("max_affordable_records"), + remaining_usd=detail.get("remaining_usd"), + requested_records=detail.get("requested_records"), + upgrade_url=detail.get("upgrade_url"), + ) exc_type = _STATUS_MAP.get(response.status_code, ServerError if response.status_code >= 500 else DiscolikeError) raise exc_type(message, status_code=response.status_code, payload=payload) diff --git a/packages/discolike/tests/test_exceptions.py b/packages/discolike/tests/test_exceptions.py index 4be2323..20e2353 100644 --- a/packages/discolike/tests/test_exceptions.py +++ b/packages/discolike/tests/test_exceptions.py @@ -5,6 +5,7 @@ from discolike import AuthenticationError from discolike import DiscolikeError +from discolike import InsufficientBalanceError from discolike import NotFoundError from discolike import PlanAccessError from discolike import RateLimitError @@ -69,3 +70,41 @@ def test_detail_list_with_non_dict_items() -> None: def test_all_are_discolike_errors() -> None: assert issubclass(RateLimitError, DiscolikeError) + + +INSUFFICIENT_BALANCE_DETAIL = { + "code": "INSUFFICIENT_BALANCE", + "message": "This request could cost up to $40.20 but the account has $12.50 left this cycle.", + "remaining_usd": 12.5, + "max_affordable_records": 3075, + "requested_records": 10000, + "upgrade_url": "https://app.discolike.com/account/management/plans", +} + + +def test_insufficient_balance_403_raises_typed_error_with_fields() -> None: + with pytest.raises(InsufficientBalanceError) as exc_info: + raise_for_status(_response(403, json_body={"detail": INSUFFICIENT_BALANCE_DETAIL})) + err = exc_info.value + assert isinstance(err, PlanAccessError) + assert str(err) == INSUFFICIENT_BALANCE_DETAIL["message"] + assert err.status_code == 403 + assert err.max_affordable_records == 3075 + assert err.remaining_usd == 12.5 + assert err.requested_records == 10000 + assert err.upgrade_url == "https://app.discolike.com/account/management/plans" + assert err.payload == {"detail": INSUFFICIENT_BALANCE_DETAIL} + + +def test_other_dict_detail_uses_its_message() -> None: + body = {"detail": {"code": "SOMETHING_ELSE", "message": "not allowed"}} + with pytest.raises(PlanAccessError) as exc_info: + raise_for_status(_response(403, json_body=body)) + assert type(exc_info.value) is PlanAccessError + assert str(exc_info.value) == "not allowed" + + +def test_plain_403_stays_plan_access_error() -> None: + with pytest.raises(PlanAccessError) as exc_info: + raise_for_status(_response(403)) + assert type(exc_info.value) is PlanAccessError From eb56a4e66a71beb0fc452bf9311d8d9510f94ad6 Mon Sep 17 00:00:00 2001 From: Yegor Dolgopolov Date: Tue, 13 Oct 2026 00:24:56 -0700 Subject: [PATCH 05/10] Raise InsufficientBalanceError only for a 403 and name the size unit --- CHANGELOG.md | 2 +- README.md | 2 +- packages/discolike/src/discolike/_exceptions.py | 6 ++++-- packages/discolike/tests/test_exceptions.py | 15 ++++++++++++--- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ffcb70..c6f2a72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- SDK/CLI: the API now prices every billed request before it runs and refuses one the remaining balance cannot cover with a 403 whose `detail` is an object (`code: "INSUFFICIENT_BALANCE"`, `message`, `remaining_usd`, `max_affordable_records`, `requested_records`, `upgrade_url`). The SDK raises it as the new `InsufficientBalanceError`, a `PlanAccessError` subclass, so existing `except PlanAccessError` handlers still catch it; its message is the server's `message`, and `max_affordable_records`, `remaining_usd`, `requested_records` and `upgrade_url` are attributes, so a caller can retry with at most `max_affordable_records` records. Any other object `detail` with a `message` now uses that message instead of the raw JSON. The CLI exits 3 with `"code": "insufficient_balance"` and adds `max_affordable_records` and `remaining_usd` to the error JSON. Older SDK versions raise a plain `PlanAccessError` whose message is the JSON body. +- SDK/CLI: the API now prices every billed request before it runs and refuses one the remaining balance cannot cover with a 403 whose `detail` is an object (`code: "INSUFFICIENT_BALANCE"`, `message`, `remaining_usd`, `max_affordable_records`, `requested_records`, `upgrade_url`). The SDK raises it as the new `InsufficientBalanceError`, a `PlanAccessError` subclass, so existing `except PlanAccessError` handlers still catch it; its message is the server's `message`, and `max_affordable_records`, `remaining_usd`, `requested_records` and `upgrade_url` are attributes, counted in the request's own size unit (records, or contacts, companies, domains, rows, queries or personas for requests sized by those; the message names it), so a caller can retry at `max_affordable_records`. Only a 403 carrying that code raises it. A request the API could not price answers 503 (`ServerError`), which the SDK retries automatically on GET calls. Any other object `detail` with a `message` now uses that message instead of the raw JSON. The CLI exits 3 with `"code": "insufficient_balance"` and adds `max_affordable_records` and `remaining_usd` to the error JSON. Older SDK versions raise a plain `PlanAccessError` whose message is the JSON body. ## 0.6.0 (2026-10-12) diff --git a/README.md b/README.md index 1963798..8f60293 100644 --- a/README.md +++ b/README.md @@ -370,7 +370,7 @@ except ValidationError as err: ... ``` -`AuthenticationError`, `PlanAccessError`, `NotFoundError`, `ServerError`, and `APIConnectionError` cover the rest. A request the account's remaining balance cannot cover raises `InsufficientBalanceError` (a `PlanAccessError`), whose `max_affordable_records` is the largest record count that would fit. Transient failures are retried automatically (3 attempts by default). +`AuthenticationError`, `PlanAccessError`, `NotFoundError`, `ServerError`, and `APIConnectionError` cover the rest. A request the account's remaining balance cannot cover raises `InsufficientBalanceError` (a `PlanAccessError`), whose `max_affordable_records` is the largest size that would fit, counted in the request's own unit (the message names it: records, contacts, domains, rows, queries or personas). Transient failures are retried automatically (3 attempts by default). ```python import pydantic diff --git a/packages/discolike/src/discolike/_exceptions.py b/packages/discolike/src/discolike/_exceptions.py index 5c55927..3ea096a 100644 --- a/packages/discolike/src/discolike/_exceptions.py +++ b/packages/discolike/src/discolike/_exceptions.py @@ -28,7 +28,9 @@ class PlanAccessError(DiscolikeError): ... class InsufficientBalanceError(PlanAccessError): """A billed request priced above the account's remaining balance (403 ``INSUFFICIENT_BALANCE``). - Retry with at most ``max_affordable_records`` records, or top up at ``upgrade_url``. + ``requested_records`` and ``max_affordable_records`` count in the request's own size unit + (records, contacts, companies, domains, rows, queries or personas; the message names it), so a + retry at ``max_affordable_records`` fits. A fixed-size lookup reports 0; top up at ``upgrade_url``. """ def __init__( @@ -123,7 +125,7 @@ def raise_for_status(response: httpx2.Response) -> None: retry_after = float(header) if header and header.replace(".", "", 1).isdigit() else None raise RateLimitError(message, status_code=429, payload=payload, retry_after=retry_after) detail = payload.get("detail") if isinstance(payload, dict) else None - if isinstance(detail, dict) and detail.get("code") == INSUFFICIENT_BALANCE_CODE: + if response.status_code == 403 and isinstance(detail, dict) and detail.get("code") == INSUFFICIENT_BALANCE_CODE: raise InsufficientBalanceError( message, status_code=response.status_code, diff --git a/packages/discolike/tests/test_exceptions.py b/packages/discolike/tests/test_exceptions.py index 20e2353..f0f46cb 100644 --- a/packages/discolike/tests/test_exceptions.py +++ b/packages/discolike/tests/test_exceptions.py @@ -74,8 +74,11 @@ def test_all_are_discolike_errors() -> None: INSUFFICIENT_BALANCE_DETAIL = { "code": "INSUFFICIENT_BALANCE", - "message": "This request could cost up to $40.20 but the account has $12.50 left this cycle.", - "remaining_usd": 12.5, + "message": ( + "This request could cost up to $140.70 but the account has $43.75 left this cycle. " + "Lower it to at most 3075 records, or upgrade or top up to continue." + ), + "remaining_usd": 43.75, "max_affordable_records": 3075, "requested_records": 10000, "upgrade_url": "https://app.discolike.com/account/management/plans", @@ -90,12 +93,18 @@ def test_insufficient_balance_403_raises_typed_error_with_fields() -> None: assert str(err) == INSUFFICIENT_BALANCE_DETAIL["message"] assert err.status_code == 403 assert err.max_affordable_records == 3075 - assert err.remaining_usd == 12.5 + assert err.remaining_usd == 43.75 assert err.requested_records == 10000 assert err.upgrade_url == "https://app.discolike.com/account/management/plans" assert err.payload == {"detail": INSUFFICIENT_BALANCE_DETAIL} +def test_insufficient_balance_code_on_another_status_is_not_a_balance_error() -> None: + with pytest.raises(ServerError) as exc_info: + raise_for_status(_response(500, json_body={"detail": INSUFFICIENT_BALANCE_DETAIL})) + assert not isinstance(exc_info.value, InsufficientBalanceError) + + def test_other_dict_detail_uses_its_message() -> None: body = {"detail": {"code": "SOMETHING_ELSE", "message": "not allowed"}} with pytest.raises(PlanAccessError) as exc_info: From 3868f152b7e84e3d1abb18e20d49b8e158f818ef Mon Sep 17 00:00:00 2001 From: Yegor Dolgopolov Date: Tue, 13 Oct 2026 06:24:31 -0700 Subject: [PATCH 06/10] docs(sdk): InsufficientBalanceError reports what fits, a fixed-size request only asks to top up --- packages/discolike/src/discolike/_exceptions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/discolike/src/discolike/_exceptions.py b/packages/discolike/src/discolike/_exceptions.py index 3ea096a..e44da39 100644 --- a/packages/discolike/src/discolike/_exceptions.py +++ b/packages/discolike/src/discolike/_exceptions.py @@ -30,7 +30,9 @@ class InsufficientBalanceError(PlanAccessError): ``requested_records`` and ``max_affordable_records`` count in the request's own size unit (records, contacts, companies, domains, rows, queries or personas; the message names it), so a - retry at ``max_affordable_records`` fits. A fixed-size lookup reports 0; top up at ``upgrade_url``. + retry at ``max_affordable_records`` fits; it is 0 when even the request fee does not fit. A request + with no size to lower (a single-company lookup or a single email find) cannot be retried smaller, + and its message only asks to top up at ``upgrade_url``. """ def __init__( From 39a295e9a106fd8886e6c0f274853376c15137e5 Mon Sep 17 00:00:00 2001 From: Yegor Dolgopolov Date: Tue, 13 Oct 2026 06:57:18 -0700 Subject: [PATCH 07/10] docs(sdk): max_affordable_records is 0 when no record fits after the fee too --- packages/discolike/src/discolike/_exceptions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/discolike/src/discolike/_exceptions.py b/packages/discolike/src/discolike/_exceptions.py index e44da39..5a71d99 100644 --- a/packages/discolike/src/discolike/_exceptions.py +++ b/packages/discolike/src/discolike/_exceptions.py @@ -30,8 +30,8 @@ class InsufficientBalanceError(PlanAccessError): ``requested_records`` and ``max_affordable_records`` count in the request's own size unit (records, contacts, companies, domains, rows, queries or personas; the message names it), so a - retry at ``max_affordable_records`` fits; it is 0 when even the request fee does not fit. A request - with no size to lower (a single-company lookup or a single email find) cannot be retried smaller, + retry at ``max_affordable_records`` fits; it is 0 when the request fee does not fit or no record + fits after the fee. A request with no size to lower (a single-company lookup or a single email find) cannot be retried smaller, and its message only asks to top up at ``upgrade_url``. """ From e1887a851212e05e35393910f002f9e81bf8ca08 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Mon, 19 Oct 2026 14:40:31 -0700 Subject: [PATCH 08/10] feat(discover): add relevance and deprecate variance The API is replacing variance with relevance: three stop points picked by estimated ICP fit (MAX_RELEVANCE, BALANCED, MAX_COVERAGE) instead of six fit-percentage tiers, and no automatic stop when it is left unset. Variance stays accepted and the API maps its old values onto relevance. DiscoverParams gains relevance, sent only when set so the SDK still works against an API that has not deployed it. Variance leaves the model the way consensus and enhanced did: passing it still sends it and raises a DeprecationWarning naming relevance. The CLI adds --relevance to discover and bulk companies and keeps --variance for existing scripts, with help that points at --relevance. The generated request file is edited by hand ahead of the platform deploy; regenerate it from the production spec once relevance is live. --- CHANGELOG.md | 3 +++ .../discolike-cli/src/discolike_cli/bulk.py | 9 +++++---- .../src/discolike_cli/discover.py | 12 ++++++++---- packages/discolike-cli/tests/test_bulk_cli.py | 7 +++++-- packages/discolike-cli/tests/test_discover.py | 7 +++++-- .../tests/test_file_inputs_cli.py | 8 ++++---- .../src/discolike/_generated/requests.py | 10 +++++----- packages/discolike/src/discolike/_models.py | 2 ++ .../discolike/tests/test_requests_module.py | 18 ++++++++++++++---- 9 files changed, 51 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6f2a72..e1e941f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## Unreleased - SDK/CLI: the API now prices every billed request before it runs and refuses one the remaining balance cannot cover with a 403 whose `detail` is an object (`code: "INSUFFICIENT_BALANCE"`, `message`, `remaining_usd`, `max_affordable_records`, `requested_records`, `upgrade_url`). The SDK raises it as the new `InsufficientBalanceError`, a `PlanAccessError` subclass, so existing `except PlanAccessError` handlers still catch it; its message is the server's `message`, and `max_affordable_records`, `remaining_usd`, `requested_records` and `upgrade_url` are attributes, counted in the request's own size unit (records, or contacts, companies, domains, rows, queries or personas for requests sized by those; the message names it), so a caller can retry at `max_affordable_records`. Only a 403 carrying that code raises it. A request the API could not price answers 503 (`ServerError`), which the SDK retries automatically on GET calls. Any other object `detail` with a `message` now uses that message instead of the raw JSON. The CLI exits 3 with `"code": "insufficient_balance"` and adds `max_affordable_records` and `remaining_usd` to the error JSON. Older SDK versions raise a plain `PlanAccessError` whose message is the JSON body. +- SDK/CLI: `DiscoverParams.relevance` (`MAX_RELEVANCE`, `BALANCED` or `MAX_COVERAGE`) picks where a discover list stops by estimated ICP fit: `MAX_RELEVANCE` returns the tightest list, `MAX_COVERAGE` the longest, stopping where fit drops too low. Left unset it is not sent and the list has no automatic stop. The CLI's `discover` and `bulk companies` gain `--relevance`. It needs an API release that knows the field; an older API ignores it. +- SDK/CLI: `variance` on discover is deprecated; the API maps its old values onto `relevance`, which wins when both are sent. Use `relevance`. It is dropped from `DiscoverParams`, but passing it still sends it and raises a `DeprecationWarning`, and the SDK no longer checks its value. The CLI keeps `--variance` so existing scripts run, with help marking it deprecated. +- SDK (note for maintainers): `relevance`, and the removal of `variance`, were written into `discolike/_generated/requests.py` by hand ahead of the platform deploy, so `scripts/gen_requests.py --check` and `scripts/check_contract.py` report them until it lands. Regenerate once the production spec serves them. ## 0.6.0 (2026-10-12) diff --git a/packages/discolike-cli/src/discolike_cli/bulk.py b/packages/discolike-cli/src/discolike_cli/bulk.py index 0025b52..a2a1ec1 100644 --- a/packages/discolike-cli/src/discolike_cli/bulk.py +++ b/packages/discolike-cli/src/discolike_cli/bulk.py @@ -40,6 +40,8 @@ from discolike_cli.discover import DOMAINS_FILE_HELP from discolike_cli.discover import PARAM_HELP from discolike_cli.discover import PARAMS_FILE_HELP +from discolike_cli.discover import RELEVANCE_HELP +from discolike_cli.discover import VARIANCE_HELP from discolike_cli.discover import _merge_params T = TypeVar("T") @@ -157,10 +159,8 @@ def companies_command( state: list[str] | None = typer.Option(None, help="State or region filter (repeatable)."), employee_range: str | None = typer.Option(None, help="Employee count range filter."), revenue_range: str | None = typer.Option(None, help="Revenue range filter."), - variance: str | None = typer.Option( - None, - help="Where the list stops: LOW (earliest), MID_LOW, MEDIUM, MID_HIGH, HIGH (latest), UNRESTRICTED (never).", - ), + relevance: str | None = typer.Option(None, help=RELEVANCE_HELP), + variance: str | None = typer.Option(None, help=VARIANCE_HELP), exclusion_query_id: list[str] | None = typer.Option(None, help=EXCLUSION_QUERY_ID_HELP), param: list[str] | None = typer.Option(None, "--param", help=PARAM_HELP), params_file: pathlib.Path | None = typer.Option(None, help=PARAMS_FILE_HELP), @@ -187,6 +187,7 @@ def companies_command( state=state, employee_range=employee_range, revenue_range=revenue_range, + relevance=relevance, variance=variance, ), COMPANIES_MANAGED, diff --git a/packages/discolike-cli/src/discolike_cli/discover.py b/packages/discolike-cli/src/discolike_cli/discover.py index a1537bb..2f42bfd 100644 --- a/packages/discolike-cli/src/discolike_cli/discover.py +++ b/packages/discolike-cli/src/discolike_cli/discover.py @@ -43,6 +43,11 @@ NEGATE_LANGUAGE_HELP = "Exclude this site language (repeatable); same values as --language." EXCLUDE_LEADGEN_HELP = "Exclude suspected lead generation sites (on by default)." COUNT_EXCLUDE_LEADGEN_HELP = "Exclude suspected lead generation sites (off by default for count)." +RELEVANCE_HELP = ( + "Where the list stops, by estimated ICP fit: MAX_RELEVANCE (tightest), BALANCED, MAX_COVERAGE (longest). " + "Omit for no automatic stop." +) +VARIANCE_HELP = "Deprecated, mapped onto --relevance by the API; use --relevance." def _parse_param(raw: str) -> tuple[str, str | list[str]]: @@ -104,10 +109,8 @@ def discover_command( min_digital_footprint: int | None = typer.Option(None, help="Minimum digital footprint score."), max_digital_footprint: int | None = typer.Option(None, help="Maximum digital footprint score."), min_similarity: int | None = typer.Option(None, help="Minimum similarity score to include (0-99)."), - variance: str | None = typer.Option( - None, - help="Where the list stops: LOW (earliest), MID_LOW, MEDIUM, MID_HIGH, HIGH (latest), UNRESTRICTED (never).", - ), + relevance: str | None = typer.Option(None, help=RELEVANCE_HELP), + variance: str | None = typer.Option(None, help=VARIANCE_HELP), consensus: int | None = typer.Option( None, help="Deprecated, no-op. Discover applies consensus itself to single-domain lookalike searches." ), @@ -187,6 +190,7 @@ def discover_command( min_digital_footprint=min_digital_footprint, max_digital_footprint=max_digital_footprint, min_similarity=min_similarity, + relevance=relevance, variance=variance, consensus=consensus, redirect=redirect, diff --git a/packages/discolike-cli/tests/test_bulk_cli.py b/packages/discolike-cli/tests/test_bulk_cli.py index 5863988..8ab497d 100644 --- a/packages/discolike-cli/tests/test_bulk_cli.py +++ b/packages/discolike-cli/tests/test_bulk_cli.py @@ -280,7 +280,7 @@ def test_bulk_companies_takes_params_file_and_drops_managed_keys( install_build_client: Callable[[Handler], None], tmp_path: Path ) -> None: form = tmp_path / "form.json" - form.write_text(json.dumps({"country": ["US"], "variance": "MID_HIGH", "max_records": 5, "offset": 99})) + form.write_text(json.dumps({"country": ["US"], "relevance": "MAX_COVERAGE", "max_records": 5, "offset": 99})) recorder = Recorder(discover=[[]], queries_exclusion_list=[{"query_id": "q"}]) install_build_client(recorder) result = runner.invoke( @@ -292,6 +292,8 @@ def test_bulk_companies_takes_params_file_and_drops_managed_keys( str(form), "--param", "consensus=3", + "--relevance", + "BALANCED", "--variance", "LOW", "--out", @@ -302,6 +304,7 @@ def test_bulk_companies_takes_params_file_and_drops_managed_keys( assert result.exit_code == 0, result.output params = recorder.params("/v1/discover")[0] assert params.get_list("country") == ["US"] + assert params.get("relevance") == "BALANCED" assert params.get("variance") == "LOW" assert params.get("consensus") == "3" assert params.get("max_records") == "10000" @@ -316,7 +319,7 @@ def test_bulk_companies_validates_before_first_call( recorder = Recorder(discover=[[]]) install_build_client(recorder) result = runner.invoke( - app, ["bulk", "companies", "--variance", "BOGUS", "--out", str(tmp_path / "c.csv"), *NO_LIMIT] + app, ["bulk", "companies", "--relevance", "BOGUS", "--out", str(tmp_path / "c.csv"), *NO_LIMIT] ) assert result.exit_code == 2 assert "ValidationError" in result.output diff --git a/packages/discolike-cli/tests/test_discover.py b/packages/discolike-cli/tests/test_discover.py index 477cebc..d52db5e 100644 --- a/packages/discolike-cli/tests/test_discover.py +++ b/packages/discolike-cli/tests/test_discover.py @@ -146,6 +146,8 @@ def handler(request: httpx2.Request) -> httpx2.Response: "2020-01-01", "--min-similarity", "40", + "--relevance", + "MAX_RELEVANCE", "--variance", "MEDIUM", "--consensus", @@ -171,6 +173,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert params.get_list("negate_social") == ["tiktok"] assert params.get("start_date") == "2020-01-01" assert params.get("min_similarity") == "40" + assert params.get("relevance") == "MAX_RELEVANCE" assert params.get("variance") == "MEDIUM" assert params.get("consensus") == "3" assert params.get("redirect") == "true" @@ -183,9 +186,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert params.get_list("inclusion_query_id") == ["q1"] -def test_discover_rejects_invalid_variance(install_build_client: Callable[[Handler], None]) -> None: +def test_discover_rejects_invalid_relevance(install_build_client: Callable[[Handler], None]) -> None: install_build_client(_discover_ok) - result = runner.invoke(app, ["discover", "--variance", "BOGUS"]) + result = runner.invoke(app, ["discover", "--relevance", "BOGUS"]) assert result.exit_code == 2 diff --git a/packages/discolike-cli/tests/test_file_inputs_cli.py b/packages/discolike-cli/tests/test_file_inputs_cli.py index 23d611c..abefb6f 100644 --- a/packages/discolike-cli/tests/test_file_inputs_cli.py +++ b/packages/discolike-cli/tests/test_file_inputs_cli.py @@ -120,17 +120,17 @@ def test_discover_params_file_under_param_under_flags( install_build_client: Callable[[Handler], None], tmp_path: Path ) -> None: form = tmp_path / "form.json" - form.write_text(json.dumps({"country": ["US"], "variance": "LOW", "min_similarity": 10, "consensus": 3})) + form.write_text(json.dumps({"country": ["US"], "relevance": "MAX_COVERAGE", "min_similarity": 10, "consensus": 3})) captured: dict[str, Any] = {} install_build_client(_capture_json(captured, [])) result = runner.invoke( app, - ["discover", "--params-file", str(form), "--param", "min_similarity=50", "--variance", "HIGH"], + ["discover", "--params-file", str(form), "--param", "min_similarity=50", "--relevance", "BALANCED"], ) assert result.exit_code == 0, result.output params = captured["params"] assert params.get_list("country") == ["US"] - assert params.get("variance") == "HIGH" + assert params.get("relevance") == "BALANCED" assert params.get("min_similarity") == "50" assert params.get("consensus") == "3" @@ -139,7 +139,7 @@ def test_discover_params_file_is_validated_locally( install_build_client: Callable[[Handler], None], tmp_path: Path ) -> None: form = tmp_path / "form.json" - form.write_text(json.dumps({"variance": "BOGUS"})) + form.write_text(json.dumps({"relevance": "BOGUS"})) calls: list[str] = [] def handler(request: httpx2.Request) -> httpx2.Response: diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index a38e76b..5aa1bb9 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -2096,13 +2096,13 @@ class DiscoverParams(DiscolikeRequest): title="Min Similarity", ), ] = 0 - variance: Annotated[ - Literal["LOW", "MID_LOW", "MEDIUM", "MID_HIGH", "HIGH", "UNRESTRICTED"] | None, + relevance: Annotated[ + Literal["MAX_RELEVANCE", "BALANCED", "MAX_COVERAGE"] | None, Field( - description="Where the list stops: LOW earliest (rows ~70% fit), MID_LOW, MEDIUM, MID_HIGH, HIGH latest (~30%). UNRESTRICTED never stops.", - title="Variance", + description="Where the list stops, by estimated ICP fit: MAX_RELEVANCE gives the tightest list, BALANCED the middle, MAX_COVERAGE the longest (stops where fit drops too low). Omit for no automatic stop.", + title="Relevance", ), - ] = "UNRESTRICTED" + ] = None offset: Annotated[ int | None, Field( diff --git a/packages/discolike/src/discolike/_models.py b/packages/discolike/src/discolike/_models.py index 2f18796..d3c25bc 100644 --- a/packages/discolike/src/discolike/_models.py +++ b/packages/discolike/src/discolike/_models.py @@ -34,6 +34,8 @@ "DiscoverParams": { "consensus": "consensus is deprecated and ignored: discover applies consensus itself to single-domain lookalike searches.", "enhanced": "enhanced is deprecated and ignored: discover no longer adds AI-suggested domains.", + "variance": "variance is deprecated: LOW, MID_LOW and MEDIUM map to relevance MAX_RELEVANCE, MID_HIGH and HIGH to BALANCED, " + "UNRESTRICTED to no stop; relevance wins when both are sent. Use relevance.", }, } diff --git a/packages/discolike/tests/test_requests_module.py b/packages/discolike/tests/test_requests_module.py index 660f93e..8a9daf8 100644 --- a/packages/discolike/tests/test_requests_module.py +++ b/packages/discolike/tests/test_requests_module.py @@ -53,11 +53,21 @@ def test_email_validated_is_deprecated_but_still_accepted(model: type[DiscolikeR assert request.to_wire() == {"email_validated": True} -@pytest.mark.parametrize("name", ["consensus", "enhanced"]) -def test_discover_consensus_and_enhanced_are_deprecated_but_still_sent(name: str) -> None: +@pytest.mark.parametrize(("name", "value"), [("consensus", 2), ("enhanced", True), ("variance", "LOW")]) +def test_discover_deprecated_fields_warn_but_are_still_sent(name: str, value: object) -> None: + assert name not in DiscoverParams.model_fields with pytest.warns(DeprecationWarning, match=f"{name} is deprecated"): - request = DiscoverParams.model_validate({name: 2 if name == "consensus" else True}) - assert name in request.to_wire() + request = DiscoverParams.model_validate({name: value}) + assert request.to_wire() == {name: value} + + +def test_discover_relevance_is_omitted_unless_set() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert DiscoverParams().to_wire() == {} + assert DiscoverParams(relevance="MAX_COVERAGE").to_wire() == {"relevance": "MAX_COVERAGE"} + with pytest.raises(pydantic.ValidationError, match="relevance"): + DiscoverParams.model_validate({"relevance": "BOGUS"}) def test_contacts_consensus_is_not_deprecated() -> None: From 2293f77b1aefcc793e658d047164ac243b778dec Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Mon, 19 Oct 2026 18:43:42 -0700 Subject: [PATCH 09/10] Release 0.6.1: discover relevance, InsufficientBalanceError, footprint default 25 Bump the SDK and CLI to 0.6.1, date the changelog section and add the API's new min_digital_footprint default of 25. The platform release is live, so request models are regenerated from the production spec: relevance no longer needs the hand edit, and question_seq leaves PENDING_PROPERTIES. gen_requests.py --check and check_contract.py are clean against prod. --- CHANGELOG.md | 4 ++-- packages/discolike-cli/pyproject.toml | 4 ++-- packages/discolike/pyproject.toml | 2 +- .../discolike/src/discolike/_generated/requests.py | 6 +++--- packages/discolike/src/discolike/_version.py | 2 +- packages/discolike/tests/test_package.py | 2 +- scripts/gen_requests.py | 14 +------------- uv.lock | 2 +- 8 files changed, 12 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1e941f..0733078 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,11 @@ # Changelog -## Unreleased +## 0.6.1 (2026-10-19) +- SDK/CLI: discover's server-side default for `min_digital_footprint` drops from 50 to 25 when the field is omitted, so lists include more real companies with a thin web presence. Pass `min_digital_footprint=50` (CLI `--min-digital-footprint 50`) to keep the old floor. No SDK change; older versions get the new default too. - SDK/CLI: the API now prices every billed request before it runs and refuses one the remaining balance cannot cover with a 403 whose `detail` is an object (`code: "INSUFFICIENT_BALANCE"`, `message`, `remaining_usd`, `max_affordable_records`, `requested_records`, `upgrade_url`). The SDK raises it as the new `InsufficientBalanceError`, a `PlanAccessError` subclass, so existing `except PlanAccessError` handlers still catch it; its message is the server's `message`, and `max_affordable_records`, `remaining_usd`, `requested_records` and `upgrade_url` are attributes, counted in the request's own size unit (records, or contacts, companies, domains, rows, queries or personas for requests sized by those; the message names it), so a caller can retry at `max_affordable_records`. Only a 403 carrying that code raises it. A request the API could not price answers 503 (`ServerError`), which the SDK retries automatically on GET calls. Any other object `detail` with a `message` now uses that message instead of the raw JSON. The CLI exits 3 with `"code": "insufficient_balance"` and adds `max_affordable_records` and `remaining_usd` to the error JSON. Older SDK versions raise a plain `PlanAccessError` whose message is the JSON body. - SDK/CLI: `DiscoverParams.relevance` (`MAX_RELEVANCE`, `BALANCED` or `MAX_COVERAGE`) picks where a discover list stops by estimated ICP fit: `MAX_RELEVANCE` returns the tightest list, `MAX_COVERAGE` the longest, stopping where fit drops too low. Left unset it is not sent and the list has no automatic stop. The CLI's `discover` and `bulk companies` gain `--relevance`. It needs an API release that knows the field; an older API ignores it. - SDK/CLI: `variance` on discover is deprecated; the API maps its old values onto `relevance`, which wins when both are sent. Use `relevance`. It is dropped from `DiscoverParams`, but passing it still sends it and raises a `DeprecationWarning`, and the SDK no longer checks its value. The CLI keeps `--variance` so existing scripts run, with help marking it deprecated. -- SDK (note for maintainers): `relevance`, and the removal of `variance`, were written into `discolike/_generated/requests.py` by hand ahead of the platform deploy, so `scripts/gen_requests.py --check` and `scripts/check_contract.py` report them until it lands. Regenerate once the production spec serves them. ## 0.6.0 (2026-10-12) diff --git a/packages/discolike-cli/pyproject.toml b/packages/discolike-cli/pyproject.toml index 5ec2b3e..c25eae5 100644 --- a/packages/discolike-cli/pyproject.toml +++ b/packages/discolike-cli/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "discolike-cli" -version = "0.6.0" +version = "0.6.1" description = "Official CLI for the DiscoLike API" readme = "README.md" license = "MIT" @@ -12,7 +12,7 @@ license-files = ["LICENSE"] requires-python = ">=3.11" authors = [{ name = "DiscoLike", email = "support@discolike.com" }] dependencies = [ - "discolike==0.6.0", + "discolike==0.6.1", "typer>=0.26.1,<0.28", "rich>=13.0", ] diff --git a/packages/discolike/pyproject.toml b/packages/discolike/pyproject.toml index 9ad1bcd..507ad99 100644 --- a/packages/discolike/pyproject.toml +++ b/packages/discolike/pyproject.toml @@ -33,7 +33,7 @@ classifiers = [ ] [project.optional-dependencies] -cli = ["discolike-cli==0.6.0"] +cli = ["discolike-cli==0.6.1"] [project.urls] Homepage = "https://www.discolike.com" diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index 5aa1bb9..a519c7d 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -1759,7 +1759,7 @@ class DiscoverParams(DiscolikeRequest): min_digital_footprint: Annotated[ int | None, Field( - description="Minimum digital footprint score (0-800). Default 50.", + description="Minimum digital footprint score (0-800). Default 25.", ge=0, le=800, title="Min Digital Footprint", @@ -2099,7 +2099,7 @@ class DiscoverParams(DiscolikeRequest): relevance: Annotated[ Literal["MAX_RELEVANCE", "BALANCED", "MAX_COVERAGE"] | None, Field( - description="Where the list stops, by estimated ICP fit: MAX_RELEVANCE gives the tightest list, BALANCED the middle, MAX_COVERAGE the longest (stops where fit drops too low). Omit for no automatic stop.", + description="How far down the ranked list results go before the estimated ICP fit drops too low. Applies to lookalike searches (icp_text or domain). MAX_RELEVANCE stops earliest with the tightest fit, BALANCED goes further, MAX_COVERAGE serves the longest list. Omit for no automatic stop.", title="Relevance", ), ] = None @@ -2385,7 +2385,7 @@ class CountParams(DiscolikeRequest): min_digital_footprint: Annotated[ int | None, Field( - description="Minimum digital footprint score (0-800). Default 50.", + description="Minimum digital footprint score (0-800). Default 25.", ge=0, le=800, title="Min Digital Footprint", diff --git a/packages/discolike/src/discolike/_version.py b/packages/discolike/src/discolike/_version.py index 906d362..43c4ab0 100644 --- a/packages/discolike/src/discolike/_version.py +++ b/packages/discolike/src/discolike/_version.py @@ -1 +1 @@ -__version__ = "0.6.0" +__version__ = "0.6.1" diff --git a/packages/discolike/tests/test_package.py b/packages/discolike/tests/test_package.py index a6bf703..afc8344 100644 --- a/packages/discolike/tests/test_package.py +++ b/packages/discolike/tests/test_package.py @@ -2,4 +2,4 @@ def test_version() -> None: - assert discolike.__version__ == "0.6.0" + assert discolike.__version__ == "0.6.1" diff --git a/scripts/gen_requests.py b/scripts/gen_requests.py index 162c341..ae1d189 100644 --- a/scripts/gen_requests.py +++ b/scripts/gen_requests.py @@ -100,19 +100,7 @@ # Properties the SDK ships before the deployed spec has them. Merged in only while the spec # lacks them, so each entry clears itself once the platform release lands -- generation prints # the ones that have, to be deleted here. -PENDING_PROPERTIES: dict[str, dict[str, dict[str, Any]]] = { - "ProspectingMessageRequest": { - "question_seq": { - "anyOf": [{"minimum": 1, "type": "integer"}, {"type": "null"}], - "default": None, - "description": ( - "The seq of the question message whose suggested reply this is; the reply answers that question " - "only, never a newer one." - ), - "title": "Question Seq", - } - } -} +PENDING_PROPERTIES: dict[str, dict[str, dict[str, Any]]] = {} # Properties generated from this schema rather than the spec's, whatever the spec says. The # platform's sub-industry enum lists parent-qualified keys only; a bare label reaches it through diff --git a/uv.lock b/uv.lock index 429b470..38b08de 100644 --- a/uv.lock +++ b/uv.lock @@ -343,7 +343,7 @@ dev = [ [[package]] name = "discolike-cli" -version = "0.6.0" +version = "0.6.1" source = { editable = "packages/discolike-cli" } dependencies = [ { name = "discolike" }, From 1e6ae0b9fcb867e92a2facc67d7ab92fef50fab7 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Mon, 19 Oct 2026 18:53:31 -0700 Subject: [PATCH 10/10] Add the discover consensus and enhanced deprecation to the 0.6.1 changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0733078..98473af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - SDK/CLI: the API now prices every billed request before it runs and refuses one the remaining balance cannot cover with a 403 whose `detail` is an object (`code: "INSUFFICIENT_BALANCE"`, `message`, `remaining_usd`, `max_affordable_records`, `requested_records`, `upgrade_url`). The SDK raises it as the new `InsufficientBalanceError`, a `PlanAccessError` subclass, so existing `except PlanAccessError` handlers still catch it; its message is the server's `message`, and `max_affordable_records`, `remaining_usd`, `requested_records` and `upgrade_url` are attributes, counted in the request's own size unit (records, or contacts, companies, domains, rows, queries or personas for requests sized by those; the message names it), so a caller can retry at `max_affordable_records`. Only a 403 carrying that code raises it. A request the API could not price answers 503 (`ServerError`), which the SDK retries automatically on GET calls. Any other object `detail` with a `message` now uses that message instead of the raw JSON. The CLI exits 3 with `"code": "insufficient_balance"` and adds `max_affordable_records` and `remaining_usd` to the error JSON. Older SDK versions raise a plain `PlanAccessError` whose message is the JSON body. - SDK/CLI: `DiscoverParams.relevance` (`MAX_RELEVANCE`, `BALANCED` or `MAX_COVERAGE`) picks where a discover list stops by estimated ICP fit: `MAX_RELEVANCE` returns the tightest list, `MAX_COVERAGE` the longest, stopping where fit drops too low. Left unset it is not sent and the list has no automatic stop. The CLI's `discover` and `bulk companies` gain `--relevance`. It needs an API release that knows the field; an older API ignores it. - SDK/CLI: `variance` on discover is deprecated; the API maps its old values onto `relevance`, which wins when both are sent. Use `relevance`. It is dropped from `DiscoverParams`, but passing it still sends it and raises a `DeprecationWarning`, and the SDK no longer checks its value. The CLI keeps `--variance` so existing scripts run, with help marking it deprecated. +- SDK/CLI: `consensus` and `enhanced` on discover are deprecated: the API now picks consensus itself and no longer runs AI result enhancement, so both are ignored. They are dropped from `DiscoverParams`; passing either still sends it and raises a `DeprecationWarning`. Contacts `consensus` is unchanged. The CLI keeps `--consensus` and `--enhanced`, with help saying what the server does now. ## 0.6.0 (2026-10-12)