writehuman 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: writehuman
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Official WriteHuman API client for Python: humanize AI text, detect AI authorship, read usage.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://writehuman.ai/api/docs
@@ -44,6 +44,16 @@ print(score["ai_probability"], score["predicted_class"])
44
44
  print(wh.usage())
45
45
  ```
46
46
 
47
+ Premium plans and above can ask for the enhanced model. On a Standard plan this raises
48
+ `PlanRequiredError` (nothing is charged) with `upgrade_url` set; while the enhanced model is
49
+ switched off on the API it raises `FeatureUnavailableError`, so send without `enhanced`. Every
50
+ humanize response's `model` (`"standard"` or `"enhanced"`) says which model served it:
51
+
52
+ ```python
53
+ out = wh.humanize(text, enhanced=True)
54
+ print(out["model"]) # "enhanced"
55
+ ```
56
+
47
57
  Async, same methods:
48
58
 
49
59
  ```python
@@ -57,13 +67,16 @@ async with AsyncWriteHuman("wh_live_...") as wh:
57
67
 
58
68
  Every failure raises a subclass of `WriteHumanError` with `code` (the API's stable error
59
69
  code), `status`, `retry_after` (seconds, when the API sent one) and, on
60
- `InsufficientBalanceError`, `words_remaining`.
70
+ `InsufficientBalanceError`, `words_remaining`; on `PlanRequiredError`, `upgrade_url`.
61
71
 
62
72
  | exception | API `code` |
63
73
  |---|---|
64
74
  | `AuthenticationError` | `missing_api_key`, `invalid_api_key` |
65
75
  | `NoSubscriptionError` | `no_subscription` |
66
76
  | `InsufficientBalanceError` | `insufficient_balance` |
77
+ | `ReverifyRequiredError` | `reverify_required` (403: the account owner must re-verify by email at writehuman.ai; not retried) |
78
+ | `PlanRequiredError` | `enhanced_requires_premium` (403: `enhanced=True` on a Standard plan; `upgrade_url` says where to upgrade; nothing charged; not retried) |
79
+ | `FeatureUnavailableError` | `enhanced_unavailable` (403: the enhanced model is switched off for everyone; send without `enhanced`; nothing charged; not retried) |
67
80
  | `InvalidRequestError` | `invalid_request`, `forbidden_param`, `word_limit` |
68
81
  | `ModerationError` | `moderation_blocked` |
69
82
  | `RateLimitError` | `rate_limited`, `auth_locked_out`, `queue_full`, `queue_timeout` |
@@ -24,6 +24,16 @@ print(score["ai_probability"], score["predicted_class"])
24
24
  print(wh.usage())
25
25
  ```
26
26
 
27
+ Premium plans and above can ask for the enhanced model. On a Standard plan this raises
28
+ `PlanRequiredError` (nothing is charged) with `upgrade_url` set; while the enhanced model is
29
+ switched off on the API it raises `FeatureUnavailableError`, so send without `enhanced`. Every
30
+ humanize response's `model` (`"standard"` or `"enhanced"`) says which model served it:
31
+
32
+ ```python
33
+ out = wh.humanize(text, enhanced=True)
34
+ print(out["model"]) # "enhanced"
35
+ ```
36
+
27
37
  Async, same methods:
28
38
 
29
39
  ```python
@@ -37,13 +47,16 @@ async with AsyncWriteHuman("wh_live_...") as wh:
37
47
 
38
48
  Every failure raises a subclass of `WriteHumanError` with `code` (the API's stable error
39
49
  code), `status`, `retry_after` (seconds, when the API sent one) and, on
40
- `InsufficientBalanceError`, `words_remaining`.
50
+ `InsufficientBalanceError`, `words_remaining`; on `PlanRequiredError`, `upgrade_url`.
41
51
 
42
52
  | exception | API `code` |
43
53
  |---|---|
44
54
  | `AuthenticationError` | `missing_api_key`, `invalid_api_key` |
45
55
  | `NoSubscriptionError` | `no_subscription` |
46
56
  | `InsufficientBalanceError` | `insufficient_balance` |
57
+ | `ReverifyRequiredError` | `reverify_required` (403: the account owner must re-verify by email at writehuman.ai; not retried) |
58
+ | `PlanRequiredError` | `enhanced_requires_premium` (403: `enhanced=True` on a Standard plan; `upgrade_url` says where to upgrade; nothing charged; not retried) |
59
+ | `FeatureUnavailableError` | `enhanced_unavailable` (403: the enhanced model is switched off for everyone; send without `enhanced`; nothing charged; not retried) |
47
60
  | `InvalidRequestError` | `invalid_request`, `forbidden_param`, `word_limit` |
48
61
  | `ModerationError` | `moderation_blocked` |
49
62
  | `RateLimitError` | `rate_limited`, `auth_locked_out`, `queue_full`, `queue_timeout` |
