Skip to content

Commit fbdcb28

Browse files
author
Convilyn Release
committed
Release v3.1.0
Generated public mirror snapshot.
1 parent 4e6e9da commit fbdcb28

12 files changed

Lines changed: 592 additions & 129 deletions

File tree

CHANGELOG.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,65 @@
33
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
44
versioning follows [Semantic Versioning](https://semver.org/).
55

6+
## [3.1.0] - 2026-08-17
7+
8+
Minor, and both halves are why: the public surface **grows** by four exception
9+
types, and the set of packages installed into your environment **shrinks** by
10+
one. Nothing is removed from the API and nothing you catch today stops being
11+
caught, so no migration is required.
12+
13+
### Added
14+
15+
- **Four typed billing refusals.** The paid path can refuse a run in four ways
16+
that want four different next steps from you, and until now all four arrived
17+
as a bare `APIError` — so telling "top up" from "wait" from "this workflow
18+
has no price" meant string-matching `exc.code`, which is matching on
19+
something we reserve the right to change.
20+
21+
| | status | what to do |
22+
|---|---|---|
23+
| `InsufficientCreditsError` | 402 | top up — carries `required_credits`, `available_credits`, `shortfall_credits` |
24+
| `FreeTierBlockedError` | 403 | leave the Free plan (or fund the run) — carries `upgrade_url` |
25+
| `SpecNotPricedError` | 409 | pick another workflow; retrying will not help |
26+
| `ChargeUnavailableError` | 409 | transient — retry later |
27+
28+
**`InsufficientCreditsError` is not `QuotaExceededError`, and they share HTTP
29+
402.** A quota is a ceiling you were given and it resets at the next period; a
30+
balance is money you hold and it does not refill on its own. One status code,
31+
two different facts about your account — so they are two types rather than one
32+
type you branch on by `code`:
33+
34+
```python
35+
except InsufficientCreditsError as exc:
36+
print(f"short by {exc.shortfall_credits} credits") # None when unknown
37+
except QuotaExceededError:
38+
... # wait, or upgrade
39+
```
40+
41+
`shortfall_credits` is derived from the two operands rather than sent as a
42+
third field, because a third field that must agree with two others is a field
43+
that can disagree with them. It is `None`*unknown*, never zero — when the
44+
refusal carried no operands, and clamped at zero if they ever disagree.
45+
46+
All four subclass `APIError`, so every existing `except APIError:` and
47+
`except ConvilynError:` keeps catching them. **A refusal code this build does
48+
not model still arrives as a plain `APIError`** with `code` and `details`
49+
intact — on 402, 403 and 409 alike — so a new server signal is never an
50+
unhandled crash and never a type asserting a remediation nobody verified.
51+
52+
### Removed
53+
54+
- **`websockets` is no longer a dependency.** It had been *required* since
55+
before 3.0.0 and imported nowhere in the package since — the WebSocket
56+
surface was removed in 3.0.0 (`goals.events()`, `GoalEvent`, `WebSocketError`,
57+
`ws_url`) and the dependency did not follow, so every `pip install convilyn`
58+
pulled a package no code could reach.
59+
60+
Nothing in the public API changes; there was nothing left importing it. What
61+
changes is your installed environment — one fewer transitive package, one
62+
fewer version-compatibility surface, one fewer CVE feed to read. That is why
63+
this is a minor rather than a patch.
64+
665
## [3.0.1] - 2026-08-17
766

867
A fix-only release: nothing added, nothing removed from the public API. That is

docs/QUICKSTART.md

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -390,8 +390,33 @@ except ConvilynError as exc: # covers every type listed below
390390
```
391391
392392
Catch a specific one when you can actually do something different about it —
393-
top up on `QuotaExceededError`, back off on `RateLimitError`, fall back to file
394-
conversion on `UnderstandUnavailableError`.
393+
top up on `InsufficientCreditsError`, back off on `RateLimitError`, fall back to
394+
file conversion on `UnderstandUnavailableError`.
395+
396+
**`QuotaExceededError` and `InsufficientCreditsError` are both HTTP 402 and they
397+
are not the same thing.** A quota is a ceiling you were given and it resets at
398+
the next period; a balance is money you hold and it does not refill on its own.
399+
So they are separate types rather than one type you branch on by `code`:
400+
401+
```python
402+
from convilyn import InsufficientCreditsError, QuotaExceededError
403+
404+
try:
405+
result = client.goals.run(goal_text="Summarise this", files=[file_id])
406+
except InsufficientCreditsError as exc:
407+
# `shortfall_credits` is None when the server did not send the operands —
408+
# read that as unknown, never as zero.
409+
print(f"top up: short by {exc.shortfall_credits} credits")
410+
except QuotaExceededError:
411+
print("allowance spent — wait for the next period, or upgrade")
412+
```
413+
414+
The billing path refuses on three other statuses too, each wanting a different
415+
next step: `FreeTierBlockedError` (403 — leave the Free plan),
416+
`ChargeUnavailableError` (409 — transient, retry later) and `SpecNotPricedError`
417+
(409 — permanent for that workflow, retrying will not help). A refusal code this
418+
build does not model still arrives as a plain `APIError` with `code` and
419+
`details` intact, so a new server signal never becomes an unhandled crash.
395420
396421
**Importable from `convilyn`:**
397422
@@ -403,7 +428,11 @@ conversion on `UnderstandUnavailableError`.
403428
| `APIError` | the API answered with an error status |
404429
| `RateLimitError` | too many requests — back off and retry |
405430
| `QuotaExceededError` | the plan's allowance for this call is used up |
431+
| `InsufficientCreditsError` | your **balance** cannot fund this run — carries `required_credits` / `available_credits` / `shortfall_credits` |
406432
| `PlanRequiredError` | the call needs a tier this account is not on |
433+
| `FreeTierBlockedError` | a Free-plan gate refused the run — this workflow is not on Free, or Free's monthly cap is spent |
434+
| `SpecNotPricedError` | this workflow has no price configured; retrying will not help |
435+
| `ChargeUnavailableError` | billing could not record the charge right now — transient, retry later |
407436
| `RetryExhaustedError` | retried to the configured limit and still failing |
408437
| `S3UploadError` | the upload itself failed, before any job existed |
409438
| `JobFailedError` | a conversion job finished with `status=failed` |

docs/README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -152,10 +152,12 @@ convilyn goals start "summarise these contracts" --dry-run
152152
## Free to install, metered to use
153153

154154
`pip install convilyn` is free, and everything under `convilyn local` stays free
155-
and unlimited — it runs on your hardware. Platform calls draw on your plan's
156-
quota, and the SDK raises typed `PlanRequiredError` / `QuotaExceededError`
157-
(both `APIError`) rather than failing opaquely. Check first with
158-
`client.account`.
155+
and unlimited — it runs on your hardware. Platform calls draw on your balance and
156+
your plan, and every refusal is a typed `APIError` subclass rather than an opaque
157+
failure: `InsufficientCreditsError` (your balance cannot fund this run — it
158+
carries `shortfall_credits`), `QuotaExceededError` (an allowance is spent),
159+
`PlanRequiredError` and `FreeTierBlockedError` (this needs a different plan).
160+
Check first with `client.account`.
159161

160162
## Known limits
161163

docs/STABILITY.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,16 @@ If streaming returns, it will be through a short-lived, single-use connect
136136
ticket — a design that shares no code with what was removed, which is the other
137137
reason keeping this was not "free optionality".
138138

139+
**The dependency did not follow until 3.1.0.** `websockets` stayed a *required*
140+
dependency of this package for the whole of 3.0.x with zero imports anywhere in
141+
`src/`, so every `pip install convilyn` pulled a package no code could reach. It
142+
is removed in 3.1.0. Nothing about the surface changes — there was nothing left
143+
importing it — but the set of packages installed into your environment does,
144+
which is why it is a minor and not a patch. This is worth recording rather than
145+
quietly deleting: the removal of a *surface* and the removal of the *dependency
146+
that served it* are two steps, and only the first one is visible in a diff of
147+
the public API.
148+
139149
## Deprecation policy
140150

141151
We do not remove public surface without warning. A symbol slated for

pyproject.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ license = "Apache-2.0"
1111
license-files = ["LICENSE"]
1212
requires-python = ">=3.10"
1313
authors = [
14-
{ name = "Convilyn", email = "support@corenovus.com" },
14+
{ name = "Convilyn", email = "support@convilyn.com" },
1515
]
1616
keywords = [
1717
"convilyn",
@@ -37,7 +37,6 @@ dependencies = [
3737
"httpx>=0.25.0,<1.0.0",
3838
"pydantic>=2.0.0,<3.0.0",
3939
"click>=8.0.0,<9.0.0",
40-
"websockets>=13.0,<17.0",
4140
"typing-extensions>=4.7.0; python_version < '3.11'",
4241
]
4342

src/convilyn/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,20 @@
3838
from convilyn.exceptions import (
3939
APIError,
4040
AuthError,
41+
ChargeUnavailableError,
4142
ConvilynError,
43+
FreeTierBlockedError,
4244
GoalJobFailedError,
4345
GoalJobTimeoutError,
46+
InsufficientCreditsError,
4447
JobFailedError,
4548
JobTimeoutError,
4649
PlanRequiredError,
4750
QuotaExceededError,
4851
RateLimitError,
4952
RetryExhaustedError,
5053
S3UploadError,
54+
SpecNotPricedError,
5155
UnderstandUnavailableError,
5256
)
5357
from convilyn.sync_client import Convilyn
@@ -105,16 +109,19 @@
105109
"BuilderSession",
106110
"BuilderTurn",
107111
"CatalogWorkflow",
112+
"ChargeUnavailableError",
108113
"ConvertJob",
109114
"Convilyn",
110115
"ConvilynError",
111116
"CostEstimate",
112117
"ExponentialBackoffRetry",
113118
"File",
114119
"FileList",
120+
"FreeTierBlockedError",
115121
"GoalJob",
116122
"GoalJobFailedError",
117123
"GoalJobTimeoutError",
124+
"InsufficientCreditsError",
118125
"JobError",
119126
"JobFailedError",
120127
"JobTimeoutError",
@@ -132,6 +139,7 @@
132139
"RetryExhaustedError",
133140
"RetryPolicy",
134141
"S3UploadError",
142+
"SpecNotPricedError",
135143
"StorageUsage",
136144
"StoredFile",
137145
"ToolCostEstimate",

src/convilyn/_internal/http.py

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,15 @@
4646
from convilyn._version import __version__
4747
from convilyn.exceptions import (
4848
APIError,
49+
ChargeUnavailableError,
50+
FreeTierBlockedError,
51+
InsufficientCreditsError,
4952
PlanRequiredError,
5053
QuotaExceededError,
5154
RateLimitError,
5255
RetryExhaustedError,
5356
S3UploadError,
57+
SpecNotPricedError,
5458
)
5559

5660
# Backend emits any of these on 402 to signal a plan-tier mismatch.
@@ -65,6 +69,26 @@
6569
}
6670
)
6771

72+
# Backend emits either of these on 403 when a Free-plan gate refuses a run
73+
# before any charge (``services/billing/quote.py`` ``QuoteFreeBlocked``). Same
74+
# shape and same rationale as the frozenset above: a third Free gate is one
75+
# line here and changes no dispatch.
76+
_FREE_TIER_BLOCKED_CODES: frozenset[str] = frozenset(
77+
{
78+
"spec_not_allowed_on_free",
79+
"free_cost_cap_exceeded",
80+
}
81+
)
82+
83+
# 409 billing refusals, keyed to their class. A dict rather than an `if`/`elif`
84+
# chain because the two mean OPPOSITE things about retrying — permanent vs
85+
# transient — and a reader should see that as two rows, not as an order of
86+
# tests (`coding-style.md` O).
87+
_BILLING_CONFLICT_ERRORS: dict[str, type[APIError]] = {
88+
"SPEC_NOT_PRICED": SpecNotPricedError,
89+
"CHARGE_UNAVAILABLE": ChargeUnavailableError,
90+
}
91+
6892
DEFAULT_BASE_URL = "https://api.convilyn.corenovus.com"
6993
DEFAULT_TIMEOUT = 30.0
7094
ENV_BASE_URL = "CONVILYN_BASE_URL"
@@ -527,10 +551,15 @@ def _decode_error(response: httpx.Response) -> APIError:
527551
* 429 → :class:`RateLimitError`
528552
* 402 + code in :data:`_PLAN_REQUIRED_CODES` → :class:`PlanRequiredError`
529553
* 402 + code = ``QUOTA_EXCEEDED`` → :class:`QuotaExceededError`
554+
* 402 + code = ``INSUFFICIENT_CREDITS`` → :class:`InsufficientCreditsError`
555+
* 403 + code in :data:`_FREE_TIER_BLOCKED_CODES` → :class:`FreeTierBlockedError`
556+
* 409 + code in :data:`_BILLING_CONFLICT_ERRORS` → that class
530557
* Otherwise → base :class:`APIError`
531558
532-
Unknown 402 codes fall through to ``APIError`` — forward-compat for
533-
new tier signals the SDK doesn't know about yet.
559+
Unknown codes fall through to ``APIError`` — forward-compat for signals the
560+
SDK doesn't know about yet. That is deliberate on EVERY status here, not
561+
just 402: a 403 the SDK cannot name is still a 403, and inventing a type for
562+
it would assert a remediation nobody verified.
534563
"""
535564
status = response.status_code
536565
try:
@@ -572,6 +601,40 @@ def _decode_error(response: httpx.Response) -> APIError:
572601
),
573602
upgrade_url=upgrade_url,
574603
)
604+
if code == "INSUFFICIENT_CREDITS":
605+
# These two ride INSIDE `details`, not beside it — the refusal is
606+
# built as `detail={code, message, details={requiredCredits, ...}}`
607+
# (`api/v1/goal_lane/_charging.py`), so `inner` holds the dict and
608+
# not the numbers. Reading them off `inner` would silently produce
609+
# None on every real refusal while looking exactly like the
610+
# QUOTA_EXCEEDED branch above.
611+
credit_detail = details if isinstance(details, dict) else {}
612+
return InsufficientCreditsError(
613+
status,
614+
code,
615+
message,
616+
details,
617+
required_credits=_coerce_int(
618+
_first_present(credit_detail, "requiredCredits", "required_credits")
619+
),
620+
available_credits=_coerce_int(
621+
_first_present(credit_detail, "availableCredits", "available_credits")
622+
),
623+
)
624+
625+
if status == 403 and code in _FREE_TIER_BLOCKED_CODES:
626+
return FreeTierBlockedError(
627+
status,
628+
code,
629+
message,
630+
details,
631+
upgrade_url=inner.get("upgrade_url") or inner.get("upgradeUrl"),
632+
)
633+
634+
if status == 409:
635+
conflict = _BILLING_CONFLICT_ERRORS.get(code)
636+
if conflict is not None:
637+
return conflict(status, code, message, details)
575638

576639
return APIError(status, code, message, details)
577640

@@ -635,6 +698,25 @@ def _flatten_error_envelope(payload: dict[str, Any]) -> dict[str, Any]:
635698
return nested if isinstance(nested, dict) else payload
636699

637700

701+
def _first_present(payload: dict[str, Any], *keys: str) -> Any:
702+
"""The first key that is PRESENT, not the first that is truthy.
703+
704+
``payload.get("a") or payload.get("a_snake")`` is the idiom used elsewhere
705+
in this module for camel/snake aliases, and it is wrong for any field whose
706+
zero value is meaningful. ``availableCredits`` is exactly that field: it is
707+
``0`` for the caller :class:`InsufficientCreditsError` exists for — someone
708+
whose balance is empty — and ``0 or None`` is ``None``, so the number would
709+
read as "the server did not send it" on the most common refusal there is.
710+
711+
Returns ``None`` when no key is present, which :func:`_coerce_int` then
712+
passes through as the genuine "unknown".
713+
"""
714+
for key in keys:
715+
if key in payload:
716+
return payload[key]
717+
return None
718+
719+
638720
def _coerce_int(value: Any) -> int | None:
639721
"""Best-effort ``int`` coercion for wire fields that may arrive as str."""
640722
if value is None:

src/convilyn/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@
1111
hardcoded constant in two places.
1212
"""
1313

14-
__version__ = "3.0.1"
14+
__version__ = "3.1.0"

0 commit comments

Comments
 (0)