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.
- fewertokens-0.1.0/LICENSE +21 -0
- fewertokens-0.1.0/MANIFEST.in +4 -0
- fewertokens-0.1.0/PKG-INFO +78 -0
- fewertokens-0.1.0/README.md +52 -0
- fewertokens-0.1.0/pyproject.toml +46 -0
- fewertokens-0.1.0/setup.cfg +4 -0
- fewertokens-0.1.0/src/fewertokens/__init__.py +29 -0
- fewertokens-0.1.0/src/fewertokens/_constants.py +10 -0
- fewertokens-0.1.0/src/fewertokens/_exceptions.py +55 -0
- fewertokens-0.1.0/src/fewertokens/_http.py +92 -0
- fewertokens-0.1.0/src/fewertokens/_providers.py +24 -0
- fewertokens-0.1.0/src/fewertokens/client.py +63 -0
- fewertokens-0.1.0/src/fewertokens/resources/__init__.py +1 -0
- fewertokens-0.1.0/src/fewertokens/resources/chat.py +472 -0
- fewertokens-0.1.0/src/fewertokens/types/__init__.py +5 -0
- fewertokens-0.1.0/src/fewertokens/types/chat.py +35 -0
- fewertokens-0.1.0/src/fewertokens.egg-info/PKG-INFO +78 -0
- fewertokens-0.1.0/src/fewertokens.egg-info/SOURCES.txt +19 -0
- fewertokens-0.1.0/src/fewertokens.egg-info/dependency_links.txt +1 -0
- fewertokens-0.1.0/src/fewertokens.egg-info/requires.txt +8 -0
- fewertokens-0.1.0/src/fewertokens.egg-info/top_level.txt +1 -0
|
@@ -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,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,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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fewertokens
|