@@ -1,33 +1,33 @@
1
- [build-system]
2
- requires = ["setuptools>=68"]
3
- build-backend = "setuptools.build_meta"
4
-
5
- [project]
6
- name = "writehuman"
7
- version = "0.1.0"
8
- description = "Official WriteHuman API client for Python: humanize AI text, detect AI authorship, read usage."
9
- readme = "README.md"
10
- license = { text = "MIT" }
11
- requires-python = ">=3.9"
12
- dependencies = ["httpx>=0.25,<1"]
13
- keywords = ["writehuman", "humanize", "ai-detection", "api-client"]
14
- classifiers = [
15
- "Development Status :: 4 - Beta",
16
- "Intended Audience :: Developers",
17
- "License :: OSI Approved :: MIT License",
18
- "Programming Language :: Python :: 3",
19
- "Programming Language :: Python :: 3 :: Only",
20
- "Typing :: Typed",
21
- ]
22
-
23
- [project.urls]
24
- Homepage = "https://writehuman.ai/api/docs"
25
- Documentation = "https://writehuman.ai/api/docs"
26
- Source = "https://github.com/WriteHuman-ai/rephrase-api/tree/main/sdk/python"
27
- Changelog = "https://github.com/WriteHuman-ai/rephrase-api/blob/main/sdk/python/CHANGELOG.md"
28
-
29
- [tool.setuptools.packages.find]
30
- where = ["src"]
31
-
32
- [tool.setuptools.package-data]
33
- writehuman = ["py.typed"]
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "writehuman"
7
+ version = "0.2.0"
8
+ description = "Official WriteHuman API client for Python: humanize AI text, detect AI authorship, read usage."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ dependencies = ["httpx>=0.25,<1"]
13
+ keywords = ["writehuman", "humanize", "ai-detection", "api-client"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Typing :: Typed",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://writehuman.ai/api/docs"
25
+ Documentation = "https://writehuman.ai/api/docs"
26
+ Source = "https://github.com/WriteHuman-ai/rephrase-api/tree/main/sdk/python"
27
+ Changelog = "https://github.com/WriteHuman-ai/rephrase-api/blob/main/sdk/python/CHANGELOG.md"
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["src"]
31
+
32
+ [tool.setuptools.package-data]
33
+ writehuman = ["py.typed"]
@@ -1,47 +1,51 @@
1
- """Official WriteHuman API client for Python.
2
-
3
- from writehuman import WriteHuman
4
- wh = WriteHuman("wh_live_...")
5
- out = wh.humanize("Artificial intelligence has transformed numerous industries ...")
6
- print(out["results"][0])
7
-
8
- Docs: https://writehuman.ai/api/docs. Contract: https://api.writehuman.ai/openapi.json.
9
- """
10
-
11
- __version__ = "0.1.0"
12
-
13
- from .client import DEFAULT_BASE_URL, USER_AGENT, AsyncWriteHuman, WriteHuman # noqa: E402
14
- from .errors import ( # noqa: E402
15
- APIError,
16
- AuthenticationError,
17
- BillingError,
18
- ClientTimeoutError,
19
- ConnectionError,
20
- InsufficientBalanceError,
21
- InvalidRequestError,
22
- ModerationError,
23
- NoSubscriptionError,
24
- ProcessingError,
25
- ProtocolError,
26
- RateLimitError,
27
- ServerTimeoutError,
28
- ServiceUnavailableError,
29
- WriteHumanError,
30
- )
31
- from .types import ( # noqa: E402
32
- API_VERSION,
33
- DetectResponse,
34
- ErrorCode,
35
- HumanizeResponse,
36
- UsageResponse,
37
- WordsRemaining,
38
- )
39
-
40
- __all__ = [
41
- "__version__", "API_VERSION", "DEFAULT_BASE_URL", "USER_AGENT",
42
- "WriteHuman", "AsyncWriteHuman",
43
- "WriteHumanError", "APIError", "AuthenticationError", "NoSubscriptionError", "InsufficientBalanceError",
44
- "InvalidRequestError", "ModerationError", "RateLimitError", "ServiceUnavailableError", "ServerTimeoutError",
45
- "ProcessingError", "BillingError", "ProtocolError", "ClientTimeoutError", "ConnectionError",
46
- "HumanizeResponse", "DetectResponse", "UsageResponse", "WordsRemaining", "ErrorCode",
47
- ]
1
+ """Official WriteHuman API client for Python.
2
+
3
+ from writehuman import WriteHuman
4
+ wh = WriteHuman("wh_live_...")
5
+ out = wh.humanize("Artificial intelligence has transformed numerous industries ...")
6
+ print(out["results"][0])
7
+
8
+ Docs: https://writehuman.ai/api/docs. Contract: https://api.writehuman.ai/openapi.json.
9
+ """
10
+
11
+ __version__ = "0.2.0"
12
+
13
+ from .client import DEFAULT_BASE_URL, USER_AGENT, AsyncWriteHuman, WriteHuman # noqa: E402
14
+ from .errors import ( # noqa: E402
15
+ APIError,
16
+ AuthenticationError,
17
+ BillingError,
18
+ ClientTimeoutError,
19
+ ConnectionError,
20
+ FeatureUnavailableError,
21
+ InsufficientBalanceError,
22
+ InvalidRequestError,
23
+ ModerationError,
24
+ NoSubscriptionError,
25
+ PlanRequiredError,
26
+ ProcessingError,
27
+ ProtocolError,
28
+ RateLimitError,
29
+ ReverifyRequiredError,
30
+ ServerTimeoutError,
31
+ ServiceUnavailableError,
32
+ WriteHumanError,
33
+ )
34
+ from .types import ( # noqa: E402
35
+ API_VERSION,
36
+ DetectResponse,
37
+ ErrorCode,
38
+ HumanizeResponse,
39
+ UsageResponse,
40
+ WordsRemaining,
41
+ )
42
+
43
+ __all__ = [
44
+ "__version__", "API_VERSION", "DEFAULT_BASE_URL", "USER_AGENT",
45
+ "WriteHuman", "AsyncWriteHuman",
46
+ "WriteHumanError", "APIError", "AuthenticationError", "NoSubscriptionError", "InsufficientBalanceError",
47
+ "ReverifyRequiredError", "PlanRequiredError", "FeatureUnavailableError",
48
+ "InvalidRequestError", "ModerationError", "RateLimitError", "ServiceUnavailableError", "ServerTimeoutError",
49
+ "ProcessingError", "BillingError", "ProtocolError", "ClientTimeoutError", "ConnectionError",
50
+ "HumanizeResponse", "DetectResponse", "UsageResponse", "WordsRemaining", "ErrorCode",
51
+ ]
@@ -1,83 +1,83 @@
1
- """The request/retry policy shared by the sync and async clients. Pure functions, no I/O,
2
- so both clients are thin loops around it and the behaviour is identical. The policy is the
3
- one sdk/conformance/README.md specifies and the conformance scenarios enforce."""
4
- from typing import Any, Dict, Optional, Tuple
5
-
6
- from .errors import WriteHumanError, error_from_body
7
-
8
- RETRYABLE_STATUSES = frozenset({429, 503, 504})
9
-
10
- # Total timeouts per operation, in seconds: the 30-day p99 of successful calls with room,
11
- # under the API's own 170 s humanize deadline.
12
- DEFAULT_TIMEOUTS: Dict[str, float] = {"humanize": 120.0, "detect": 30.0, "usage": 15.0}
13
- DEFAULT_CONNECT_TIMEOUT = 10.0
14
- DEFAULT_MAX_RETRIES = 2
15
- DEFAULT_MAX_RETRY_AFTER = 60.0
16
- DEFAULT_FALLBACK_RETRY_AFTER = 2.0
17
-
18
- OPERATIONS = {
19
- # name: (method, path, idempotent)
20
- "humanize": ("POST", "/v1/humanize", False),
21
- "detect": ("POST", "/v1/detect", False),
22
- "usage": ("GET", "/v1/usage", True),
23
- }
24
-
25
-
26
- def parse_retry_after(value: Optional[str]) -> Optional[float]:
27
- if value is None:
28
- return None
29
- try:
30
- return max(0.0, float(value))
31
- except ValueError:
32
- return None
33
-
34
-
35
- class Policy:
36
- def __init__(self, *, max_retries: int = DEFAULT_MAX_RETRIES, max_retry_after: float = DEFAULT_MAX_RETRY_AFTER,
37
- fallback_retry_after: float = DEFAULT_FALLBACK_RETRY_AFTER):
38
- self.max_retries = max(0, int(max_retries))
39
- self.max_retry_after = float(max_retry_after)
40
- self.fallback_retry_after = float(fallback_retry_after)
41
-
42
- def wait_after_response(self, status: int, retry_after: Optional[float], attempt: int, *,
43
- from_api: bool = True, idempotent: bool = False) -> Optional[float]:
44
- """Seconds to sleep before retrying a non-2xx response, or None to raise now.
45
- Only 429/503/504 are retried, and only when the API itself answered (its error body):
46
- the contract guarantees those left nothing charged. The same status from a load
47
- balancer or proxy carries no such guarantee (a balancer 504 can follow a request the
48
- server went on to finish and bill), so it is treated like a transport error."""
49
- if status not in RETRYABLE_STATUSES:
50
- return None
51
- if not from_api:
52
- return self.wait_after_transport_error(idempotent, attempt)
53
- if attempt >= self.max_retries:
54
- return None
55
- if retry_after is None:
56
- return self.fallback_retry_after
57
- if retry_after > self.max_retry_after:
58
- return None # a 15-minute lockout is for the caller to decide about, not to sleep through
59
- return retry_after
60
-
61
- def wait_after_transport_error(self, idempotent: bool, attempt: int) -> Optional[float]:
62
- """A timeout or dropped connection: retried only for the read-only usage call. A
63
- billed POST may have been processed, so it is never resent automatically."""
64
- if not idempotent or attempt >= self.max_retries:
65
- return None
66
- return self.fallback_retry_after
67
-
68
-
69
- def interpret(status: int, headers: Any, body_text: str) -> Tuple[Optional[dict], Optional[WriteHumanError], Optional[float]]:
70
- """Turn a response into (data, None, retry_after) on success or (None, error, retry_after)."""
71
- import json
72
-
73
- retry_after = parse_retry_after(headers.get("Retry-After") if hasattr(headers, "get") else None)
74
- try:
75
- data = json.loads(body_text) if body_text else None
76
- except ValueError:
77
- data = None
78
- if 200 <= status < 300:
79
- if isinstance(data, dict):
80
- return data, None, retry_after
81
- from .errors import ProtocolError
82
- return None, ProtocolError(f"HTTP {status} with a body that is not JSON", status=status), retry_after
83
- return None, error_from_body(status, data, retry_after), retry_after
1
+ """The request/retry policy shared by the sync and async clients. Pure functions, no I/O,
2
+ so both clients are thin loops around it and the behaviour is identical. The policy is the
3
+ one sdk/conformance/README.md specifies and the conformance scenarios enforce."""
4
+ from typing import Any, Dict, Optional, Tuple
5
+
6
+ from .errors import WriteHumanError, error_from_body
7
+
8
+ RETRYABLE_STATUSES = frozenset({429, 503, 504})
9
+
10
+ # Total timeouts per operation, in seconds: the 30-day p99 of successful calls with room,
11
+ # under the API's own 170 s humanize deadline.
12
+ DEFAULT_TIMEOUTS: Dict[str, float] = {"humanize": 120.0, "detect": 30.0, "usage": 15.0}
13
+ DEFAULT_CONNECT_TIMEOUT = 10.0
14
+ DEFAULT_MAX_RETRIES = 2
15
+ DEFAULT_MAX_RETRY_AFTER = 60.0
16
+ DEFAULT_FALLBACK_RETRY_AFTER = 2.0
17
+
18
+ OPERATIONS = {
19
+ # name: (method, path, idempotent)
20
+ "humanize": ("POST", "/v1/humanize", False),
21
+ "detect": ("POST", "/v1/detect", False),
22
+ "usage": ("GET", "/v1/usage", True),
23
+ }
24
+
25
+
26
+ def parse_retry_after(value: Optional[str]) -> Optional[float]:
27
+ if value is None:
28
+ return None
29
+ try:
30
+ return max(0.0, float(value))
31
+ except ValueError:
32
+ return None
33
+
34
+
35
+ class Policy:
36
+ def __init__(self, *, max_retries: int = DEFAULT_MAX_RETRIES, max_retry_after: float = DEFAULT_MAX_RETRY_AFTER,
37
+ fallback_retry_after: float = DEFAULT_FALLBACK_RETRY_AFTER):
38
+ self.max_retries = max(0, int(max_retries))
39
+ self.max_retry_after = float(max_retry_after)
40
+ self.fallback_retry_after = float(fallback_retry_after)
41
+
42
+ def wait_after_response(self, status: int, retry_after: Optional[float], attempt: int, *,
43
+ from_api: bool = True, idempotent: bool = False) -> Optional[float]:
44
+ """Seconds to sleep before retrying a non-2xx response, or None to raise now.
45
+ Only 429/503/504 are retried, and only when the API itself answered (its error body):
46
+ the contract guarantees those left nothing charged. The same status from a load
47
+ balancer or proxy carries no such guarantee (a balancer 504 can follow a request the
48
+ server went on to finish and bill), so it is treated like a transport error."""
49
+ if status not in RETRYABLE_STATUSES:
50
+ return None
51
+ if not from_api:
52
+ return self.wait_after_transport_error(idempotent, attempt)
53
+ if attempt >= self.max_retries:
54
+ return None
55
+ if retry_after is None:
56
+ return self.fallback_retry_after
57
+ if retry_after > self.max_retry_after:
58
+ return None # a 15-minute lockout is for the caller to decide about, not to sleep through
59
+ return retry_after
60
+
61
+ def wait_after_transport_error(self, idempotent: bool, attempt: int) -> Optional[float]:
62
+ """A timeout or dropped connection: retried only for the read-only usage call. A
63
+ billed POST may have been processed, so it is never resent automatically."""
64
+ if not idempotent or attempt >= self.max_retries:
65
+ return None
66
+ return self.fallback_retry_after
67
+
68
+
69
+ def interpret(status: int, headers: Any, body_text: str) -> Tuple[Optional[dict], Optional[WriteHumanError], Optional[float]]:
70
+ """Turn a response into (data, None, retry_after) on success or (None, error, retry_after)."""
71
+ import json
72
+
73
+ retry_after = parse_retry_after(headers.get("Retry-After") if hasattr(headers, "get") else None)
74
+ try:
75
+ data = json.loads(body_text) if body_text else None
76
+ except ValueError:
77
+ data = None
78
+ if 200 <= status < 300:
79
+ if isinstance(data, dict):
80
+ return data, None, retry_after
81
+ from .errors import ProtocolError
82
+ return None, ProtocolError(f"HTTP {status} with a body that is not JSON", status=status), retry_after
83
+ return None, error_from_body(status, data, retry_after), retry_after
@@ -4,6 +4,7 @@
4
4
  wh = WriteHuman("wh_live_...")
5
5
  out = wh.humanize("Artificial intelligence has transformed ...", tone="professional")
6
6
  print(out["results"][0], out["words_remaining"]["total"])
7
+ out = wh.humanize("...", enhanced=True) # Premium plans: the enhanced model
7
8
 
8
9
  from writehuman import AsyncWriteHuman
9
10
  async with AsyncWriteHuman("wh_live_...") as wh:
@@ -70,12 +71,15 @@ class _Base:
70
71
  return httpx.Timeout(total, connect=min(self.connect_timeout, total))
71
72
 
72
73
  @staticmethod
73
- def _humanize_body(text: str, tone: Optional[str], language: Optional[str]) -> Dict[str, Any]:
74
+ def _humanize_body(text: str, tone: Optional[str], language: Optional[str], enhanced: bool) -> Dict[str, Any]:
74
75
  body: Dict[str, Any] = {"text": text}
75
76
  if tone is not None:
76
77
  body["tone"] = tone
77
78
  if language is not None:
78
79
  body["language"] = language
80
+ if enhanced:
81
+ # only when asked for, so a default request's body is unchanged
82
+ body["enhanced"] = True
79
83
  return body
80
84
 
81
85
  @staticmethod
@@ -105,8 +109,12 @@ class WriteHuman(_Base):
105
109
  self.close()
106
110
 
107
111
  def humanize(self, text: str, *, tone: Optional[str] = None, language: Optional[str] = None,
108
- timeout: Optional[float] = None) -> HumanizeResponse:
109
- return self._call("humanize", self._humanize_body(text, tone, language), timeout) # type: ignore[return-value]
112
+ enhanced: bool = False, timeout: Optional[float] = None) -> HumanizeResponse:
113
+ """`enhanced=True` serves the request with the enhanced model (Premium plans and above;
114
+ the response's `model` says which model served it). On a Standard plan it raises
115
+ PlanRequiredError with `upgrade_url`; while the enhanced model is switched off it raises
116
+ FeatureUnavailableError. Nothing is charged for either."""
117
+ return self._call("humanize", self._humanize_body(text, tone, language, enhanced), timeout) # type: ignore[return-value]
110
118
 
