fewertokens 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FewerTokens contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,4 @@
1
+ include LICENSE README.md pyproject.toml
2
+ recursive-include src *.py
3
+ recursive-exclude tests *
4
+ prune tests
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: fewertokens
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the FewerTokens API — reduce LLM token usage automatically
5
+ License-Expression: MIT
6
+ Classifier: Development Status :: 4 - Beta
7
+ Classifier: Intended Audience :: Developers
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.9
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: httpx>=0.25.0
19
+ Requires-Dist: pydantic>=2.0.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=7.4.0; extra == "dev"
22
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
23
+ Requires-Dist: respx>=0.21.0; extra == "dev"
24
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
25
+ Dynamic: license-file
26
+
27
+ # fewertokens
28
+
29
+ Python SDK for **FewerTokens** — route OpenAI-compatible chat completions through the gateway to reduce token usage.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install fewertokens
35
+ ```
36
+
37
+ From this monorepo (editable):
38
+
39
+ ```bash
40
+ pip install -e "./fewertokens-sdk[dev]"
41
+ ```
42
+
43
+ ## Quick start
44
+
45
+ ```python
46
+ from fewertokens import FewerTokens
47
+
48
+ client = FewerTokens(
49
+ api_key="tr_live_...",
50
+ model="gpt-4o",
51
+ llm_api_key="sk-...",
52
+ base_url="http://localhost:8000", # your FewerTokens API host
53
+ )
54
+ response = client.chat.completions.create("What is Python?")
55
+ print(response.content)
56
+ print(response.tokens_saved)
57
+ ```
58
+
59
+ ## Streaming
60
+
61
+ ```python
62
+ for chunk in client.chat.completions.create("Explain Docker", stream=True):
63
+ print(chunk.content, end="", flush=True)
64
+ ```
65
+
66
+ ## Async
67
+
68
+ ```python
69
+ response = await client.chat.completions.acreate("Hello!")
70
+
71
+ stream = await client.chat.completions.acreate("Hi", stream=True)
72
+ async for chunk in stream:
73
+ print(chunk.content, end="")
74
+ ```
75
+
76
+ ## Specification
77
+
78
+ See `ai-token-reducer/docs/SDK_SPEC.md` in the main repo for the full HTTP contract.
@@ -0,0 +1,52 @@
1
+ # fewertokens
2
+
3
+ Python SDK for **FewerTokens** — route OpenAI-compatible chat completions through the gateway to reduce token usage.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install fewertokens
9
+ ```
10
+
11
+ From this monorepo (editable):
12
+
13
+ ```bash
14
+ pip install -e "./fewertokens-sdk[dev]"
15
+ ```
16
+
17
+ ## Quick start
18
+
19
+ ```python
20
+ from fewertokens import FewerTokens
21
+
22
+ client = FewerTokens(
23
+ api_key="tr_live_...",
24
+ model="gpt-4o",
25
+ llm_api_key="sk-...",
26
+ base_url="http://localhost:8000", # your FewerTokens API host
27
+ )
28
+ response = client.chat.completions.create("What is Python?")
29
+ print(response.content)
30
+ print(response.tokens_saved)
31
+ ```
32
+
33
+ ## Streaming
34
+
35
+ ```python
36
+ for chunk in client.chat.completions.create("Explain Docker", stream=True):
37
+ print(chunk.content, end="", flush=True)
38
+ ```
39
+
40
+ ## Async
41
+
42
+ ```python
43
+ response = await client.chat.completions.acreate("Hello!")
44
+
45
+ stream = await client.chat.completions.acreate("Hi", stream=True)
46
+ async for chunk in stream:
47
+ print(chunk.content, end="")
48
+ ```
49
+
50
+ ## Specification
51
+
52
+ See `ai-token-reducer/docs/SDK_SPEC.md` in the main repo for the full HTTP contract.
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "fewertokens"
7
+ version = "0.1.0"
8
+ description = "Python SDK for the FewerTokens API — reduce LLM token usage automatically"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Developers",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.9",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Topic :: Software Development :: Libraries :: Python Modules",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = [
24
+ "httpx>=0.25.0",
25
+ "pydantic>=2.0.0",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ dev = [
30
+ "pytest>=7.4.0",
31
+ "pytest-asyncio>=0.23.0",
32
+ "respx>=0.21.0",
33
+ "ruff>=0.1.0",
34
+ ]
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
38
+
39
+ [tool.pytest.ini_options]
40
+ asyncio_mode = "auto"
41
+ pythonpath = ["src"]
42
+ testpaths = ["tests"]
43
+
44
+ [tool.ruff]
45
+ line-length = 100
46
+ target-version = "py39"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,29 @@
1
+ """Python SDK for the FewerTokens API."""
2
+
3
+ from fewertokens.client import FewerTokens
4
+ from fewertokens.types.chat import ChatResponse, StreamChunk, Usage
5
+ from fewertokens._exceptions import (
6
+ AuthenticationError,
7
+ BadRequestError,
8
+ ConnectionError,
9
+ FewerTokensError,
10
+ RateLimitError,
11
+ ServerError,
12
+ TimeoutError,
13
+ )
14
+
15
+ __all__ = [
16
+ "FewerTokens",
17
+ "ChatResponse",
18
+ "StreamChunk",
19
+ "Usage",
20
+ "FewerTokensError",
21
+ "AuthenticationError",
22
+ "BadRequestError",
23
+ "ConnectionError",
24
+ "RateLimitError",
25
+ "ServerError",
26
+ "TimeoutError",
27
+ ]
28
+
29
+ __version__ = "0.1.0"
@@ -0,0 +1,10 @@
1
+ """HTTP header names and defaults for the FewerTokens API."""
2
+
3
+ DEFAULT_BASE_URL = "https://api.fewertokens.com"
4
+ CHAT_COMPLETIONS_PATH = "/v1/chat/completions"
5
+
6
+ HEADER_LLM_API_KEY = "x-llm-api-key"
7
+ HEADER_LLM_PROVIDER = "x-llm-provider"
8
+ HEADER_LLM_BASE_URL = "x-llm-base-url"
9
+ HEADER_BYPASS_OPTIMIZATIONS = "x-bypass-optimizations"
10
+ HEADER_TOKENS_SAVED = "x-tokens-saved"
@@ -0,0 +1,55 @@
1
+ """SDK exception types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+
9
+ class FewerTokensError(Exception):
10
+ """Base exception for all SDK errors."""
11
+
12
+ def __init__(self, message: str, status_code: int | None = None) -> None:
13
+ super().__init__(message)
14
+ self.message = message
15
+ self.status_code = status_code
16
+
17
+
18
+ class AuthenticationError(FewerTokensError):
19
+ """401 — invalid or missing API key."""
20
+
21
+
22
+ class RateLimitError(FewerTokensError):
23
+ """429 — rate limit exceeded."""
24
+
25
+
26
+ class BadRequestError(FewerTokensError):
27
+ """400 — invalid request."""
28
+
29
+
30
+ class ServerError(FewerTokensError):
31
+ """500/502/503 — FewerTokens gateway or upstream LLM error."""
32
+
33
+
34
+ class TimeoutError(FewerTokensError):
35
+ """Request timed out."""
36
+
37
+
38
+ class ConnectionError(FewerTokensError):
39
+ """Could not connect to the FewerTokens API."""
40
+
41
+
42
+ def _error_message_from_response_body(body: bytes) -> str | None:
43
+ if not body:
44
+ return None
45
+ try:
46
+ data: dict[str, Any] = json.loads(body.decode("utf-8"))
47
+ except (json.JSONDecodeError, UnicodeDecodeError):
48
+ return None
49
+ if isinstance(data.get("detail"), str):
50
+ return data["detail"]
51
+ if isinstance(data.get("message"), str):
52
+ return data["message"]
53
+ if isinstance(data.get("error"), dict) and isinstance(data["error"].get("message"), str):
54
+ return data["error"]["message"]
55
+ return None
@@ -0,0 +1,92 @@
1
+ """Headers and HTTP error mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+
7
+ from fewertokens._constants import (
8
+ HEADER_BYPASS_OPTIMIZATIONS,
9
+ HEADER_LLM_API_KEY,
10
+ HEADER_LLM_BASE_URL,
11
+ HEADER_LLM_PROVIDER,
12
+ )
13
+ from fewertokens._exceptions import (
14
+ AuthenticationError,
15
+ BadRequestError,
16
+ ConnectionError,
17
+ RateLimitError,
18
+ ServerError,
19
+ TimeoutError,
20
+ FewerTokensError,
21
+ _error_message_from_response_body,
22
+ )
23
+
24
+
25
+ def build_headers(
26
+ *,
27
+ api_key: str,
28
+ llm_api_key: str | None,
29
+ provider: str | None,
30
+ llm_base_url: str | None,
31
+ bypass: bool,
32
+ ) -> dict[str, str]:
33
+ headers: dict[str, str] = {
34
+ "Authorization": f"Bearer {api_key}",
35
+ "Content-Type": "application/json",
36
+ "Accept": "application/json",
37
+ }
38
+ if llm_api_key:
39
+ headers[HEADER_LLM_API_KEY] = llm_api_key
40
+ if provider:
41
+ headers[HEADER_LLM_PROVIDER] = provider
42
+ if llm_base_url:
43
+ headers[HEADER_LLM_BASE_URL] = llm_base_url
44
+ if bypass:
45
+ headers[HEADER_BYPASS_OPTIMIZATIONS] = "true"
46
+ return headers
47
+
48
+
49
+ def build_headers_streaming(
50
+ *,
51
+ api_key: str,
52
+ llm_api_key: str | None,
53
+ provider: str | None,
54
+ llm_base_url: str | None,
55
+ bypass: bool,
56
+ ) -> dict[str, str]:
57
+ h = build_headers(
58
+ api_key=api_key,
59
+ llm_api_key=llm_api_key,
60
+ provider=provider,
61
+ llm_base_url=llm_base_url,
62
+ bypass=bypass,
63
+ )
64
+ h["Accept"] = "text/event-stream"
65
+ return h
66
+
67
+
68
+ def raise_for_status(response: httpx.Response) -> None:
69
+ if response.is_success:
70
+ return
71
+ status = response.status_code
72
+ detail = _error_message_from_response_body(response.content) or response.reason_phrase
73
+ message = f"HTTP {status}: {detail}"
74
+ if status == 401:
75
+ raise AuthenticationError(message, status_code=status)
76
+ if status == 429:
77
+ raise RateLimitError(message, status_code=status)
78
+ if status == 400:
79
+ raise BadRequestError(message, status_code=status)
80
+ if status in (500, 502, 503):
81
+ raise ServerError(message, status_code=status)
82
+ raise FewerTokensError(message, status_code=status)
83
+
84
+
85
+ def map_httpx_exception(exc: Exception) -> FewerTokensError:
86
+ if isinstance(exc, httpx.TimeoutException):
87
+ return TimeoutError(str(exc) or "Request timed out", status_code=None)
88
+ if isinstance(exc, httpx.ConnectError):
89
+ return ConnectionError(str(exc) or "Could not connect to server", status_code=None)
90
+ if isinstance(exc, httpx.RequestError):
91
+ return ConnectionError(str(exc) or "Request failed", status_code=None)
92
+ return FewerTokensError(str(exc))
@@ -0,0 +1,24 @@
1
+ """Infer LLM provider from model name."""
2
+
3
+ _PROVIDER_MAP: tuple[tuple[str, str], ...] = (
4
+ ("gpt-", "openai"),
5
+ ("o1", "openai"),
6
+ ("o3", "openai"),
7
+ ("gemini-", "gemini"),
8
+ ("claude-", "anthropic"),
9
+ ("llama", "ollama"),
10
+ ("mistral", "ollama"),
11
+ ("mixtral", "ollama"),
12
+ ("qwen", "ollama"),
13
+ ("deepseek", "ollama"),
14
+ ("phi-", "ollama"),
15
+ ("command-r", "ollama"),
16
+ )
17
+
18
+
19
+ def detect_provider(model: str) -> str:
20
+ m = model.lower()
21
+ for prefix, provider in _PROVIDER_MAP:
22
+ if m.startswith(prefix):
23
+ return provider
24
+ return "openai"
@@ -0,0 +1,63 @@
1
+ """Synchronous and async-capable FewerTokens client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+
7
+ from fewertokens._constants import DEFAULT_BASE_URL
8
+ from fewertokens.resources.chat import Chat
9
+
10
+
11
+ class FewerTokens:
12
+ """HTTP client for the FewerTokens API (OpenAI-compatible chat completions)."""
13
+
14
+ def __init__(
15
+ self,
16
+ api_key: str,
17
+ *,
18
+ model: str | None = None,
19
+ llm_api_key: str | None = None,
20
+ provider: str | None = None,
21
+ llm_base_url: str | None = None,
22
+ base_url: str = DEFAULT_BASE_URL,
23
+ timeout: float = 60.0,
24
+ ) -> None:
25
+ if not api_key:
26
+ raise ValueError("api_key is required")
27
+ self.api_key = api_key
28
+ self.model = model
29
+ self.llm_api_key = llm_api_key
30
+ self.provider = provider
31
+ self.llm_base_url = llm_base_url
32
+ self.base_url = base_url.rstrip("/")
33
+ self.timeout = timeout
34
+ self._client = httpx.Client(base_url=self.base_url, timeout=timeout)
35
+ self._async_client: httpx.AsyncClient | None = None
36
+ self.chat = Chat(self)
37
+
38
+ @property
39
+ def _async(self) -> httpx.AsyncClient:
40
+ if self._async_client is None:
41
+ self._async_client = httpx.AsyncClient(base_url=self.base_url, timeout=self.timeout)
42
+ return self._async_client
43
+
44
+ def close(self) -> None:
45
+ self._client.close()
46
+
47
+ async def aclose(self) -> None:
48
+ if self._async_client is not None:
49
+ await self._async_client.aclose()
50
+ self._async_client = None
51
+
52
+ def __enter__(self) -> FewerTokens:
53
+ return self
54
+
55
+ def __exit__(self, *args: object) -> None:
56
+ self.close()
57
+
58
+ async def __aenter__(self) -> FewerTokens:
59
+ return self
60
+
61
+ async def __aexit__(self, *args: object) -> None:
62
+ self.close()
63
+ await self.aclose()
@@ -0,0 +1 @@
1
+ """API resource namespaces."""
@@ -0,0 +1,472 @@
1
+ """Chat completions resource (sync + async)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING, Any, AsyncIterator, Iterator, cast
7
+
8
+ import httpx
9
+
10
+ from fewertokens._constants import CHAT_COMPLETIONS_PATH, HEADER_TOKENS_SAVED
11
+ from fewertokens._http import (
12
+ build_headers,
13
+ build_headers_streaming,
14
+ map_httpx_exception,
15
+ raise_for_status,
16
+ )
17
+ from fewertokens._providers import detect_provider
18
+ from fewertokens.types.chat import ChatResponse, StreamChunk, Usage
19
+
20
+ if TYPE_CHECKING:
21
+ from fewertokens.client import FewerTokens
22
+
23
+
24
+ class Chat:
25
+ def __init__(self, client: FewerTokens) -> None:
26
+ self._c = client
27
+ self.completions = ChatCompletions(client)
28
+
29
+
30
+ class ChatCompletions:
31
+ def __init__(self, client: FewerTokens) -> None:
32
+ self._c = client
33
+
34
+ def create(
35
+ self,
36
+ prompt: str | None = None,
37
+ *,
38
+ messages: list[dict[str, str]] | None = None,
39
+ model: str | None = None,
40
+ llm_api_key: str | None = None,
41
+ provider: str | None = None,
42
+ llm_base_url: str | None = None,
43
+ stream: bool = False,
44
+ bypass: bool = False,
45
+ temperature: float | None = None,
46
+ max_tokens: int | None = None,
47
+ top_p: float | None = None,
48
+ **kwargs: Any,
49
+ ) -> ChatResponse | Iterator[StreamChunk]:
50
+ msgs, resolved_model = _prepare_messages_and_model(
51
+ prompt=prompt,
52
+ messages=messages,
53
+ model=model,
54
+ default_model=self._c.model,
55
+ )
56
+ if stream:
57
+ return self._create_stream(
58
+ messages=msgs,
59
+ model=resolved_model,
60
+ llm_api_key=llm_api_key,
61
+ provider=provider,
62
+ llm_base_url=llm_base_url,
63
+ bypass=bypass,
64
+ temperature=temperature,
65
+ max_tokens=max_tokens,
66
+ top_p=top_p,
67
+ **kwargs,
68
+ )
69
+ return self._create_json(
70
+ messages=msgs,
71
+ model=resolved_model,
72
+ llm_api_key=llm_api_key,
73
+ provider=provider,
74
+ llm_base_url=llm_base_url,
75
+ bypass=bypass,
76
+ temperature=temperature,
77
+ max_tokens=max_tokens,
78
+ top_p=top_p,
79
+ **kwargs,
80
+ )
81
+
82
+ async def acreate(
83
+ self,
84
+ prompt: str | None = None,
85
+ *,
86
+ messages: list[dict[str, str]] | None = None,
87
+ model: str | None = None,
88
+ llm_api_key: str | None = None,
89
+ provider: str | None = None,
90
+ llm_base_url: str | None = None,
91
+ stream: bool = False,
92
+ bypass: bool = False,
93
+ temperature: float | None = None,
94
+ max_tokens: int | None = None,
95
+ top_p: float | None = None,
96
+ **kwargs: Any,
97
+ ) -> ChatResponse | AsyncIterator[StreamChunk]:
98
+ msgs, resolved_model = _prepare_messages_and_model(
99
+ prompt=prompt,
100
+ messages=messages,
101
+ model=model,
102
+ default_model=self._c.model,
103
+ )
104
+ if stream:
105
+ return self._stream_async(
106
+ messages=msgs,
107
+ model=resolved_model,
108
+ llm_api_key=llm_api_key,
109
+ provider=provider,
110
+ llm_base_url=llm_base_url,
111
+ bypass=bypass,
112
+ temperature=temperature,
113
+ max_tokens=max_tokens,
114
+ top_p=top_p,
115
+ **kwargs,
116
+ )
117
+ return await self._acreate_json(
118
+ messages=msgs,
119
+ model=resolved_model,
120
+ llm_api_key=llm_api_key,
121
+ provider=provider,
122
+ llm_base_url=llm_base_url,
123
+ bypass=bypass,
124
+ temperature=temperature,
125
+ max_tokens=max_tokens,
126
+ top_p=top_p,
127
+ **kwargs,
128
+ )
129
+
130
+ def _create_json(
131
+ self,
132
+ *,
133
+ messages: list[dict[str, Any]],
134
+ model: str,
135
+ llm_api_key: str | None,
136
+ provider: str | None,
137
+ llm_base_url: str | None,
138
+ bypass: bool,
139
+ temperature: float | None,
140
+ max_tokens: int | None,
141
+ top_p: float | None,
142
+ **kwargs: Any,
143
+ ) -> ChatResponse:
144
+ body = _build_body(
145
+ model=model,
146
+ messages=messages,
147
+ stream=False,
148
+ temperature=temperature,
149
+ max_tokens=max_tokens,
150
+ top_p=top_p,
151
+ extra=kwargs,
152
+ )
153
+ llm_key = _coalesce(llm_api_key, self._c.llm_api_key)
154
+ prov = _resolve_provider(provider, self._c.provider, model)
155
+ base_llm = _coalesce(llm_base_url, self._c.llm_base_url)
156
+ headers = build_headers(
157
+ api_key=self._c.api_key,
158
+ llm_api_key=llm_key,
159
+ provider=prov,
160
+ llm_base_url=base_llm,
161
+ bypass=bypass,
162
+ )
163
+ try:
164
+ r = self._c._client.post(CHAT_COMPLETIONS_PATH, json=body, headers=headers)
165
+ raise_for_status(r)
166
+ except httpx.HTTPError as e:
167
+ raise map_httpx_exception(e) from e
168
+ data = r.json()
169
+ tokens_saved = _parse_tokens_saved_header(r.headers.get(HEADER_TOKENS_SAVED))
170
+ return _response_to_chat_response(data, tokens_saved)
171
+
172
+ async def _acreate_json(
173
+ self,
174
+ *,
175
+ messages: list[dict[str, Any]],
176
+ model: str,
177
+ llm_api_key: str | None,
178
+ provider: str | None,
179
+ llm_base_url: str | None,
180
+ bypass: bool,
181
+ temperature: float | None,
182
+ max_tokens: int | None,
183
+ top_p: float | None,
184
+ **kwargs: Any,
185
+ ) -> ChatResponse:
186
+ body = _build_body(
187
+ model=model,
188
+ messages=messages,
189
+ stream=False,
190
+ temperature=temperature,
191
+ max_tokens=max_tokens,
192
+ top_p=top_p,
193
+ extra=kwargs,
194
+ )
195
+ llm_key = _coalesce(llm_api_key, self._c.llm_api_key)
196
+ prov = _resolve_provider(provider, self._c.provider, model)
197
+ base_llm = _coalesce(llm_base_url, self._c.llm_base_url)
198
+ headers = build_headers(
199
+ api_key=self._c.api_key,
200
+ llm_api_key=llm_key,
201
+ provider=prov,
202
+ llm_base_url=base_llm,
203
+ bypass=bypass,
204
+ )
205
+ try:
206
+ r = await self._c._async.post(CHAT_COMPLETIONS_PATH, json=body, headers=headers)
207
+ raise_for_status(r)
208
+ except httpx.HTTPError as e:
209
+ raise map_httpx_exception(e) from e
210
+ data = r.json()
211
+ tokens_saved = _parse_tokens_saved_header(r.headers.get(HEADER_TOKENS_SAVED))
212
+ return _response_to_chat_response(data, tokens_saved)
213
+
214
+ def _create_stream(
215
+ self,
216
+ *,
217
+ messages: list[dict[str, Any]],
218
+ model: str,
219
+ llm_api_key: str | None,
220
+ provider: str | None,
221
+ llm_base_url: str | None,
222
+ bypass: bool,
223
+ temperature: float | None,
224
+ max_tokens: int | None,
225
+ top_p: float | None,
226
+ **kwargs: Any,
227
+ ) -> Iterator[StreamChunk]:
228
+ body = _build_body(
229
+ model=model,
230
+ messages=messages,
231
+ stream=True,
232
+ temperature=temperature,
233
+ max_tokens=max_tokens,
234
+ top_p=top_p,
235
+ extra=kwargs,
236
+ )
237
+ llm_key = _coalesce(llm_api_key, self._c.llm_api_key)
238
+ prov = _resolve_provider(provider, self._c.provider, model)
239
+ base_llm = _coalesce(llm_base_url, self._c.llm_base_url)
240
+ headers = build_headers_streaming(
241
+ api_key=self._c.api_key,
242
+ llm_api_key=llm_key,
243
+ provider=prov,
244
+ llm_base_url=base_llm,
245
+ bypass=bypass,
246
+ )
247
+
248
+ def gen() -> Iterator[StreamChunk]:
249
+ try:
250
+ with self._c._client.stream(
251
+ "POST", CHAT_COMPLETIONS_PATH, json=body, headers=headers
252
+ ) as r:
253
+ raise_for_status(r)
254
+ yield from _iter_sse_sync(r)
255
+ except httpx.HTTPError as e:
256
+ raise map_httpx_exception(e) from e
257
+
258
+ return gen()
259
+
260
+ def _stream_async(
261
+ self,
262
+ *,
263
+ messages: list[dict[str, Any]],
264
+ model: str,
265
+ llm_api_key: str | None,
266
+ provider: str | None,
267
+ llm_base_url: str | None,
268
+ bypass: bool,
269
+ temperature: float | None,
270
+ max_tokens: int | None,
271
+ top_p: float | None,
272
+ **kwargs: Any,
273
+ ) -> AsyncIterator[StreamChunk]:
274
+ body = _build_body(
275
+ model=model,
276
+ messages=messages,
277
+ stream=True,
278
+ temperature=temperature,
279
+ max_tokens=max_tokens,
280
+ top_p=top_p,
281
+ extra=kwargs,
282
+ )
283
+ llm_key = _coalesce(llm_api_key, self._c.llm_api_key)
284
+ prov = _resolve_provider(provider, self._c.provider, model)
285
+ base_llm = _coalesce(llm_base_url, self._c.llm_base_url)
286
+ headers = build_headers_streaming(
287
+ api_key=self._c.api_key,
288
+ llm_api_key=llm_key,
289
+ provider=prov,
290
+ llm_base_url=base_llm,
291
+ bypass=bypass,
292
+ )
293
+
294
+ async def agen() -> AsyncIterator[StreamChunk]:
295
+ try:
296
+ async with self._c._async.stream(
297
+ "POST", CHAT_COMPLETIONS_PATH, json=body, headers=headers
298
+ ) as r:
299
+ raise_for_status(r)
300
+ async for chunk in _iter_sse_async(r):
301
+ yield chunk
302
+ except httpx.HTTPError as e:
303
+ raise map_httpx_exception(e) from e
304
+
305
+ return agen()
306
+
307
+
308
+ def _coalesce(a: str | None, b: str | None) -> str | None:
309
+ return a if a is not None else b
310
+
311
+
312
+ def _resolve_provider(
313
+ request_provider: str | None, client_provider: str | None, model: str
314
+ ) -> str | None:
315
+ if request_provider is not None:
316
+ return request_provider
317
+ if client_provider is not None:
318
+ return client_provider
319
+ return detect_provider(model)
320
+
321
+
322
+ def _prepare_messages_and_model(
323
+ *,
324
+ prompt: str | None,
325
+ messages: list[dict[str, str]] | None,
326
+ model: str | None,
327
+ default_model: str | None,
328
+ ) -> tuple[list[dict[str, Any]], str]:
329
+ if messages is not None:
330
+ msgs = cast(list[dict[str, Any]], list(messages))
331
+ resolved = model or default_model
332
+ elif prompt is not None:
333
+ msgs = [{"role": "user", "content": prompt}]
334
+ resolved = model or default_model
335
+ else:
336
+ raise ValueError("Either prompt (positional) or messages= must be provided")
337
+ if not resolved:
338
+ raise ValueError("model is required (pass client model= or model= on this call)")
339
+ return msgs, resolved
340
+
341
+
342
+ def _build_body(
343
+ *,
344
+ model: str,
345
+ messages: list[dict[str, Any]],
346
+ stream: bool,
347
+ temperature: float | None,
348
+ max_tokens: int | None,
349
+ top_p: float | None,
350
+ extra: dict[str, Any],
351
+ ) -> dict[str, Any]:
352
+ body: dict[str, Any] = {
353
+ "model": model,
354
+ "messages": messages,
355
+ "stream": stream,
356
+ }
357
+ if temperature is not None:
358
+ body["temperature"] = temperature
359
+ if max_tokens is not None:
360
+ body["max_tokens"] = max_tokens
361
+ if top_p is not None:
362
+ body["top_p"] = top_p
363
+ body.update(extra)
364
+ return body
365
+
366
+
367
+ def _parse_tokens_saved_header(raw: str | None) -> int:
368
+ if raw is None or raw == "":
369
+ return 0
370
+ try:
371
+ return int(raw)
372
+ except ValueError:
373
+ return 0
374
+
375
+
376
+ def _content_from_message(msg: dict[str, Any]) -> str:
377
+ c = msg.get("content")
378
+ if c is None:
379
+ return ""
380
+ if isinstance(c, str):
381
+ return c
382
+ return json.dumps(c)
383
+
384
+
385
+ def _response_to_chat_response(data: dict[str, Any], tokens_saved: int) -> ChatResponse:
386
+ choices = data.get("choices") or []
387
+ content = ""
388
+ finish_reason: str | None = None
389
+ if choices:
390
+ ch0 = cast(dict[str, Any], choices[0])
391
+ message = cast(dict[str, Any], ch0.get("message") or {})
392
+ content = _content_from_message(message)
393
+ fr = ch0.get("finish_reason")
394
+ finish_reason = str(fr) if fr is not None else None
395
+ model = str(data.get("model") or "")
396
+ usage = None
397
+ if data.get("usage"):
398
+ u = cast(dict[str, Any], data["usage"])
399
+ usage = Usage(
400
+ prompt_tokens=int(u.get("prompt_tokens") or 0),
401
+ completion_tokens=int(u.get("completion_tokens") or 0),
402
+ total_tokens=int(u.get("total_tokens") or 0),
403
+ )
404
+ return ChatResponse(
405
+ content=content,
406
+ model=model,
407
+ finish_reason=finish_reason,
408
+ usage=usage,
409
+ tokens_saved=tokens_saved,
410
+ raw=data,
411
+ )
412
+
413
+
414
+ def _parse_stream_chunk(data: dict[str, Any]) -> StreamChunk:
415
+ choices = data.get("choices") or []
416
+ if not choices:
417
+ return StreamChunk(
418
+ content="",
419
+ finish_reason=None,
420
+ model=data.get("model") if data.get("model") is not None else None,
421
+ tokens_saved=0,
422
+ )
423
+ ch0 = cast(dict[str, Any], choices[0])
424
+ delta = cast(dict[str, Any], ch0.get("delta") or {})
425
+ raw_content = delta.get("content")
426
+ if raw_content is None:
427
+ content = ""
428
+ elif isinstance(raw_content, str):
429
+ content = raw_content
430
+ else:
431
+ content = str(raw_content)
432
+ fr = ch0.get("finish_reason")
433
+ finish_reason = str(fr) if fr is not None else None
434
+ model = data.get("model")
435
+ return StreamChunk(
436
+ content=content,
437
+ finish_reason=finish_reason,
438
+ model=str(model) if model is not None else None,
439
+ tokens_saved=0,
440
+ )
441
+
442
+
443
+ def _iter_sse_sync(response: httpx.Response) -> Iterator[StreamChunk]:
444
+ for line in response.iter_lines():
445
+ if not line:
446
+ continue
447
+ if line.startswith("data: "):
448
+ payload = line[6:].strip()
449
+ if payload == "[DONE]":
450
+ return
451
+ try:
452
+ data = json.loads(payload)
453
+ except json.JSONDecodeError:
454
+ continue
455
+ if isinstance(data, dict):
456
+ yield _parse_stream_chunk(data)
457
+
458
+
459
+ async def _iter_sse_async(response: httpx.Response) -> AsyncIterator[StreamChunk]:
460
+ async for line in response.aiter_lines():
461
+ if not line:
462
+ continue
463
+ if line.startswith("data: "):
464
+ payload = line[6:].strip()
465
+ if payload == "[DONE]":
466
+ return
467
+ try:
468
+ data = json.loads(payload)
469
+ except json.JSONDecodeError:
470
+ continue
471
+ if isinstance(data, dict):
472
+ yield _parse_stream_chunk(data)
@@ -0,0 +1,5 @@
1
+ """Response and usage types."""
2
+
3
+ from fewertokens.types.chat import ChatResponse, StreamChunk, Usage
4
+
5
+ __all__ = ["ChatResponse", "StreamChunk", "Usage"]
@@ -0,0 +1,35 @@
1
+ """Pydantic models for chat responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, ConfigDict
8
+
9
+
10
+ class Usage(BaseModel):
11
+ model_config = ConfigDict(extra="allow")
12
+
13
+ prompt_tokens: int = 0
14
+ completion_tokens: int = 0
15
+ total_tokens: int = 0
16
+
17
+
18
+ class ChatResponse(BaseModel):
19
+ model_config = ConfigDict(extra="allow")
20
+
21
+ content: str
22
+ model: str
23
+ finish_reason: str | None = None
24
+ usage: Usage | None = None
25
+ tokens_saved: int = 0
26
+ raw: dict[str, Any]
27
+
28
+
29
+ class StreamChunk(BaseModel):
30
+ model_config = ConfigDict(extra="allow")
31
+
32
+ content: str = ""
33
+ finish_reason: str | None = None
34
+ model: str | None = None
35
+ tokens_saved: int = 0
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: fewertokens
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the FewerTokens API — reduce LLM token usage automatically
5
+ License-Expression: MIT
6
+ Classifier: Development Status :: 4 - Beta
7
+ Classifier: Intended Audience :: Developers
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.9
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: httpx>=0.25.0
19
+ Requires-Dist: pydantic>=2.0.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=7.4.0; extra == "dev"
22
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
23
+ Requires-Dist: respx>=0.21.0; extra == "dev"
24
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
25
+ Dynamic: license-file
26
+
27
+ # fewertokens
28
+
29
+ Python SDK for **FewerTokens** — route OpenAI-compatible chat completions through the gateway to reduce token usage.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install fewertokens
35
+ ```
36
+
37
+ From this monorepo (editable):
38
+
39
+ ```bash
40
+ pip install -e "./fewertokens-sdk[dev]"
41
+ ```
42
+
43
+ ## Quick start
44
+
45
+ ```python
46
+ from fewertokens import FewerTokens
47
+
48
+ client = FewerTokens(
49
+ api_key="tr_live_...",
50
+ model="gpt-4o",
51
+ llm_api_key="sk-...",
52
+ base_url="http://localhost:8000", # your FewerTokens API host
53
+ )
54
+ response = client.chat.completions.create("What is Python?")
55
+ print(response.content)
56
+ print(response.tokens_saved)
57
+ ```
58
+
59
+ ## Streaming
60
+
61
+ ```python
62
+ for chunk in client.chat.completions.create("Explain Docker", stream=True):
63
+ print(chunk.content, end="", flush=True)
64
+ ```
65
+
66
+ ## Async
67
+
68
+ ```python
69
+ response = await client.chat.completions.acreate("Hello!")
70
+
71
+ stream = await client.chat.completions.acreate("Hi", stream=True)
72
+ async for chunk in stream:
73
+ print(chunk.content, end="")
74
+ ```
75
+
76
+ ## Specification
77
+
78
+ See `ai-token-reducer/docs/SDK_SPEC.md` in the main repo for the full HTTP contract.
@@ -0,0 +1,19 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ src/fewertokens/__init__.py
6
+ src/fewertokens/_constants.py
7
+ src/fewertokens/_exceptions.py
8
+ src/fewertokens/_http.py
9
+ src/fewertokens/_providers.py
10
+ src/fewertokens/client.py
11
+ src/fewertokens.egg-info/PKG-INFO
12
+ src/fewertokens.egg-info/SOURCES.txt
13
+ src/fewertokens.egg-info/dependency_links.txt
14
+ src/fewertokens.egg-info/requires.txt
15
+ src/fewertokens.egg-info/top_level.txt
16
+ src/fewertokens/resources/__init__.py
17
+ src/fewertokens/resources/chat.py
18
+ src/fewertokens/types/__init__.py
19
+ src/fewertokens/types/chat.py
@@ -0,0 +1,8 @@
1
+ httpx>=0.25.0
2
+ pydantic>=2.0.0
3
+
4
+ [dev]
5
+ pytest>=7.4.0
6
+ pytest-asyncio>=0.23.0
7
+ respx>=0.21.0
8
+ ruff>=0.1.0
@@ -0,0 +1 @@
1
+ fewertokens