classer 0.0.3__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.
@@ -0,0 +1 @@
1
+ sst.pyi
classer-0.0.3/PKG-INFO ADDED
@@ -0,0 +1,162 @@
1
+ Metadata-Version: 2.4
2
+ Name: classer
3
+ Version: 0.0.3
4
+ Summary: High-performance AI classification — <200ms latency, up to 100x cheaper, beats GPT-5 mini accuracy
5
+ Project-URL: Homepage, https://classer.ai
6
+ Project-URL: Documentation, https://docs.classer.ai
7
+ Project-URL: Repository, https://github.com/classer-ai/classer-python
8
+ Author: Classer.ai
9
+ License-Expression: MIT
10
+ Keywords: ai,classification,llm,machine-learning,nlp,text-classification
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Requires-Python: >=3.9
21
+ Requires-Dist: httpx>=0.25.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
24
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
25
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ exit
29
+ # [classer](https://classer.ai)
30
+
31
+ High-performance AI classification
32
+
33
+ <200ms latency · up to 100x cheaper · beats GPT-5 mini accuracy · self-calibrating accuracy · no prompt engineering
34
+
35
+ ## Installation
36
+
37
+ ```bash
38
+ pip install classer
39
+ ```
40
+
41
+ ## Quick Start
42
+
43
+ ```python
44
+ import classer
45
+
46
+ # Single-label classification
47
+ result = classer.classify(
48
+ text="I can't log in and need a password reset.",
49
+ labels=["billing", "technical_support", "sales", "spam"]
50
+ )
51
+ print(result.label) # "technical_support"
52
+ print(result.confidence) # 0.94
53
+
54
+ # With descriptions for better accuracy
55
+ lead = classer.classify(
56
+ text="We need a solution for 500 users, what's your enterprise pricing?",
57
+ labels=["hot", "warm", "cold"],
58
+ descriptions={
59
+ "hot": "Ready to buy, asking for pricing or demo",
60
+ "warm": "Interested but exploring options",
61
+ "cold": "Just browsing, no clear intent"
62
+ }
63
+ )
64
+ print(lead.label) # "hot"
65
+
66
+ # Multi-label tagging
67
+ result = classer.tag(
68
+ text="Breaking: Tech stocks surge amid AI boom",
69
+ labels=["politics", "technology", "finance", "sports"],
70
+ threshold=0.3
71
+ )
72
+ for t in result.labels:
73
+ print(f"{t.label}: {t.confidence}")
74
+ # technology: 0.92
75
+ # finance: 0.78
76
+ ```
77
+
78
+ ## Configuration
79
+
80
+ No API key is needed to get started. To unlock higher rate limits, get an API key from [classer.ai/api-keys](https://classer.ai/api-keys).
81
+
82
+ ```bash
83
+ export CLASSER_API_KEY=your-api-key
84
+ ```
85
+
86
+ Or configure programmatically:
87
+
88
+ ```python
89
+ from classer import ClasserClient
90
+
91
+ client = ClasserClient(
92
+ api_key="your-api-key"
93
+ )
94
+ ```
95
+
96
+ ## API Reference
97
+
98
+ ### `classify(text, labels=None, classifier=None, descriptions=None, speed=None, cache=None)`
99
+
100
+ Classify text into exactly one of the provided labels.
101
+
102
+ ```python
103
+ result = classer.classify(
104
+ text="Text to classify",
105
+ labels=["label1", "label2"], # 1-200 possible labels
106
+ descriptions={"label1": "Description for better accuracy"},
107
+ speed="standard", # "standard" (default, <1s) or "fast" (<200ms)
108
+ cache=True # Set to False to bypass cache. Default: True
109
+ )
110
+
111
+ result.label # Selected label
112
+ result.confidence # 0-1 confidence score
113
+ result.tokens # Total tokens used
114
+ result.latency_ms # Processing time in ms
115
+ result.cached # Whether served from cache
116
+ ```
117
+
118
+ ### `tag(text, labels=None, classifier=None, descriptions=None, threshold=None, speed=None, cache=None)`
119
+
120
+ Multi-label tagging — returns all labels above a confidence threshold.
121
+
122
+ ```python
123
+ result = classer.tag(
124
+ text="Text to tag",
125
+ labels=["label1", "label2"], # 1-200 possible labels
126
+ descriptions={"label1": "Description"},
127
+ threshold=0.3, # Default: 0.3
128
+ speed="standard", # "standard" (default, <1s) or "fast" (<200ms)
129
+ cache=True # Set to False to bypass cache. Default: True
130
+ )
131
+
132
+ for t in result.labels:
133
+ print(f"{t.label}: {t.confidence}")
134
+
135
+ result.tokens # Total tokens used
136
+ result.latency_ms # Processing time in ms
137
+ result.cached # Whether served from cache
138
+ ```
139
+
140
+ ## Error Handling
141
+
142
+ ```python
143
+ from classer import ClasserError
144
+
145
+ try:
146
+ result = classer.classify(text="hello", labels=["a", "b"])
147
+ except ClasserError as e:
148
+ print(e.status) # HTTP status code
149
+ print(e.detail) # Error detail from API
150
+ ```
151
+
152
+ ## Documentation
153
+
154
+ Full API reference and guides at [docs.classer.ai](https://docs.classer.ai).
155
+
156
+ ## GitHub
157
+
158
+ [github.com/classer-ai/classer-python](https://github.com/classer-ai/classer-python)
159
+
160
+ ## License
161
+
162
+ MIT
@@ -0,0 +1,135 @@
1
+ exit
2
+ # [classer](https://classer.ai)
3
+
4
+ High-performance AI classification
5
+
6
+ <200ms latency · up to 100x cheaper · beats GPT-5 mini accuracy · self-calibrating accuracy · no prompt engineering
7
+
8
+ ## Installation
9
+
10
+ ```bash
11
+ pip install classer
12
+ ```
13
+
14
+ ## Quick Start
15
+
16
+ ```python
17
+ import classer
18
+
19
+ # Single-label classification
20
+ result = classer.classify(
21
+ text="I can't log in and need a password reset.",
22
+ labels=["billing", "technical_support", "sales", "spam"]
23
+ )
24
+ print(result.label) # "technical_support"
25
+ print(result.confidence) # 0.94
26
+
27
+ # With descriptions for better accuracy
28
+ lead = classer.classify(
29
+ text="We need a solution for 500 users, what's your enterprise pricing?",
30
+ labels=["hot", "warm", "cold"],
31
+ descriptions={
32
+ "hot": "Ready to buy, asking for pricing or demo",
33
+ "warm": "Interested but exploring options",
34
+ "cold": "Just browsing, no clear intent"
35
+ }
36
+ )
37
+ print(lead.label) # "hot"
38
+
39
+ # Multi-label tagging
40
+ result = classer.tag(
41
+ text="Breaking: Tech stocks surge amid AI boom",
42
+ labels=["politics", "technology", "finance", "sports"],
43
+ threshold=0.3
44
+ )
45
+ for t in result.labels:
46
+ print(f"{t.label}: {t.confidence}")
47
+ # technology: 0.92
48
+ # finance: 0.78
49
+ ```
50
+
51
+ ## Configuration
52
+
53
+ No API key is needed to get started. To unlock higher rate limits, get an API key from [classer.ai/api-keys](https://classer.ai/api-keys).
54
+
55
+ ```bash
56
+ export CLASSER_API_KEY=your-api-key
57
+ ```
58
+
59
+ Or configure programmatically:
60
+
61
+ ```python
62
+ from classer import ClasserClient
63
+
64
+ client = ClasserClient(
65
+ api_key="your-api-key"
66
+ )
67
+ ```
68
+
69
+ ## API Reference
70
+
71
+ ### `classify(text, labels=None, classifier=None, descriptions=None, speed=None, cache=None)`
72
+
73
+ Classify text into exactly one of the provided labels.
74
+
75
+ ```python
76
+ result = classer.classify(
77
+ text="Text to classify",
78
+ labels=["label1", "label2"], # 1-200 possible labels
79
+ descriptions={"label1": "Description for better accuracy"},
80
+ speed="standard", # "standard" (default, <1s) or "fast" (<200ms)
81
+ cache=True # Set to False to bypass cache. Default: True
82
+ )
83
+
84
+ result.label # Selected label
85
+ result.confidence # 0-1 confidence score
86
+ result.tokens # Total tokens used
87
+ result.latency_ms # Processing time in ms
88
+ result.cached # Whether served from cache
89
+ ```
90
+
91
+ ### `tag(text, labels=None, classifier=None, descriptions=None, threshold=None, speed=None, cache=None)`
92
+
93
+ Multi-label tagging — returns all labels above a confidence threshold.
94
+
95
+ ```python
96
+ result = classer.tag(
97
+ text="Text to tag",
98
+ labels=["label1", "label2"], # 1-200 possible labels
99
+ descriptions={"label1": "Description"},
100
+ threshold=0.3, # Default: 0.3
101
+ speed="standard", # "standard" (default, <1s) or "fast" (<200ms)
102
+ cache=True # Set to False to bypass cache. Default: True
103
+ )
104
+
105
+ for t in result.labels:
106
+ print(f"{t.label}: {t.confidence}")
107
+
108
+ result.tokens # Total tokens used
109
+ result.latency_ms # Processing time in ms
110
+ result.cached # Whether served from cache
111
+ ```
112
+
113
+ ## Error Handling
114
+
115
+ ```python
116
+ from classer import ClasserError
117
+
118
+ try:
119
+ result = classer.classify(text="hello", labels=["a", "b"])
120
+ except ClasserError as e:
121
+ print(e.status) # HTTP status code
122
+ print(e.detail) # Error detail from API
123
+ ```
124
+
125
+ ## Documentation
126
+
127
+ Full API reference and guides at [docs.classer.ai](https://docs.classer.ai).
128
+
129
+ ## GitHub
130
+
131
+ [github.com/classer-ai/classer-python](https://github.com/classer-ai/classer-python)
132
+
133
+ ## License
134
+
135
+ MIT
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "classer"
7
+ version = "0.0.3"
8
+ description = "High-performance AI classification — <200ms latency, up to 100x cheaper, beats GPT-5 mini accuracy"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ authors = [{ name = "Classer.ai" }]
12
+ keywords = ["ai", "classification", "nlp", "machine-learning", "text-classification", "llm"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.9",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
23
+ ]
24
+ requires-python = ">=3.9"
25
+ dependencies = ["httpx>=0.25.0"]
26
+
27
+ [project.optional-dependencies]
28
+ dev = ["pytest>=8.0.0", "pytest-asyncio>=0.23.0", "ruff>=0.1.0"]
29
+
30
+ [project.urls]
31
+ Homepage = "https://classer.ai"
32
+ Documentation = "https://docs.classer.ai"
33
+ Repository = "https://github.com/classer-ai/classer-python"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/classer"]
37
+
38
+ [tool.ruff]
39
+ line-length = 100
40
+ target-version = "py39"
41
+
42
+ [tool.ruff.lint]
43
+ select = ["E", "F", "I", "W"]
44
+
45
+ [tool.pytest.ini_options]
46
+ asyncio_mode = "auto"
47
+ testpaths = ["tests"]
@@ -0,0 +1,69 @@
1
+ """Classer SDK - Low-cost, fast AI classification API."""
2
+
3
+ from typing import Optional
4
+
5
+ from .client import ClasserClient
6
+ from .exceptions import ClasserError
7
+ from .types import (
8
+ ClassifyResponse,
9
+ TagLabel,
10
+ TagResponse,
11
+ )
12
+
13
+ __all__ = [
14
+ "ClasserClient",
15
+ "ClasserError",
16
+ "ClassifyResponse",
17
+ "TagLabel",
18
+ "TagResponse",
19
+ "classify",
20
+ "tag",
21
+ ]
22
+
23
+ # Default client instance
24
+ _default_client: ClasserClient | None = None
25
+
26
+
27
+ def _get_default_client() -> ClasserClient:
28
+ global _default_client
29
+ if _default_client is None:
30
+ _default_client = ClasserClient()
31
+ return _default_client
32
+
33
+
34
+ def classify(
35
+ text: str,
36
+ labels: Optional[list[str]] = None,
37
+ classifier: Optional[str] = None,
38
+ descriptions: Optional[dict[str, str]] = None,
39
+ speed: Optional[str] = None,
40
+ cache: Optional[bool] = None,
41
+ ) -> ClassifyResponse:
42
+ """Classify text into one of the provided labels (single-label).
43
+
44
+ See ClasserClient.classify for full parameter documentation.
45
+ """
46
+ return _get_default_client().classify(
47
+ text, labels=labels, classifier=classifier,
48
+ descriptions=descriptions, speed=speed, cache=cache,
49
+ )
50
+
51
+
52
+ def tag(
53
+ text: str,
54
+ labels: Optional[list[str]] = None,
55
+ classifier: Optional[str] = None,
56
+ descriptions: Optional[dict[str, str]] = None,
57
+ threshold: Optional[float] = None,
58
+ speed: Optional[str] = None,
59
+ cache: Optional[bool] = None,
60
+ ) -> TagResponse:
61
+ """Tag text with multiple labels (multi-label).
62
+
63
+ See ClasserClient.tag for full parameter documentation.
64
+ """
65
+ return _get_default_client().tag(
66
+ text, labels=labels, classifier=classifier,
67
+ descriptions=descriptions, threshold=threshold,
68
+ speed=speed, cache=cache,
69
+ )
@@ -0,0 +1,186 @@
1
+ """Classer client implementation."""
2
+
3
+ import os
4
+ from typing import Optional
5
+
6
+ import httpx
7
+
8
+ from .exceptions import ClasserError
9
+ from .types import (
10
+ ClassifyResponse,
11
+ TagLabel,
12
+ TagResponse,
13
+ )
14
+
15
+
16
+ class ClasserClient:
17
+ """Client for the Classer API."""
18
+
19
+ BASE_URL = "https://api.classer.ai"
20
+
21
+ def __init__(
22
+ self,
23
+ api_key: Optional[str] = None,
24
+ timeout: float = 30.0,
25
+ ):
26
+ """
27
+ Initialize the Classer client.
28
+
29
+ Args:
30
+ api_key: API key for authentication. Falls back to CLASSER_API_KEY env var.
31
+ timeout: Request timeout in seconds.
32
+ """
33
+ self.api_key = api_key or os.environ.get("CLASSER_API_KEY", "")
34
+ self.timeout = timeout
35
+
36
+ def _request(self, endpoint: str, body: dict) -> dict:
37
+ """Make a POST request to the API."""
38
+ url = f"{self.BASE_URL}{endpoint}"
39
+
40
+ headers = {"Content-Type": "application/json"}
41
+ if self.api_key:
42
+ headers["Authorization"] = f"Bearer {self.api_key}"
43
+
44
+ response = httpx.post(url, json=body, headers=headers, timeout=self.timeout)
45
+
46
+ if not response.is_success:
47
+ detail = None
48
+ try:
49
+ error_data = response.json()
50
+ detail = error_data.get("detail") if isinstance(error_data, dict) else None
51
+ except (ValueError, TypeError):
52
+ detail = response.text[:200] if response.text else None
53
+ raise ClasserError(
54
+ f"Request failed with status {response.status_code}",
55
+ status=response.status_code,
56
+ detail=detail,
57
+ )
58
+
59
+ return response.json()
60
+
61
+ @staticmethod
62
+ def _build_body(
63
+ text: str,
64
+ labels: Optional[list[str]] = None,
65
+ classifier: Optional[str] = None,
66
+ descriptions: Optional[dict[str, str]] = None,
67
+ speed: Optional[str] = None,
68
+ cache: Optional[bool] = None,
69
+ threshold: Optional[float] = None,
70
+ ) -> dict:
71
+ """Build a request body dict, omitting None/empty fields."""
72
+ body: dict = {"text": text}
73
+ if classifier:
74
+ body["classifier"] = classifier
75
+ if labels:
76
+ body["labels"] = labels
77
+ if descriptions:
78
+ body["descriptions"] = descriptions
79
+ if threshold is not None:
80
+ body["threshold"] = threshold
81
+ if speed:
82
+ body["speed"] = speed
83
+ if cache is not None:
84
+ body["cache"] = cache
85
+ return body
86
+
87
+ def classify(
88
+ self,
89
+ text: str,
90
+ labels: Optional[list[str]] = None,
91
+ classifier: Optional[str] = None,
92
+ descriptions: Optional[dict[str, str]] = None,
93
+ speed: Optional[str] = None,
94
+ cache: Optional[bool] = None,
95
+ ) -> ClassifyResponse:
96
+ """
97
+ Classify text into one of the provided labels (single-label).
98
+
99
+ Args:
100
+ text: Text to classify.
101
+ labels: List of possible labels (1-200).
102
+ classifier: Saved classifier name or "name@vN" reference.
103
+ descriptions: Maps label name to description for better accuracy.
104
+ speed: Speed tier — "standard" (default, <1s) or "fast" (<200ms).
105
+ cache: Set to False to bypass cache. Default: True.
106
+
107
+ Returns:
108
+ ClassifyResponse with label and confidence.
109
+
110
+ Example:
111
+ >>> result = classer.classify(
112
+ ... text="I can't log in",
113
+ ... labels=["billing", "technical_support", "sales"]
114
+ ... )
115
+ >>> print(result.label) # "technical_support"
116
+ """
117
+ body = self._build_body(
118
+ text, labels=labels, classifier=classifier,
119
+ descriptions=descriptions, speed=speed, cache=cache,
120
+ )
121
+
122
+ data = self._request("/v1/classify", body)
123
+
124
+ return ClassifyResponse(
125
+ label=data.get("label"),
126
+ confidence=data.get("confidence"),
127
+ tokens=data.get("tokens", 0),
128
+ latency_ms=data.get("latency_ms", 0),
129
+ cached=data.get("cached", False),
130
+ public=data.get("public"),
131
+ )
132
+
133
+ def tag(
134
+ self,
135
+ text: str,
136
+ labels: Optional[list[str]] = None,
137
+ classifier: Optional[str] = None,
138
+ descriptions: Optional[dict[str, str]] = None,
139
+ threshold: Optional[float] = None,
140
+ speed: Optional[str] = None,
141
+ cache: Optional[bool] = None,
142
+ ) -> TagResponse:
143
+ """
144
+ Tag text with multiple labels that exceed a confidence threshold.
145
+
146
+ Args:
147
+ text: Text to tag.
148
+ labels: List of possible labels (1-200).
149
+ classifier: Saved classifier name or "name@vN" reference.
150
+ descriptions: Maps label name to description for better accuracy.
151
+ threshold: Confidence threshold (0-1). Default: 0.3.
152
+ speed: Speed tier — "standard" (default, <1s) or "fast" (<200ms).
153
+ cache: Set to False to bypass cache. Default: True.
154
+
155
+ Returns:
156
+ TagResponse with labels list (each has label and confidence).
157
+
158
+ Example:
159
+ >>> result = classer.tag(
160
+ ... text="Breaking: Tech stocks surge amid AI boom",
161
+ ... labels=["politics", "technology", "finance", "sports"],
162
+ ... threshold=0.3
163
+ ... )
164
+ >>> for tag in result.labels:
165
+ ... print(f"{tag.label}: {tag.confidence}")
166
+ """
167
+ body = self._build_body(
168
+ text, labels=labels, classifier=classifier,
169
+ descriptions=descriptions, threshold=threshold,
170
+ speed=speed, cache=cache,
171
+ )
172
+
173
+ data = self._request("/v1/tag", body)
174
+
175
+ tag_labels = [
176
+ TagLabel(label=item["label"], confidence=item["confidence"])
177
+ for item in data.get("labels") or []
178
+ ]
179
+
180
+ return TagResponse(
181
+ labels=tag_labels,
182
+ tokens=data.get("tokens", 0),
183
+ latency_ms=data.get("latency_ms", 0),
184
+ cached=data.get("cached", False),
185
+ public=data.get("public"),
186
+ )
@@ -0,0 +1,26 @@
1
+ """Exception classes for the Classer SDK."""
2
+
3
+ from typing import Optional
4
+
5
+
6
+ class ClasserError(Exception):
7
+ """Base exception for Classer SDK errors."""
8
+
9
+ def __init__(
10
+ self,
11
+ message: str,
12
+ status: Optional[int] = None,
13
+ detail: Optional[str] = None,
14
+ ):
15
+ super().__init__(message)
16
+ self.message = message
17
+ self.status = status
18
+ self.detail = detail
19
+
20
+ def __str__(self) -> str:
21
+ parts = [self.message]
22
+ if self.status:
23
+ parts.append(f"(status: {self.status})")
24
+ if self.detail:
25
+ parts.append(f"- {self.detail}")
26
+ return " ".join(parts)
@@ -0,0 +1,35 @@
1
+ """Type definitions for the Classer SDK."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass
8
+ class ClassifyResponse:
9
+ """Response from single-label classification."""
10
+
11
+ label: Optional[str] = None
12
+ confidence: Optional[float] = None
13
+ tokens: int = 0
14
+ latency_ms: float = 0.0
15
+ cached: bool = False
16
+ public: Optional[bool] = None
17
+
18
+
19
+ @dataclass
20
+ class TagLabel:
21
+ """Single label with confidence in a tag response."""
22
+
23
+ label: str
24
+ confidence: float
25
+
26
+
27
+ @dataclass
28
+ class TagResponse:
29
+ """Response from multi-label tagging."""
30
+
31
+ labels: list[TagLabel] = field(default_factory=list)
32
+ tokens: int = 0
33
+ latency_ms: float = 0.0
34
+ cached: bool = False
35
+ public: Optional[bool] = None
File without changes
@@ -0,0 +1,635 @@
1
+ """Tests for the Classer SDK."""
2
+
3
+ import os
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ import pytest
7
+
8
+ from classer import (
9
+ ClasserClient,
10
+ ClasserError,
11
+ ClassifyResponse,
12
+ TagResponse,
13
+ TagLabel,
14
+ classify,
15
+ tag,
16
+ )
17
+
18
+
19
+ class TestClasserClient:
20
+ """Tests for ClasserClient class."""
21
+
22
+ def test_constructor_accepts_custom_config(self):
23
+ client = ClasserClient(
24
+ api_key="test-key",
25
+ )
26
+ assert client.api_key == "test-key"
27
+
28
+ def test_constructor_reads_api_key_from_environment(self):
29
+ with patch.dict(os.environ, {"CLASSER_API_KEY": "env-api-key"}):
30
+ client = ClasserClient()
31
+ assert client.api_key == "env-api-key"
32
+
33
+ def test_constructor_explicit_api_key_overrides_env(self):
34
+ with patch.dict(os.environ, {"CLASSER_API_KEY": "env-key"}):
35
+ client = ClasserClient(api_key="explicit-key")
36
+ assert client.api_key == "explicit-key"
37
+
38
+ def test_constructor_default_timeout(self):
39
+ client = ClasserClient()
40
+ assert client.timeout == 30.0
41
+
42
+ def test_constructor_custom_timeout(self):
43
+ client = ClasserClient(timeout=10.0)
44
+ assert client.timeout == 10.0
45
+
46
+ @patch("classer.client.httpx.post")
47
+ def test_passes_timeout_to_httpx(self, mock_post):
48
+ mock_response = MagicMock()
49
+ mock_response.is_success = True
50
+ mock_response.json.return_value = {
51
+ "label": "a",
52
+ "confidence": 0.9,
53
+ "latency_ms": 30,
54
+ }
55
+ mock_post.return_value = mock_response
56
+
57
+ client = ClasserClient(timeout=5.0)
58
+ client.classify(text="test", labels=["a", "b"])
59
+
60
+ call_args = mock_post.call_args
61
+ assert call_args[1]["timeout"] == 5.0
62
+
63
+
64
+ class TestClassify:
65
+ """Tests for classify method."""
66
+
67
+ @patch("classer.client.httpx.post")
68
+ def test_classify_text_successfully(self, mock_post):
69
+ mock_response = MagicMock()
70
+ mock_response.is_success = True
71
+ mock_response.json.return_value = {
72
+ "label": "technical_support",
73
+ "confidence": 0.94,
74
+ "tokens": 101,
75
+ "latency_ms": 45,
76
+ "cached": False,
77
+ }
78
+ mock_post.return_value = mock_response
79
+
80
+ client = ClasserClient(api_key="test-key")
81
+ result = client.classify(
82
+ text="I can't log in",
83
+ labels=["billing", "technical_support", "sales"],
84
+ )
85
+
86
+ assert isinstance(result, ClassifyResponse)
87
+ assert result.label == "technical_support"
88
+ assert result.confidence == 0.94
89
+ assert result.tokens == 101
90
+ assert result.latency_ms == 45
91
+ assert result.cached is False
92
+
93
+ mock_post.assert_called_once()
94
+ call_args = mock_post.call_args
95
+ assert call_args[0][0] == "https://api.classer.ai/v1/classify"
96
+ assert call_args[1]["headers"]["Authorization"] == "Bearer test-key"
97
+
98
+ @patch("classer.client.httpx.post")
99
+ def test_classify_includes_descriptions(self, mock_post):
100
+ mock_response = MagicMock()
101
+ mock_response.is_success = True
102
+ mock_response.json.return_value = {
103
+ "label": "hot",
104
+ "confidence": 0.92,
105
+ "latency_ms": 38,
106
+ }
107
+ mock_post.return_value = mock_response
108
+
109
+ client = ClasserClient(api_key="test-key")
110
+ client.classify(
111
+ text="I need enterprise pricing for 500 users",
112
+ labels=["hot", "warm", "cold"],
113
+ descriptions={
114
+ "hot": "Ready to buy",
115
+ "warm": "Interested but exploring",
116
+ "cold": "Just browsing",
117
+ },
118
+ )
119
+
120
+ call_args = mock_post.call_args
121
+ body = call_args[1]["json"]
122
+ assert body["descriptions"] == {
123
+ "hot": "Ready to buy",
124
+ "warm": "Interested but exploring",
125
+ "cold": "Just browsing",
126
+ }
127
+
128
+ @patch("classer.client.httpx.post")
129
+ def test_classify_handles_api_errors(self, mock_post):
130
+ mock_response = MagicMock()
131
+ mock_response.is_success = False
132
+ mock_response.status_code = 400
133
+ mock_response.json.return_value = {"detail": "labels cannot be empty"}
134
+ mock_post.return_value = mock_response
135
+
136
+ client = ClasserClient(api_key="test-key")
137
+
138
+ with pytest.raises(ClasserError) as exc_info:
139
+ client.classify(text="test", labels=[])
140
+
141
+ assert exc_info.value.status == 400
142
+ assert exc_info.value.detail == "labels cannot be empty"
143
+
144
+ @patch("classer.client.httpx.post")
145
+ def test_classify_handles_network_errors(self, mock_post):
146
+ mock_post.side_effect = Exception("Network error")
147
+
148
+ client = ClasserClient(api_key="test-key")
149
+
150
+ with pytest.raises(Exception, match="Network error"):
151
+ client.classify(text="test", labels=["a", "b"])
152
+
153
+ @patch("classer.client.httpx.post")
154
+ def test_classify_does_not_send_mode(self, mock_post):
155
+ """classify() should NOT send mode in the request body."""
156
+ mock_response = MagicMock()
157
+ mock_response.is_success = True
158
+ mock_response.json.return_value = {
159
+ "label": "a",
160
+ "confidence": 0.9,
161
+ "latency_ms": 30,
162
+ }
163
+ mock_post.return_value = mock_response
164
+
165
+ client = ClasserClient(api_key="test-key")
166
+ client.classify(text="test", labels=["a", "b"])
167
+
168
+ call_args = mock_post.call_args
169
+ body = call_args[1]["json"]
170
+ assert "mode" not in body
171
+ assert "threshold" not in body
172
+
173
+ @patch("classer.client.httpx.post")
174
+ def test_classify_with_classifier_param(self, mock_post):
175
+ """classify() sends classifier instead of labels when provided."""
176
+ mock_response = MagicMock()
177
+ mock_response.is_success = True
178
+ mock_response.json.return_value = {
179
+ "label": "billing",
180
+ "confidence": 0.88,
181
+ "latency_ms": 42,
182
+ }
183
+ mock_post.return_value = mock_response
184
+
185
+ client = ClasserClient(api_key="test-key")
186
+ result = client.classify(text="test", classifier="support-tickets@v2")
187
+
188
+ call_args = mock_post.call_args
189
+ body = call_args[1]["json"]
190
+ assert body["classifier"] == "support-tickets@v2"
191
+ assert "labels" not in body
192
+ assert result.label == "billing"
193
+
194
+ @patch("classer.client.httpx.post")
195
+ def test_classify_omits_none_optional_fields(self, mock_post):
196
+ """Optional params that are None should not appear in the request body."""
197
+ mock_response = MagicMock()
198
+ mock_response.is_success = True
199
+ mock_response.json.return_value = {
200
+ "label": "a",
201
+ "confidence": 0.9,
202
+ "latency_ms": 30,
203
+ }
204
+ mock_post.return_value = mock_response
205
+
206
+ client = ClasserClient(api_key="test-key")
207
+ client.classify(text="test", labels=["a", "b"])
208
+
209
+ call_args = mock_post.call_args
210
+ body = call_args[1]["json"]
211
+ assert body == {"text": "test", "labels": ["a", "b"]}
212
+
213
+ @patch("classer.client.httpx.post")
214
+ def test_classify_defaults_when_fields_missing(self, mock_post):
215
+ """tokens and cached should default when absent from response."""
216
+ mock_response = MagicMock()
217
+ mock_response.is_success = True
218
+ mock_response.json.return_value = {
219
+ "label": "a",
220
+ "confidence": 0.9,
221
+ "latency_ms": 30,
222
+ }
223
+ mock_post.return_value = mock_response
224
+
225
+ client = ClasserClient(api_key="test-key")
226
+ result = client.classify(text="test", labels=["a", "b"])
227
+
228
+ assert result.tokens == 0
229
+ assert result.cached is False
230
+
231
+
232
+ class TestTag:
233
+ """Tests for tag method."""
234
+
235
+ @patch("classer.client.httpx.post")
236
+ def test_tag_with_multiple_labels(self, mock_post):
237
+ mock_response = MagicMock()
238
+ mock_response.is_success = True
239
+ mock_response.json.return_value = {
240
+ "labels": [
241
+ {"label": "technology", "confidence": 0.65},
242
+ {"label": "finance", "confidence": 0.42},
243
+ ],
244
+ "tokens": 200,
245
+ "latency_ms": 52,
246
+ "cached": False,
247
+ }
248
+ mock_post.return_value = mock_response
249
+
250
+ client = ClasserClient(api_key="test-key")
251
+ result = client.tag(
252
+ text="Tech stocks surge amid AI boom",
253
+ labels=["politics", "technology", "finance", "sports"],
254
+ threshold=0.3,
255
+ )
256
+
257
+ assert isinstance(result, TagResponse)
258
+ assert len(result.labels) == 2
259
+ assert result.labels[0].label == "technology"
260
+ assert result.labels[0].confidence == 0.65
261
+ assert result.labels[1].label == "finance"
262
+ assert result.tokens == 200
263
+ assert result.cached is False
264
+
265
+ call_args = mock_post.call_args
266
+ assert call_args[0][0] == "https://api.classer.ai/v1/tag"
267
+
268
+ @patch("classer.client.httpx.post")
269
+ def test_tag_sends_threshold(self, mock_post):
270
+ mock_response = MagicMock()
271
+ mock_response.is_success = True
272
+ mock_response.json.return_value = {
273
+ "labels": [{"label": "technology", "confidence": 0.85}],
274
+ "latency_ms": 48,
275
+ }
276
+ mock_post.return_value = mock_response
277
+
278
+ client = ClasserClient(api_key="test-key")
279
+ client.tag(
280
+ text="AI is transforming industries",
281
+ labels=["technology", "sports"],
282
+ threshold=0.5,
283
+ )
284
+
285
+ call_args = mock_post.call_args
286
+ body = call_args[1]["json"]
287
+ assert body["threshold"] == 0.5
288
+
289
+ @patch("classer.client.httpx.post")
290
+ def test_tag_returns_empty_when_nothing_matches(self, mock_post):
291
+ mock_response = MagicMock()
292
+ mock_response.is_success = True
293
+ mock_response.json.return_value = {
294
+ "labels": [],
295
+ "latency_ms": 35,
296
+ }
297
+ mock_post.return_value = mock_response
298
+
299
+ client = ClasserClient(api_key="test-key")
300
+ result = client.tag(
301
+ text="Random unrelated text",
302
+ labels=["sports", "politics"],
303
+ threshold=0.9,
304
+ )
305
+
306
+ assert result.labels == []
307
+
308
+ @patch("classer.client.httpx.post")
309
+ def test_tag_returns_empty_when_labels_is_null(self, mock_post):
310
+ """API may return null instead of empty array."""
311
+ mock_response = MagicMock()
312
+ mock_response.is_success = True
313
+ mock_response.json.return_value = {
314
+ "labels": None,
315
+ "latency_ms": 35,
316
+ }
317
+ mock_post.return_value = mock_response
318
+
319
+ client = ClasserClient(api_key="test-key")
320
+ result = client.tag(text="test", labels=["a", "b"])
321
+
322
+ assert result.labels == []
323
+
324
+ @patch("classer.client.httpx.post")
325
+ def test_tag_does_not_send_mode(self, mock_post):
326
+ """tag() should NOT send mode in the request body."""
327
+ mock_response = MagicMock()
328
+ mock_response.is_success = True
329
+ mock_response.json.return_value = {
330
+ "labels": [{"label": "a", "confidence": 0.8}],
331
+ "latency_ms": 30,
332
+ }
333
+ mock_post.return_value = mock_response
334
+
335
+ client = ClasserClient(api_key="test-key")
336
+ client.tag(text="test", labels=["a", "b"])
337
+
338
+ call_args = mock_post.call_args
339
+ body = call_args[1]["json"]
340
+ assert "mode" not in body
341
+
342
+ @patch("classer.client.httpx.post")
343
+ def test_tag_omits_threshold_when_not_provided(self, mock_post):
344
+ mock_response = MagicMock()
345
+ mock_response.is_success = True
346
+ mock_response.json.return_value = {
347
+ "labels": [{"label": "a", "confidence": 0.8}],
348
+ "latency_ms": 30,
349
+ }
350
+ mock_post.return_value = mock_response
351
+
352
+ client = ClasserClient(api_key="test-key")
353
+ client.tag(text="test", labels=["a", "b"])
354
+
355
+ call_args = mock_post.call_args
356
+ body = call_args[1]["json"]
357
+ assert "threshold" not in body
358
+
359
+ @patch("classer.client.httpx.post")
360
+ def test_tag_with_classifier_param(self, mock_post):
361
+ mock_response = MagicMock()
362
+ mock_response.is_success = True
363
+ mock_response.json.return_value = {
364
+ "labels": [{"label": "urgent", "confidence": 0.91}],
365
+ "latency_ms": 55,
366
+ }
367
+ mock_post.return_value = mock_response
368
+
369
+ client = ClasserClient(api_key="test-key")
370
+ result = client.tag(text="test", classifier="priority-tagger")
371
+
372
+ call_args = mock_post.call_args
373
+ body = call_args[1]["json"]
374
+ assert body["classifier"] == "priority-tagger"
375
+ assert "labels" not in body
376
+ assert result.labels[0].label == "urgent"
377
+
378
+ @patch("classer.client.httpx.post")
379
+ def test_tag_with_descriptions(self, mock_post):
380
+ mock_response = MagicMock()
381
+ mock_response.is_success = True
382
+ mock_response.json.return_value = {
383
+ "labels": [{"label": "tech", "confidence": 0.85}],
384
+ "latency_ms": 40,
385
+ }
386
+ mock_post.return_value = mock_response
387
+
388
+ client = ClasserClient(api_key="test-key")
389
+ client.tag(
390
+ text="test",
391
+ labels=["tech", "sports"],
392
+ descriptions={"tech": "Technology news", "sports": "Sports news"},
393
+ )
394
+
395
+ call_args = mock_post.call_args
396
+ body = call_args[1]["json"]
397
+ assert body["descriptions"] == {
398
+ "tech": "Technology news",
399
+ "sports": "Sports news",
400
+ }
401
+
402
+ @patch("classer.client.httpx.post")
403
+ def test_tag_handles_api_errors(self, mock_post):
404
+ mock_response = MagicMock()
405
+ mock_response.is_success = False
406
+ mock_response.status_code = 422
407
+ mock_response.json.return_value = {"detail": "At least 2 labels required"}
408
+ mock_post.return_value = mock_response
409
+
410
+ client = ClasserClient(api_key="test-key")
411
+
412
+ with pytest.raises(ClasserError) as exc_info:
413
+ client.tag(text="test", labels=["only_one"])
414
+
415
+ assert exc_info.value.status == 422
416
+ assert exc_info.value.detail == "At least 2 labels required"
417
+
418
+ @patch("classer.client.httpx.post")
419
+ def test_tag_defaults_when_fields_missing(self, mock_post):
420
+ """tokens and cached should default when absent from response."""
421
+ mock_response = MagicMock()
422
+ mock_response.is_success = True
423
+ mock_response.json.return_value = {
424
+ "labels": [{"label": "a", "confidence": 0.8}],
425
+ "latency_ms": 50,
426
+ }
427
+ mock_post.return_value = mock_response
428
+
429
+ client = ClasserClient(api_key="test-key")
430
+ result = client.tag(text="test", labels=["a", "b"])
431
+
432
+ assert result.tokens == 0
433
+ assert result.cached is False
434
+
435
+ @patch("classer.client.httpx.post")
436
+ def test_tag_latency_ms(self, mock_post):
437
+ mock_response = MagicMock()
438
+ mock_response.is_success = True
439
+ mock_response.json.return_value = {
440
+ "labels": [],
441
+ "latency_ms": 203,
442
+ }
443
+ mock_post.return_value = mock_response
444
+
445
+ client = ClasserClient(api_key="test-key")
446
+ result = client.tag(text="test", labels=["a", "b"])
447
+
448
+ assert result.latency_ms == 203
449
+
450
+
451
+ class TestDefaultExports:
452
+ """Tests for module-level convenience functions."""
453
+
454
+ @patch("classer.client.httpx.post")
455
+ def test_classify_function_works(self, mock_post):
456
+ mock_response = MagicMock()
457
+ mock_response.is_success = True
458
+ mock_response.json.return_value = {
459
+ "label": "greeting",
460
+ "confidence": 0.95,
461
+ "latency_ms": 30,
462
+ }
463
+ mock_post.return_value = mock_response
464
+
465
+ result = classify(
466
+ text="Hello there!",
467
+ labels=["greeting", "question", "complaint"],
468
+ )
469
+
470
+ assert result.label == "greeting"
471
+
472
+ @patch("classer.client.httpx.post")
473
+ def test_tag_function_works(self, mock_post):
474
+ mock_response = MagicMock()
475
+ mock_response.is_success = True
476
+ mock_response.json.return_value = {
477
+ "labels": [
478
+ {"label": "news", "confidence": 0.8},
479
+ {"label": "technology", "confidence": 0.6},
480
+ ],
481
+ "latency_ms": 45,
482
+ }
483
+ mock_post.return_value = mock_response
484
+
485
+ result = tag(
486
+ text="Apple announces new iPhone",
487
+ labels=["news", "technology", "sports"],
488
+ )
489
+
490
+ assert len(result.labels) == 2
491
+ assert result.labels[0].label == "news"
492
+
493
+
494
+ class TestClasserError:
495
+ """Tests for ClasserError exception."""
496
+
497
+ @patch("classer.client.httpx.post")
498
+ def test_error_contains_status_and_detail(self, mock_post):
499
+ mock_response = MagicMock()
500
+ mock_response.is_success = False
501
+ mock_response.status_code = 422
502
+ mock_response.json.return_value = {"detail": "Validation error"}
503
+ mock_post.return_value = mock_response
504
+
505
+ client = ClasserClient(api_key="test-key")
506
+
507
+ with pytest.raises(ClasserError) as exc_info:
508
+ client.classify(text="", labels=["a"])
509
+
510
+ assert exc_info.value.status == 422
511
+ assert exc_info.value.detail == "Validation error"
512
+
513
+ @patch("classer.client.httpx.post")
514
+ def test_error_handles_non_json_responses(self, mock_post):
515
+ mock_response = MagicMock()
516
+ mock_response.is_success = False
517
+ mock_response.status_code = 500
518
+ mock_response.text = ""
519
+ mock_response.json.side_effect = ValueError("Invalid JSON")
520
+ mock_post.return_value = mock_response
521
+
522
+ client = ClasserClient(api_key="test-key")
523
+
524
+ with pytest.raises(ClasserError) as exc_info:
525
+ client.classify(text="test", labels=["a", "b"])
526
+
527
+ assert exc_info.value.status == 500
528
+ assert exc_info.value.detail is None
529
+
530
+ def test_error_str_includes_status_and_detail(self):
531
+ err = ClasserError("Request failed", status=429, detail="Rate limit exceeded")
532
+ assert "429" in str(err)
533
+ assert "Rate limit exceeded" in str(err)
534
+
535
+ def test_error_str_without_detail(self):
536
+ err = ClasserError("Request failed", status=500)
537
+ assert "500" in str(err)
538
+ assert str(err) == "Request failed (status: 500)"
539
+
540
+ def test_error_str_without_status(self):
541
+ err = ClasserError("Something went wrong")
542
+ assert str(err) == "Something went wrong"
543
+
544
+
545
+ class TestRequestHeaders:
546
+ """Tests for request header handling."""
547
+
548
+ @patch("classer.client.httpx.post")
549
+ def test_includes_authorization_header_when_api_key_set(self, mock_post):
550
+ mock_response = MagicMock()
551
+ mock_response.is_success = True
552
+ mock_response.json.return_value = {
553
+ "label": "a",
554
+ "confidence": 0.9,
555
+ "latency_ms": 30,
556
+ }
557
+ mock_post.return_value = mock_response
558
+
559
+ client = ClasserClient(api_key="my-secret-key")
560
+ client.classify(text="test", labels=["a", "b"])
561
+
562
+ call_args = mock_post.call_args
563
+ assert call_args[1]["headers"]["Authorization"] == "Bearer my-secret-key"
564
+
565
+ @patch("classer.client.httpx.post")
566
+ def test_no_authorization_header_when_api_key_empty(self, mock_post):
567
+ mock_response = MagicMock()
568
+ mock_response.is_success = True
569
+ mock_response.json.return_value = {
570
+ "label": "a",
571
+ "confidence": 0.9,
572
+ "latency_ms": 30,
573
+ }
574
+ mock_post.return_value = mock_response
575
+
576
+ with patch.dict(os.environ, {}, clear=True):
577
+ client = ClasserClient(api_key="")
578
+ client.classify(text="test", labels=["a", "b"])
579
+
580
+ call_args = mock_post.call_args
581
+ assert "Authorization" not in call_args[1]["headers"]
582
+
583
+ @patch("classer.client.httpx.post")
584
+ def test_always_includes_content_type_header(self, mock_post):
585
+ mock_response = MagicMock()
586
+ mock_response.is_success = True
587
+ mock_response.json.return_value = {
588
+ "label": "a",
589
+ "confidence": 0.9,
590
+ "latency_ms": 30,
591
+ }
592
+ mock_post.return_value = mock_response
593
+
594
+ client = ClasserClient()
595
+ client.classify(text="test", labels=["a", "b"])
596
+
597
+ call_args = mock_post.call_args
598
+ assert call_args[1]["headers"]["Content-Type"] == "application/json"
599
+
600
+
601
+ class TestEndpoints:
602
+ """Tests for correct endpoint URLs."""
603
+
604
+ @patch("classer.client.httpx.post")
605
+ def test_classify_uses_correct_endpoint(self, mock_post):
606
+ mock_response = MagicMock()
607
+ mock_response.is_success = True
608
+ mock_response.json.return_value = {
609
+ "label": "a",
610
+ "confidence": 0.9,
611
+ "latency_ms": 30,
612
+ }
613
+ mock_post.return_value = mock_response
614
+
615
+ client = ClasserClient()
616
+ client.classify(text="test", labels=["a", "b"])
617
+
618
+ call_args = mock_post.call_args
619
+ assert call_args[0][0] == "https://api.classer.ai/v1/classify"
620
+
621
+ @patch("classer.client.httpx.post")
622
+ def test_tag_uses_correct_endpoint(self, mock_post):
623
+ mock_response = MagicMock()
624
+ mock_response.is_success = True
625
+ mock_response.json.return_value = {
626
+ "labels": [{"label": "a", "confidence": 0.9}],
627
+ "latency_ms": 30,
628
+ }
629
+ mock_post.return_value = mock_response
630
+
631
+ client = ClasserClient()
632
+ client.tag(text="test", labels=["a", "b"])
633
+
634
+ call_args = mock_post.call_args
635
+ assert call_args[0][0] == "https://api.classer.ai/v1/tag"