111
119
  def detect(self, text: str, *, language: Optional[str] = None, include_sentences: bool = False,
112
120
  timeout: Optional[float] = None) -> DetectResponse:
@@ -182,8 +190,12 @@ class AsyncWriteHuman(_Base):
182
190
  await self.aclose()
183
191
 
184
192
  async def humanize(self, text: str, *, tone: Optional[str] = None, language: Optional[str] = None,
185
- timeout: Optional[float] = None) -> HumanizeResponse:
186
- return await self._call("humanize", self._humanize_body(text, tone, language), timeout) # type: ignore[return-value]
193
+ enhanced: bool = False, timeout: Optional[float] = None) -> HumanizeResponse:
194
+ """`enhanced=True` serves the request with the enhanced model (Premium plans and above;
195
+ the response's `model` says which model served it). On a Standard plan it raises
196
+ PlanRequiredError with `upgrade_url`; while the enhanced model is switched off it raises
197
+ FeatureUnavailableError. Nothing is charged for either."""
198
+ return await self._call("humanize", self._humanize_body(text, tone, language, enhanced), timeout) # type: ignore[return-value]
187
199
 
188
200
  async def detect(self, text: str, *, language: Optional[str] = None, include_sentences: bool = False,
189
201
  timeout: Optional[float] = None) -> DetectResponse:
@@ -5,6 +5,7 @@
5
5
  status HTTP status, or None for client-side failures
6
6
  retry_after seconds the API asked us to wait (from the last response), or None
7
7
  words_remaining on InsufficientBalanceError: {"included", "topup", "total"}
8
+ upgrade_url on PlanRequiredError: where the account owner upgrades the plan
8
9
  """
9
10
  from typing import Optional
10
11
 
@@ -17,13 +18,15 @@ class WriteHumanError(Exception):
17
18
  kind = "api"
18
19
 
19
20
  def __init__(self, message: str, *, status: Optional[int] = None, code: Optional[str] = None,
20
- retry_after: Optional[float] = None, words_remaining: Optional[WordsRemaining] = None):
21
+ retry_after: Optional[float] = None, words_remaining: Optional[WordsRemaining] = None,
22
+ upgrade_url: Optional[str] = None):
21
23
  super().__init__(message)
22
24
  self.message = message
23
25
  self.status = status
24
26
  self.code = code
25
27
  self.retry_after = retry_after
26
28
  self.words_remaining = words_remaining
29
+ self.upgrade_url = upgrade_url
27
30
 
28
31
  def __repr__(self) -> str:
29
32
  return f"{type(self).__name__}({self.message!r}, status={self.status}, code={self.code!r})"
@@ -49,6 +52,26 @@ class InsufficientBalanceError(WriteHumanError):
49
52
  kind = "insufficient_balance"
50
53
 
51
54
 
55
+ class ReverifyRequiredError(WriteHumanError):
56
+ """reverify_required (403): the account must be re-verified by email. The account owner
57
+ signs in at writehuman.ai to continue. Never retried."""
58
+ kind = "reverify_required"
59
+
60
+
61
+ class PlanRequiredError(WriteHumanError):
62
+ """enhanced_requires_premium (403): the request asked for a feature the plan does not
63
+ include (`enhanced=True` on a Standard plan). `upgrade_url` is where to upgrade; or send
64
+ the request without the feature. Nothing is charged. Never retried."""
65
+ kind = "plan_required"
66
+
67
+
68
+ class FeatureUnavailableError(WriteHumanError):
69
+ """enhanced_unavailable (403): the feature the request asked for is switched off on the
70
+ API for every account right now (`enhanced=True` while the enhanced model is off). Send
71
+ the request without it. Upgrading does not help. Nothing is charged. Never retried."""
72
+ kind = "feature_unavailable"
73
+
74
+
52
75
  class InvalidRequestError(WriteHumanError):
53
76
  """invalid_request, forbidden_param, word_limit."""
54
77
  kind = "invalid_request"
@@ -104,6 +127,9 @@ KIND_BY_CODE = {
104
127
  "missing_api_key": "authentication", "invalid_api_key": "authentication",
105
128
  "no_subscription": "no_subscription",
106
129
  "insufficient_balance": "insufficient_balance",
130
+ "reverify_required": "reverify_required",
131
+ "enhanced_requires_premium": "plan_required",
132
+ "enhanced_unavailable": "feature_unavailable",
107
133
  "invalid_request": "invalid_request", "forbidden_param": "invalid_request", "word_limit": "invalid_request",
108
134
  "moderation_blocked": "moderation",
109
135
  "rate_limited": "rate_limit", "auth_locked_out": "rate_limit", "queue_full": "rate_limit", "queue_timeout": "rate_limit",
@@ -114,8 +140,8 @@ KIND_BY_CODE = {
114
140
  }
115
141
 
116
142
  CLASS_BY_KIND = {cls.kind: cls for cls in (
117
- APIError, AuthenticationError, NoSubscriptionError, InsufficientBalanceError, InvalidRequestError,
118
- ModerationError, RateLimitError, ServiceUnavailableError, ServerTimeoutError, ProcessingError, BillingError,
143
+ APIError, AuthenticationError, NoSubscriptionError, InsufficientBalanceError, ReverifyRequiredError,
144
+ PlanRequiredError, FeatureUnavailableError, InvalidRequestError, ModerationError, RateLimitError, ServiceUnavailableError, ServerTimeoutError, ProcessingError, BillingError,
119
145
  )}
120
146
 
121
147
 
@@ -130,5 +156,7 @@ def error_from_body(status: int, body: object, retry_after: Optional[float]) ->
130
156
  code: Optional[ErrorCode] = body["code"] # type: ignore[assignment]
131
157
  cls = CLASS_BY_KIND.get(KIND_BY_CODE.get(code or "", "api"), APIError)
132
158
  wr = body.get("words_remaining")
159
+ up = body.get("upgrade_url")
133
160
  return cls(body["error"], status=status, code=code, retry_after=retry_after,
134
- words_remaining=wr if isinstance(wr, dict) else None)
161
+ words_remaining=wr if isinstance(wr, dict) else None,
162
+ upgrade_url=up if isinstance(up, str) else None)
@@ -1,155 +1,173 @@
1
- # GENERATED from src/openapi.json by sdk/gen_types.py. Do not edit; run the generator.
2
- # Contract version 1.1.
3
- from typing import Any, List, Literal, Optional, TypedDict
4
-
5
- API_VERSION = "v1.1"
6
-
7
-
8
- class _HumanizeRequestOptional(TypedDict, total=False):
9
- # Optional writing tone. Omit for default style. A value outside this list is ignored.
10
- tone: Optional[Literal["professional", "academic", "blog", "casual", "creative", "scientific", "technical"]]
11
- # Target language (e.g. `English`, `Spanish`, `Chinese (Mandarin)`). Auto-detected if omitted.
12
- language: Optional[str]
13
-
14
-
15
- class HumanizeRequest(_HumanizeRequestOptional):
16
- # The text to humanize.
17
- text: str
18
-
19
-
20
- class WordsRemaining(TypedDict):
21
- # Words remaining in your monthly subscription allowance.
22
- included: int
23
- # Words remaining from purchased top-ups.
24
- topup: int
25
- # Total words available (included + topup).
26
- total: int
27
-
28
-
29
- class HumanizeResponse(TypedDict):
30
- # Unique identifier for this humanization request.
31
- id: str
32
- # Unix timestamp (milliseconds) when the request was received.
33
- created: int
34
- # Humanized text variations, sorted by score (best first).
35
- results: List[str]
36
- # Human-likeness score per result (0–1, higher = more human). `null` on Standard plans; array on Premium.
37
- scores: Optional[List[Optional[float]]]
38
- # Number of words billed for this request.
39
- input_words: int
40
- words_remaining: "WordsRemaining"
41
-
42
-
43
- class UsageResponse(TypedDict):
44
- # Monthly word allowance for your plan.
45
- included_words: int
46
- # Words consumed in the current billing cycle.
47
- words_used_this_cycle: int
48
- # Remaining purchased top-up words.
49
- topup_balance: int
50
- # Your plan. Every Premium size reports `api_premium`.
51
- tier: Literal["api_standard", "api_premium"]
52
- # API version.
53
- version: str
54
-
55
-
56
- class _DetectRequestOptional(TypedDict, total=False):
57
- # Language of the input (e.g. `English`, `Spanish`). Auto-detected if omitted. Non-English text is translated to English before scoring.
58
- language: Optional[str]
59
- # Include per-sentence AI probabilities in the response.
60
- include_sentences: bool
61
-
62
-
63
- class DetectRequest(_DetectRequestOptional):
64
- # The text to score.
65
- text: str
66
-
67
-
68
- class _DetectResponseClassProbabilitiesOptional(TypedDict, total=False):
69
- ai: Optional[float]
70
- human: Optional[float]
71
- mixed: Optional[float]
72
-
73
-
74
- class DetectResponseClassProbabilities(_DetectResponseClassProbabilitiesOptional):
75
- """Probabilities for each class."""
76
- pass
77
-
78
-
79
- class DetectResponseRequestMetaDocLength(TypedDict):
80
- """Length of the scored text."""
81
- chars: Optional[int]
82
- tokens: Optional[int]
83
-
84
-
85
- class DetectResponseRequestMeta(TypedDict):
86
- # Length of the scored text.
87
- doc_length: "DetectResponseRequestMetaDocLength"
88
-
89
-
90
- class DetectResponseSentencesItem(TypedDict):
91
- text: Optional[str]
92
- ai_prob: Optional[float]
93
-
94
-
95
- class _DetectResponseOptional(TypedDict, total=False):
96
- # Per-sentence AI probabilities. Present only when `include_sentences` is `true`.
97
- sentences: List["DetectResponseSentencesItem"]
98
-
99
-
100
- class DetectResponse(_DetectResponseOptional):
101
- # Identifier for this detection scan.
102
- scanId: Optional[str]
103
- # Detector model version.
104
- version: Optional[str]
105
- # Detector build identifier.
106
- versionID: Optional[str]
107
- # Probability the text is AI-generated (`1` = AI — opposite orientation of `/v1/humanize` scores).
108
- ai_probability: Optional[float]
109
- # Overall label for the document.
110
- predicted_class: Optional[Literal["ai", "human", "mixed"]]
111
- # Probabilities for each class.
112
- class_probabilities: "DetectResponseClassProbabilities"
113
- request_meta: "DetectResponseRequestMeta"
114
- # Detected (or supplied) language of the input.
115
- language: str
116
-
117
-
118
- ErrorCode = Literal[
119
- "invalid_request",
120
- "forbidden_param",
121
- "word_limit",
122
- "moderation_blocked",
123
- "missing_api_key",
124
- "invalid_api_key",
125
- "no_subscription",
126
- "insufficient_balance",
127
- "rate_limited",
128
- "auth_locked_out",
129
- "queue_full",
130
- "queue_timeout",
131
- "at_capacity",
132
- "billing_unavailable",
133
- "detector_unavailable",
134
- "timeout",
135
- "processing_failed",
136
- "no_results",
137
- "safety_limit",
138
- "billing_failed",
139
- ]
140
-
141
-
142
- class ErrorResponse(TypedDict):
143
- # Human-readable error message.
144
- error: str
145
- code: "ErrorCode"
146
-
147
-
148
- class _ErrorResponseWithBalanceOptional(TypedDict, total=False):
149
- words_remaining: "WordsRemaining"
150
-
151
-
152
- class ErrorResponseWithBalance(_ErrorResponseWithBalanceOptional):
153
- # Human-readable error message.
154
- error: str
155
- code: "ErrorCode"
1
+ # GENERATED from src/openapi.json by sdk/gen_types.py. Do not edit; run the generator.
2
+ # Contract version 1.2.
3
+ from typing import Any, List, Literal, Optional, TypedDict
4
+
5
+ API_VERSION = "v1.2"
6
+
7
+
8
+ class _HumanizeRequestOptional(TypedDict, total=False):
9
+ # Optional writing tone. Omit for default style. A value outside this list is ignored.
10
+ tone: Optional[Literal["professional", "academic", "blog", "casual", "creative", "scientific", "technical"]]
11
+ # Target language (e.g. `English`, `Spanish`, `Chinese (Mandarin)`). Auto-detected if omitted.
12
+ language: Optional[str]
13
+ # Serve this request with our enhanced model. Premium plans and above; on a Standard plan `true` returns `403 enhanced_requires_premium`. Must be a JSON boolean.
14
+ enhanced: bool
15
+
16
+
17
+ class HumanizeRequest(_HumanizeRequestOptional):
18
+ # The text to humanize.
19
+ text: str
20
+
21
+
22
+ class WordsRemaining(TypedDict):
23
+ # Words remaining in your monthly subscription allowance.
24
+ included: int
25
+ # Words remaining from purchased top-ups.
26
+ topup: int
27
+ # Total words available (included + topup).
28
+ total: int
29
+
30
+
31
+ class HumanizeResponse(TypedDict):
32
+ # Unique identifier for this humanization request.
33
+ id: str
34
+ # Unix timestamp (milliseconds) when the request was received.
35
+ created: int
36
+ # Humanized text variations, sorted by score (best first).
37
+ results: List[str]
38
+ # Human-likeness score per result (0–1, higher = more human). `null` on Standard plans; array on Premium.
39
+ scores: Optional[List[Optional[float]]]
40
+ # Number of words billed for this request.
41
+ input_words: int
42
+ words_remaining: "WordsRemaining"
43
+ # Which model served the request: `enhanced` when the request was sent with `enhanced: true`, else `standard`.
44
+ model: Literal["standard", "enhanced"]
45
+
46
+
47
+ class UsageResponse(TypedDict):
48
+ # Monthly word allowance for your plan.
49
+ included_words: int
50
+ # Words consumed in the current billing cycle.
51
+ words_used_this_cycle: int
52
+ # Remaining purchased top-up words.
53
+ topup_balance: int
54
+ # Your plan. Every Premium size reports `api_premium`.
55
+ tier: Literal["api_standard", "api_premium"]
56
+ # API version.
57
+ version: str
58
+
59
+
60
+ class _DetectRequestOptional(TypedDict, total=False):
61
+ # Language of the input (e.g. `English`, `Spanish`). Auto-detected if omitted. Non-English text is translated to English before scoring.
62
+ language: Optional[str]
63
+ # Include per-sentence AI probabilities in the response.
64
+ include_sentences: bool
65
+
66
+
67
+ class DetectRequest(_DetectRequestOptional):
68
+ # The text to score.
69
+ text: str
70
+
71
+
72
+ class _DetectResponseClassProbabilitiesOptional(TypedDict, total=False):
73
+ ai: Optional[float]
74
+ human: Optional[float]
75
+ mixed: Optional[float]
76
+
77
+
78
+ class DetectResponseClassProbabilities(_DetectResponseClassProbabilitiesOptional):
79
+ """Probabilities for each class."""
80
+ pass
81
+
82
+
83
+ class DetectResponseRequestMetaDocLength(TypedDict):
84
+ """Length of the scored text."""
85
+ chars: Optional[int]
86
+ tokens: Optional[int]
87
+
88
+
89
+ class DetectResponseRequestMeta(TypedDict):
90
+ # Length of the scored text.
91
+ doc_length: "DetectResponseRequestMetaDocLength"
92
+
93
+
94
+ class DetectResponseSentencesItem(TypedDict):
95
+ text: Optional[str]
96
+ ai_prob: Optional[float]
97
+
98
+
99
+ class _DetectResponseOptional(TypedDict, total=False):
100
+ # Per-sentence AI probabilities. Present only when `include_sentences` is `true`.
101
+ sentences: List["DetectResponseSentencesItem"]
102
+
103
+
104
+ class DetectResponse(_DetectResponseOptional):
105
+ # Identifier for this detection scan.
106
+ scanId: Optional[str]
107
+ # Detector model version.
108
+ version: Optional[str]
109
+ # Detector build identifier.
110
+ versionID: Optional[str]
111
+ # Probability the text is AI-generated (`1` = AI — opposite orientation of `/v1/humanize` scores).
112
+ ai_probability: Optional[float]
113
+ # Overall label for the document.
114
+ predicted_class: Optional[Literal["ai", "human", "mixed"]]
115
+ # Probabilities for each class.
116
+ class_probabilities: "DetectResponseClassProbabilities"
117
+ request_meta: "DetectResponseRequestMeta"
118
+ # Detected (or supplied) language of the input.
119
+ language: str
120
+
121
+
122
+ ErrorCode = Literal[
123
+ "invalid_request",
124
+ "forbidden_param",
125
+ "word_limit",
126
+ "moderation_blocked",
127
+ "missing_api_key",
128
+ "invalid_api_key",
129
+ "no_subscription",
130
+ "insufficient_balance",
131
+ "reverify_required",
132
+ "enhanced_requires_premium",
133
+ "enhanced_unavailable",
134
+ "rate_limited",
135
+ "auth_locked_out",
136
+ "queue_full",
137
+ "queue_timeout",
138
+ "at_capacity",
139
+ "billing_unavailable",
140
+ "detector_unavailable",
141
+ "timeout",
142
+ "processing_failed",
143
+ "no_results",
144
+ "safety_limit",
145
+ "billing_failed",
146
+ ]
147
+
148
+
149
+ class ErrorResponse(TypedDict):
150
+ # Human-readable error message.
151
+ error: str
152
+ code: "ErrorCode"
153
+
154
+
155
+ class _ErrorResponseWithBalanceOptional(TypedDict, total=False):
156
+ words_remaining: "WordsRemaining"
157
+
158
+
159
+ class ErrorResponseWithBalance(_ErrorResponseWithBalanceOptional):
160
+ # Human-readable error message.
161
+ error: str
162
+ code: "ErrorCode"
163
+
164
+
165
+ class _ErrorResponseWithUpgradeOptional(TypedDict, total=False):
166
+ # Where to upgrade the plan. Present on `enhanced_requires_premium`.
167
+ upgrade_url: str
168
+
169
+
170
+ class ErrorResponseWithUpgrade(_ErrorResponseWithUpgradeOptional):
171
+ # Human-readable error message.
172
+ error: str
173
+ code: "ErrorCode"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: writehuman
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Official WriteHuman API client for Python: humanize AI text, detect AI authorship, read usage.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://writehuman.ai/api/docs
@@ -44,6 +44,16 @@ print(score["ai_probability"], score["predicted_class"])
44
44
  print(wh.usage())
45
45
  ```
46
46
 
47
+ Premium plans and above can ask for the enhanced model. On a Standard plan this raises
48
+ `PlanRequiredError` (nothing is charged) with `upgrade_url` set; while the enhanced model is
49
+ switched off on the API it raises `FeatureUnavailableError`, so send without `enhanced`. Every
50
+ humanize response's `model` (`"standard"` or `"enhanced"`) says which model served it:
51
+
52
+ ```python
53
+ out = wh.humanize(text, enhanced=True)
54
+ print(out["model"]) # "enhanced"
55
+ ```
56
+
47
57
  Async, same methods:
48
58
 
49
59
  ```python
@@ -57,13 +67,16 @@ async with AsyncWriteHuman("wh_live_...") as wh:
57
67
 
58
68
  Every failure raises a subclass of `WriteHumanError` with `code` (the API's stable error
59
69
  code), `status`, `retry_after` (seconds, when the API sent one) and, on
60
- `InsufficientBalanceError`, `words_remaining`.
70
+ `InsufficientBalanceError`, `words_remaining`; on `PlanRequiredError`, `upgrade_url`.
61
71
 
62
72
  | exception | API `code` |
63
73
  |---|---|
64
74
  | `AuthenticationError` | `missing_api_key`, `invalid_api_key` |
65
75
  | `NoSubscriptionError` | `no_subscription` |
66
76
  | `InsufficientBalanceError` | `insufficient_balance` |
77
+ | `ReverifyRequiredError` | `reverify_required` (403: the account owner must re-verify by email at writehuman.ai; not retried) |
78
+ | `PlanRequiredError` | `enhanced_requires_premium` (403: `enhanced=True` on a Standard plan; `upgrade_url` says where to upgrade; nothing charged; not retried) |
79
+ | `FeatureUnavailableError` | `enhanced_unavailable` (403: the enhanced model is switched off for everyone; send without `enhanced`; nothing charged; not retried) |
67
80
  | `InvalidRequestError` | `invalid_request`, `forbidden_param`, `word_limit` |
68
81
  | `ModerationError` | `moderation_blocked` |
69
82
  | `RateLimitError` | `rate_limited`, `auth_locked_out`, `queue_full`, `queue_timeout` |
File without changes