tokenator 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.
- tokenator-0.1.0/LICENSE +21 -0
- tokenator-0.1.0/PKG-INFO +100 -0
- tokenator-0.1.0/README.md +78 -0
- tokenator-0.1.0/pyproject.toml +29 -0
- tokenator-0.1.0/src/tokenator/__init__.py +12 -0
- tokenator-0.1.0/src/tokenator/base_wrapper.py +61 -0
- tokenator-0.1.0/src/tokenator/client_anthropic.py +148 -0
- tokenator-0.1.0/src/tokenator/client_openai.py +149 -0
- tokenator-0.1.0/src/tokenator/create_migrations.py +21 -0
- tokenator-0.1.0/src/tokenator/migrations/env.py +53 -0
- tokenator-0.1.0/src/tokenator/migrations/script.py.mako +26 -0
- tokenator-0.1.0/src/tokenator/migrations.py +40 -0
- tokenator-0.1.0/src/tokenator/models.py +31 -0
- tokenator-0.1.0/src/tokenator/schemas.py +60 -0
- tokenator-0.1.0/src/tokenator/usage.py +202 -0
- tokenator-0.1.0/src/tokenator/utils.py +35 -0
tokenator-0.1.0/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2024 Ujjwal Maheshwari
|
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.
|
tokenator-0.1.0/PKG-INFO
ADDED
@@ -0,0 +1,100 @@
|
|
1
|
+
Metadata-Version: 2.1
|
2
|
+
Name: tokenator
|
3
|
+
Version: 0.1.0
|
4
|
+
Summary: Token usage tracking wrapper for LLMs
|
5
|
+
License: MIT
|
6
|
+
Author: Ujjwal Maheshwari
|
7
|
+
Author-email: your.email@example.com
|
8
|
+
Requires-Python: >=3.9,<4.0
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
16
|
+
Requires-Dist: alembic (>=1.13.0,<2.0.0)
|
17
|
+
Requires-Dist: anthropic (>=0.40.0,<0.41.0)
|
18
|
+
Requires-Dist: openai (>=1.57.0,<2.0.0)
|
19
|
+
Requires-Dist: requests (>=2.32.3,<3.0.0)
|
20
|
+
Requires-Dist: sqlalchemy (>=2.0.0,<3.0.0)
|
21
|
+
Description-Content-Type: text/markdown
|
22
|
+
|
23
|
+
# Tokenator : Easiest way to track and analyze LLM token usage and cost
|
24
|
+
|
25
|
+
Have you ever wondered about :
|
26
|
+
- How many tokens does your AI agent consume?
|
27
|
+
- How much does it cost to do run a complex AI workflow with multiple LLM providers?
|
28
|
+
- How much money did I spent today on development?
|
29
|
+
|
30
|
+
Afraid not, tokenator is here! With tokenator's easy to use API, you can start tracking LLM usage in a matter of minutes and track your LLM usage.
|
31
|
+
|
32
|
+
Get started with just 3 lines of code!
|
33
|
+
|
34
|
+
## Installation
|
35
|
+
|
36
|
+
```bash
|
37
|
+
pip install tokenator
|
38
|
+
```
|
39
|
+
|
40
|
+
## Usage
|
41
|
+
|
42
|
+
### OpenAI
|
43
|
+
|
44
|
+
```python
|
45
|
+
from openai import OpenAI
|
46
|
+
from tokenator import tokenator_openai
|
47
|
+
|
48
|
+
openai_client = OpenAI(api_key="your-api-key")
|
49
|
+
|
50
|
+
# Wrap it with Tokenator
|
51
|
+
client = tokenator_openai(openai_client)
|
52
|
+
|
53
|
+
# Use it exactly like the OpenAI client
|
54
|
+
response = client.chat.completions.create(
|
55
|
+
model="gpt-4o",
|
56
|
+
messages=[{"role": "user", "content": "Hello!"}]
|
57
|
+
)
|
58
|
+
```
|
59
|
+
|
60
|
+
### Cost Analysis
|
61
|
+
|
62
|
+
```python
|
63
|
+
from tokenator import cost
|
64
|
+
|
65
|
+
# Get usage for different time periods
|
66
|
+
cost.last_hour()
|
67
|
+
cost.last_day()
|
68
|
+
cost.last_week()
|
69
|
+
cost.last_month()
|
70
|
+
|
71
|
+
# Custom date range
|
72
|
+
cost.between("2024-03-01", "2024-03-15")
|
73
|
+
|
74
|
+
# Get usage for different LLM providers
|
75
|
+
cost.last_day("openai")
|
76
|
+
cost.last_day("anthropic")
|
77
|
+
cost.last_day("google")
|
78
|
+
```
|
79
|
+
|
80
|
+
### Example `cost` object
|
81
|
+
|
82
|
+
```json
|
83
|
+
|
84
|
+
```
|
85
|
+
|
86
|
+
## Features
|
87
|
+
|
88
|
+
- Drop-in replacement for OpenAI, Anthropic client
|
89
|
+
- Automatic token usage tracking
|
90
|
+
- Cost analysis for different time periods
|
91
|
+
- SQLite storage with zero configuration
|
92
|
+
- Thread-safe operations
|
93
|
+
- Minimal memory footprint
|
94
|
+
- Minimal latency footprint
|
95
|
+
|
96
|
+
Most importantly, none of your data is ever sent to any server.
|
97
|
+
|
98
|
+
## License
|
99
|
+
|
100
|
+
MIT
|
@@ -0,0 +1,78 @@
|
|
1
|
+
# Tokenator : Easiest way to track and analyze LLM token usage and cost
|
2
|
+
|
3
|
+
Have you ever wondered about :
|
4
|
+
- How many tokens does your AI agent consume?
|
5
|
+
- How much does it cost to do run a complex AI workflow with multiple LLM providers?
|
6
|
+
- How much money did I spent today on development?
|
7
|
+
|
8
|
+
Afraid not, tokenator is here! With tokenator's easy to use API, you can start tracking LLM usage in a matter of minutes and track your LLM usage.
|
9
|
+
|
10
|
+
Get started with just 3 lines of code!
|
11
|
+
|
12
|
+
## Installation
|
13
|
+
|
14
|
+
```bash
|
15
|
+
pip install tokenator
|
16
|
+
```
|
17
|
+
|
18
|
+
## Usage
|
19
|
+
|
20
|
+
### OpenAI
|
21
|
+
|
22
|
+
```python
|
23
|
+
from openai import OpenAI
|
24
|
+
from tokenator import tokenator_openai
|
25
|
+
|
26
|
+
openai_client = OpenAI(api_key="your-api-key")
|
27
|
+
|
28
|
+
# Wrap it with Tokenator
|
29
|
+
client = tokenator_openai(openai_client)
|
30
|
+
|
31
|
+
# Use it exactly like the OpenAI client
|
32
|
+
response = client.chat.completions.create(
|
33
|
+
model="gpt-4o",
|
34
|
+
messages=[{"role": "user", "content": "Hello!"}]
|
35
|
+
)
|
36
|
+
```
|
37
|
+
|
38
|
+
### Cost Analysis
|
39
|
+
|
40
|
+
```python
|
41
|
+
from tokenator import cost
|
42
|
+
|
43
|
+
# Get usage for different time periods
|
44
|
+
cost.last_hour()
|
45
|
+
cost.last_day()
|
46
|
+
cost.last_week()
|
47
|
+
cost.last_month()
|
48
|
+
|
49
|
+
# Custom date range
|
50
|
+
cost.between("2024-03-01", "2024-03-15")
|
51
|
+
|
52
|
+
# Get usage for different LLM providers
|
53
|
+
cost.last_day("openai")
|
54
|
+
cost.last_day("anthropic")
|
55
|
+
cost.last_day("google")
|
56
|
+
```
|
57
|
+
|
58
|
+
### Example `cost` object
|
59
|
+
|
60
|
+
```json
|
61
|
+
|
62
|
+
```
|
63
|
+
|
64
|
+
## Features
|
65
|
+
|
66
|
+
- Drop-in replacement for OpenAI, Anthropic client
|
67
|
+
- Automatic token usage tracking
|
68
|
+
- Cost analysis for different time periods
|
69
|
+
- SQLite storage with zero configuration
|
70
|
+
- Thread-safe operations
|
71
|
+
- Minimal memory footprint
|
72
|
+
- Minimal latency footprint
|
73
|
+
|
74
|
+
Most importantly, none of your data is ever sent to any server.
|
75
|
+
|
76
|
+
## License
|
77
|
+
|
78
|
+
MIT
|
@@ -0,0 +1,29 @@
|
|
1
|
+
[tool.poetry]
|
2
|
+
name = "tokenator"
|
3
|
+
version = "0.1.0"
|
4
|
+
description = "Token usage tracking wrapper for LLMs"
|
5
|
+
authors = ["Ujjwal Maheshwari <your.email@example.com>"]
|
6
|
+
readme = "README.md"
|
7
|
+
license = "MIT"
|
8
|
+
packages = [{include = "tokenator", from = "src"}]
|
9
|
+
|
10
|
+
[tool.poetry.dependencies]
|
11
|
+
python = "^3.9"
|
12
|
+
openai = "^1.57.0"
|
13
|
+
sqlalchemy = "^2.0.0"
|
14
|
+
requests = "^2.32.3"
|
15
|
+
alembic = "^1.13.0"
|
16
|
+
anthropic = "^0.40.0"
|
17
|
+
|
18
|
+
[tool.poetry.group.dev.dependencies]
|
19
|
+
pytest = "^8.0.0"
|
20
|
+
pytest-asyncio = "^0.23.0"
|
21
|
+
pytest-cov = "^4.1.0"
|
22
|
+
|
23
|
+
[build-system]
|
24
|
+
requires = ["poetry-core"]
|
25
|
+
build-backend = "poetry.core.masonry.api"
|
26
|
+
|
27
|
+
[tool.pytest.ini_options]
|
28
|
+
pythonpath = ["src"]
|
29
|
+
asyncio_mode = "auto"
|
@@ -0,0 +1,12 @@
|
|
1
|
+
"""Tokenator - Track and analyze your OpenAI API token usage and costs."""
|
2
|
+
|
3
|
+
from .client_openai import OpenAIWrapper
|
4
|
+
from . import usage
|
5
|
+
from .utils import get_default_db_path
|
6
|
+
from .migrations import check_and_run_migrations
|
7
|
+
|
8
|
+
__version__ = "0.1.0"
|
9
|
+
__all__ = ["OpenAIWrapper", "usage", "get_default_db_path"]
|
10
|
+
|
11
|
+
# Run migrations on import
|
12
|
+
check_and_run_migrations()
|
@@ -0,0 +1,61 @@
|
|
1
|
+
"""Base wrapper class for token usage tracking."""
|
2
|
+
|
3
|
+
from pathlib import Path
|
4
|
+
from typing import Any, Dict, Optional, TypeVar, Union
|
5
|
+
import logging
|
6
|
+
import uuid
|
7
|
+
|
8
|
+
from .models import Usage, TokenUsageStats
|
9
|
+
from .schemas import get_session, TokenUsage
|
10
|
+
|
11
|
+
logger = logging.getLogger(__name__)
|
12
|
+
|
13
|
+
ResponseType = TypeVar('ResponseType')
|
14
|
+
|
15
|
+
class BaseWrapper:
|
16
|
+
def __init__(self, client: Any, db_path: Optional[str] = None):
|
17
|
+
"""Initialize the base wrapper."""
|
18
|
+
self.client = client
|
19
|
+
|
20
|
+
if db_path:
|
21
|
+
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
22
|
+
logger.info("Created database directory at: %s", Path(db_path).parent)
|
23
|
+
|
24
|
+
self.Session = get_session(db_path)
|
25
|
+
|
26
|
+
logger.debug("Initializing %s with db_path: %s",
|
27
|
+
self.__class__.__name__, db_path)
|
28
|
+
|
29
|
+
def _log_usage_impl(self, token_usage_stats: TokenUsageStats, session, execution_id: str) -> None:
|
30
|
+
"""Implementation of token usage logging."""
|
31
|
+
logger.debug("Logging usage for model %s: %s", token_usage_stats.model, token_usage_stats.usage.model_dump())
|
32
|
+
try:
|
33
|
+
token_usage = TokenUsage(
|
34
|
+
execution_id=execution_id,
|
35
|
+
provider=self.provider,
|
36
|
+
model=token_usage_stats.model,
|
37
|
+
prompt_tokens=token_usage_stats.usage.prompt_tokens,
|
38
|
+
completion_tokens=token_usage_stats.usage.completion_tokens,
|
39
|
+
total_tokens=token_usage_stats.usage.total_tokens
|
40
|
+
)
|
41
|
+
session.add(token_usage)
|
42
|
+
logger.info("Logged token usage: model=%s, total_tokens=%d",
|
43
|
+
token_usage_stats.model, token_usage_stats.usage.total_tokens)
|
44
|
+
except Exception as e:
|
45
|
+
logger.error("Failed to log token usage: %s", str(e))
|
46
|
+
|
47
|
+
def _log_usage(self, token_usage_stats: TokenUsageStats, execution_id: Optional[str] = None):
|
48
|
+
"""Log token usage to database."""
|
49
|
+
if not execution_id:
|
50
|
+
execution_id = str(uuid.uuid4())
|
51
|
+
|
52
|
+
session = self.Session()
|
53
|
+
try:
|
54
|
+
try:
|
55
|
+
self._log_usage_impl(token_usage_stats, session, execution_id)
|
56
|
+
session.commit()
|
57
|
+
except Exception as e:
|
58
|
+
logger.error("Failed to log token usage: %s", str(e))
|
59
|
+
session.rollback()
|
60
|
+
finally:
|
61
|
+
session.close()
|
@@ -0,0 +1,148 @@
|
|
1
|
+
"""Anthropic client wrapper with token usage tracking."""
|
2
|
+
|
3
|
+
from typing import Any, Dict, Optional, TypeVar, Union, overload, Iterator, AsyncIterator
|
4
|
+
import logging
|
5
|
+
|
6
|
+
from anthropic import Anthropic, AsyncAnthropic
|
7
|
+
from anthropic.types import Message, RawMessageStartEvent, RawMessageDeltaEvent
|
8
|
+
|
9
|
+
from .models import Usage, TokenUsageStats
|
10
|
+
from .base_wrapper import BaseWrapper, ResponseType
|
11
|
+
|
12
|
+
logger = logging.getLogger(__name__)
|
13
|
+
|
14
|
+
class BaseAnthropicWrapper(BaseWrapper):
|
15
|
+
provider = "anthropic"
|
16
|
+
|
17
|
+
def _process_response_usage(self, response: ResponseType) -> Optional[TokenUsageStats]:
|
18
|
+
"""Process and log usage statistics from a response."""
|
19
|
+
try:
|
20
|
+
if isinstance(response, Message):
|
21
|
+
if not hasattr(response, 'usage'):
|
22
|
+
return None
|
23
|
+
usage = Usage(
|
24
|
+
prompt_tokens=response.usage.input_tokens,
|
25
|
+
completion_tokens=response.usage.output_tokens,
|
26
|
+
total_tokens=response.usage.input_tokens + response.usage.output_tokens
|
27
|
+
)
|
28
|
+
return TokenUsageStats(model=response.model, usage=usage)
|
29
|
+
elif isinstance(response, dict):
|
30
|
+
usage_dict = response.get('usage')
|
31
|
+
if not usage_dict:
|
32
|
+
return None
|
33
|
+
usage = Usage(
|
34
|
+
prompt_tokens=usage_dict.get('input_tokens', 0),
|
35
|
+
completion_tokens=usage_dict.get('output_tokens', 0),
|
36
|
+
total_tokens=usage_dict.get('input_tokens', 0) + usage_dict.get('output_tokens', 0)
|
37
|
+
)
|
38
|
+
return TokenUsageStats(
|
39
|
+
model=response.get('model', 'unknown'),
|
40
|
+
usage=usage
|
41
|
+
)
|
42
|
+
except Exception as e:
|
43
|
+
logger.warning("Failed to process usage stats: %s", str(e))
|
44
|
+
return None
|
45
|
+
return None
|
46
|
+
|
47
|
+
@property
|
48
|
+
def messages(self):
|
49
|
+
return self
|
50
|
+
|
51
|
+
class AnthropicWrapper(BaseAnthropicWrapper):
|
52
|
+
def create(self, *args: Any, execution_id: Optional[str] = None, **kwargs: Any) -> Union[Message, Iterator[Message]]:
|
53
|
+
"""Create a message completion and log token usage."""
|
54
|
+
logger.debug("Creating message completion with args: %s, kwargs: %s", args, kwargs)
|
55
|
+
|
56
|
+
response = self.client.messages.create(*args, **kwargs)
|
57
|
+
|
58
|
+
if not kwargs.get('stream', False):
|
59
|
+
usage_data = self._process_response_usage(response)
|
60
|
+
if usage_data:
|
61
|
+
self._log_usage(usage_data, execution_id=execution_id)
|
62
|
+
return response
|
63
|
+
|
64
|
+
return self._wrap_streaming_response(response, execution_id)
|
65
|
+
|
66
|
+
def _wrap_streaming_response(self, response_iter: Iterator[Message], execution_id: Optional[str]) -> Iterator[Message]:
|
67
|
+
"""Wrap streaming response to capture final usage stats"""
|
68
|
+
usage_data: TokenUsageStats = TokenUsageStats(model="", usage=Usage())
|
69
|
+
for chunk in response_iter:
|
70
|
+
if isinstance(chunk, RawMessageStartEvent):
|
71
|
+
usage_data.model = chunk.message.model
|
72
|
+
usage_data.usage.prompt_tokens = chunk.message.usage.input_tokens
|
73
|
+
usage_data.usage.completion_tokens = chunk.message.usage.output_tokens
|
74
|
+
usage_data.usage.total_tokens = chunk.message.usage.input_tokens + chunk.message.usage.output_tokens
|
75
|
+
|
76
|
+
elif isinstance(chunk, RawMessageDeltaEvent):
|
77
|
+
usage_data.usage.prompt_tokens += chunk.usage.input_tokens
|
78
|
+
usage_data.usage.completion_tokens += chunk.usage.output_tokens
|
79
|
+
usage_data.usage.total_tokens += chunk.usage.input_tokens + chunk.usage.output_tokens
|
80
|
+
|
81
|
+
yield chunk
|
82
|
+
|
83
|
+
self._log_usage(usage_data, execution_id=execution_id)
|
84
|
+
|
85
|
+
class AsyncAnthropicWrapper(BaseAnthropicWrapper):
|
86
|
+
async def create(self, *args: Any, execution_id: Optional[str] = None, **kwargs: Any) -> Union[Message, AsyncIterator[Message]]:
|
87
|
+
"""Create a message completion and log token usage."""
|
88
|
+
logger.debug("Creating message completion with args: %s, kwargs: %s", args, kwargs)
|
89
|
+
|
90
|
+
if kwargs.get('stream', False):
|
91
|
+
response = await self.client.messages.create(*args, **kwargs)
|
92
|
+
return self._wrap_streaming_response(response, execution_id)
|
93
|
+
|
94
|
+
response = await self.client.messages.create(*args, **kwargs)
|
95
|
+
usage_data = self._process_response_usage(response)
|
96
|
+
if usage_data:
|
97
|
+
self._log_usage(usage_data, execution_id=execution_id)
|
98
|
+
return response
|
99
|
+
|
100
|
+
async def _wrap_streaming_response(self, response_iter: AsyncIterator[Message], execution_id: Optional[str]) -> AsyncIterator[Message]:
|
101
|
+
"""Wrap streaming response to capture final usage stats"""
|
102
|
+
usage_data: TokenUsageStats = TokenUsageStats(model="", usage=Usage())
|
103
|
+
async for chunk in response_iter:
|
104
|
+
if isinstance(chunk, RawMessageStartEvent):
|
105
|
+
usage_data.model = chunk.message.model
|
106
|
+
usage_data.usage.prompt_tokens = chunk.message.usage.input_tokens
|
107
|
+
usage_data.usage.completion_tokens = chunk.message.usage.output_tokens
|
108
|
+
usage_data.usage.total_tokens = chunk.message.usage.input_tokens + chunk.message.usage.output_tokens
|
109
|
+
|
110
|
+
elif isinstance(chunk, RawMessageDeltaEvent):
|
111
|
+
usage_data.usage.prompt_tokens += chunk.usage.input_tokens
|
112
|
+
usage_data.usage.completion_tokens += chunk.usage.output_tokens
|
113
|
+
usage_data.usage.total_tokens += chunk.usage.input_tokens + chunk.usage.output_tokens
|
114
|
+
|
115
|
+
yield chunk
|
116
|
+
|
117
|
+
|
118
|
+
self._log_usage(usage_data, execution_id=execution_id)
|
119
|
+
|
120
|
+
@overload
|
121
|
+
def tokenator_anthropic(
|
122
|
+
client: Anthropic,
|
123
|
+
db_path: Optional[str] = None,
|
124
|
+
) -> AnthropicWrapper: ...
|
125
|
+
|
126
|
+
@overload
|
127
|
+
def tokenator_anthropic(
|
128
|
+
client: AsyncAnthropic,
|
129
|
+
db_path: Optional[str] = None,
|
130
|
+
) -> AsyncAnthropicWrapper: ...
|
131
|
+
|
132
|
+
def tokenator_anthropic(
|
133
|
+
client: Union[Anthropic, AsyncAnthropic],
|
134
|
+
db_path: Optional[str] = None,
|
135
|
+
) -> Union[AnthropicWrapper, AsyncAnthropicWrapper]:
|
136
|
+
"""Create a token-tracking wrapper for an Anthropic client.
|
137
|
+
|
138
|
+
Args:
|
139
|
+
client: Anthropic or AsyncAnthropic client instance
|
140
|
+
db_path: Optional path to SQLite database for token tracking
|
141
|
+
"""
|
142
|
+
if isinstance(client, Anthropic):
|
143
|
+
return AnthropicWrapper(client=client, db_path=db_path)
|
144
|
+
|
145
|
+
if isinstance(client, AsyncAnthropic):
|
146
|
+
return AsyncAnthropicWrapper(client=client, db_path=db_path)
|
147
|
+
|
148
|
+
raise ValueError("Client must be an instance of Anthropic or AsyncAnthropic")
|
@@ -0,0 +1,149 @@
|
|
1
|
+
"""OpenAI client wrapper with token usage tracking."""
|
2
|
+
|
3
|
+
from typing import Any, Dict, Optional, TypeVar, Union, overload, Iterator, AsyncIterator
|
4
|
+
import logging
|
5
|
+
|
6
|
+
from openai import AsyncOpenAI, AsyncStream, OpenAI, Stream
|
7
|
+
from openai.types.chat import ChatCompletion, ChatCompletionChunk
|
8
|
+
|
9
|
+
from .models import Usage, TokenUsageStats
|
10
|
+
from .base_wrapper import BaseWrapper, ResponseType
|
11
|
+
|
12
|
+
logger = logging.getLogger(__name__)
|
13
|
+
|
14
|
+
class BaseOpenAIWrapper(BaseWrapper):
|
15
|
+
provider = "openai"
|
16
|
+
|
17
|
+
def _process_response_usage(self, response: ResponseType) -> Optional[TokenUsageStats]:
|
18
|
+
"""Process and log usage statistics from a response."""
|
19
|
+
try:
|
20
|
+
if isinstance(response, ChatCompletion):
|
21
|
+
if response.usage is None:
|
22
|
+
return None
|
23
|
+
usage = Usage(
|
24
|
+
prompt_tokens=response.usage.prompt_tokens,
|
25
|
+
completion_tokens=response.usage.completion_tokens,
|
26
|
+
total_tokens=response.usage.total_tokens,
|
27
|
+
)
|
28
|
+
return TokenUsageStats(model=response.model, usage=usage)
|
29
|
+
|
30
|
+
elif isinstance(response, dict):
|
31
|
+
usage_dict = response.get('usage')
|
32
|
+
if not usage_dict:
|
33
|
+
return None
|
34
|
+
usage = Usage(
|
35
|
+
prompt_tokens=usage_dict.get('prompt_tokens', 0),
|
36
|
+
completion_tokens=usage_dict.get('completion_tokens', 0),
|
37
|
+
total_tokens=usage_dict.get('total_tokens', 0)
|
38
|
+
)
|
39
|
+
return TokenUsageStats(
|
40
|
+
model=response.get('model', 'unknown'),
|
41
|
+
usage=usage
|
42
|
+
)
|
43
|
+
except Exception as e:
|
44
|
+
logger.warning("Failed to process usage stats: %s", str(e))
|
45
|
+
return None
|
46
|
+
return None
|
47
|
+
|
48
|
+
@property
|
49
|
+
def chat(self):
|
50
|
+
return self
|
51
|
+
|
52
|
+
@property
|
53
|
+
def completions(self):
|
54
|
+
return self
|
55
|
+
|
56
|
+
class OpenAIWrapper(BaseOpenAIWrapper):
|
57
|
+
def create(self, *args: Any, execution_id: Optional[str] = None, **kwargs: Any) -> Union[ChatCompletion, Iterator[ChatCompletion]]:
|
58
|
+
"""Create a chat completion and log token usage."""
|
59
|
+
logger.debug("Creating chat completion with args: %s, kwargs: %s", args, kwargs)
|
60
|
+
|
61
|
+
response = self.client.chat.completions.create(*args, **kwargs)
|
62
|
+
|
63
|
+
if not kwargs.get('stream', False):
|
64
|
+
usage_data = self._process_response_usage(response)
|
65
|
+
if usage_data:
|
66
|
+
self._log_usage(usage_data, execution_id=execution_id)
|
67
|
+
return response
|
68
|
+
|
69
|
+
return self._wrap_streaming_response(response, execution_id)
|
70
|
+
|
71
|
+
def _wrap_streaming_response(self, response_iter: Stream[ChatCompletionChunk], execution_id: Optional[str]) -> Iterator[ChatCompletionChunk]:
|
72
|
+
"""Wrap streaming response to capture final usage stats"""
|
73
|
+
chunks_with_usage = []
|
74
|
+
for chunk in response_iter:
|
75
|
+
if isinstance(chunk, ChatCompletionChunk) and chunk.usage is not None:
|
76
|
+
chunks_with_usage.append(chunk)
|
77
|
+
yield chunk
|
78
|
+
|
79
|
+
if len(chunks_with_usage) > 0:
|
80
|
+
usage_data: TokenUsageStats = TokenUsageStats(model=chunks_with_usage[0].model, usage=Usage())
|
81
|
+
for chunk in chunks_with_usage:
|
82
|
+
usage_data.usage.prompt_tokens += chunk.usage.prompt_tokens
|
83
|
+
usage_data.usage.completion_tokens += chunk.usage.completion_tokens
|
84
|
+
usage_data.usage.total_tokens += chunk.usage.total_tokens
|
85
|
+
|
86
|
+
self._log_usage(usage_data, execution_id=execution_id)
|
87
|
+
|
88
|
+
|
89
|
+
class AsyncOpenAIWrapper(BaseOpenAIWrapper):
|
90
|
+
async def create(self, *args: Any, execution_id: Optional[str] = None, **kwargs: Any) -> Union[ChatCompletion, AsyncIterator[ChatCompletion]]:
|
91
|
+
"""Create a chat completion and log token usage."""
|
92
|
+
logger.debug("Creating chat completion with args: %s, kwargs: %s", args, kwargs)
|
93
|
+
|
94
|
+
if kwargs.get('stream', False):
|
95
|
+
response = await self.client.chat.completions.create(*args, **kwargs)
|
96
|
+
return self._wrap_streaming_response(response, execution_id)
|
97
|
+
|
98
|
+
response = await self.client.chat.completions.create(*args, **kwargs)
|
99
|
+
usage_data = self._process_response_usage(response)
|
100
|
+
if usage_data:
|
101
|
+
self._log_usage(usage_data, execution_id=execution_id)
|
102
|
+
return response
|
103
|
+
|
104
|
+
async def _wrap_streaming_response(self, response_iter: AsyncStream[ChatCompletionChunk], execution_id: Optional[str]) -> AsyncIterator[ChatCompletionChunk]:
|
105
|
+
"""Wrap streaming response to capture final usage stats"""
|
106
|
+
chunks_with_usage = []
|
107
|
+
async for chunk in response_iter:
|
108
|
+
if isinstance(chunk, ChatCompletionChunk) and chunk.usage is not None:
|
109
|
+
chunks_with_usage.append(chunk)
|
110
|
+
yield chunk
|
111
|
+
|
112
|
+
if len(chunks_with_usage) > 0:
|
113
|
+
usage_data: TokenUsageStats = TokenUsageStats(model=chunks_with_usage[0].model, usage=Usage())
|
114
|
+
for chunk in chunks_with_usage:
|
115
|
+
usage_data.usage.prompt_tokens += chunk.usage.prompt_tokens
|
116
|
+
usage_data.usage.completion_tokens += chunk.usage.completion_tokens
|
117
|
+
usage_data.usage.total_tokens += chunk.usage.total_tokens
|
118
|
+
|
119
|
+
self._log_usage(usage_data, execution_id=execution_id)
|
120
|
+
|
121
|
+
@overload
|
122
|
+
def tokenator_openai(
|
123
|
+
client: OpenAI,
|
124
|
+
db_path: Optional[str] = None,
|
125
|
+
) -> OpenAIWrapper: ...
|
126
|
+
|
127
|
+
@overload
|
128
|
+
def tokenator_openai(
|
129
|
+
client: AsyncOpenAI,
|
130
|
+
db_path: Optional[str] = None,
|
131
|
+
) -> AsyncOpenAIWrapper: ...
|
132
|
+
|
133
|
+
def tokenator_openai(
|
134
|
+
client: Union[OpenAI, AsyncOpenAI],
|
135
|
+
db_path: Optional[str] = None,
|
136
|
+
) -> Union[OpenAIWrapper, AsyncOpenAIWrapper]:
|
137
|
+
"""Create a token-tracking wrapper for an OpenAI client.
|
138
|
+
|
139
|
+
Args:
|
140
|
+
client: OpenAI or AsyncOpenAI client instance
|
141
|
+
db_path: Optional path to SQLite database for token tracking
|
142
|
+
"""
|
143
|
+
if isinstance(client, OpenAI):
|
144
|
+
return OpenAIWrapper(client=client, db_path=db_path)
|
145
|
+
|
146
|
+
if isinstance(client, AsyncOpenAI):
|
147
|
+
return AsyncOpenAIWrapper(client=client, db_path=db_path)
|
148
|
+
|
149
|
+
raise ValueError("Client must be an instance of OpenAI or AsyncOpenAI")
|
@@ -0,0 +1,21 @@
|
|
1
|
+
"""Development utilities for tokenator."""
|
2
|
+
|
3
|
+
import os
|
4
|
+
from pathlib import Path
|
5
|
+
from alembic import command
|
6
|
+
from tokenator.migrations import get_alembic_config
|
7
|
+
|
8
|
+
def create_migration():
|
9
|
+
"""Create a new migration based on model changes."""
|
10
|
+
config = get_alembic_config()
|
11
|
+
|
12
|
+
# Get the migrations directory
|
13
|
+
migrations_dir = Path(__file__).parent / "migrations" / "versions"
|
14
|
+
migrations_dir.mkdir(parents=True, exist_ok=True)
|
15
|
+
|
16
|
+
# Generate migration
|
17
|
+
command.revision(config, autogenerate=True, message="auto generated migration")
|
18
|
+
|
19
|
+
|
20
|
+
if __name__ == "__main__":
|
21
|
+
create_migration()
|
@@ -0,0 +1,53 @@
|
|
1
|
+
from logging.config import fileConfig
|
2
|
+
|
3
|
+
from sqlalchemy import engine_from_config
|
4
|
+
from sqlalchemy import pool
|
5
|
+
|
6
|
+
from alembic import context
|
7
|
+
|
8
|
+
from tokenator.models import Base
|
9
|
+
|
10
|
+
# this is the Alembic Config object
|
11
|
+
config = context.config
|
12
|
+
|
13
|
+
# Interpret the config file for Python logging.
|
14
|
+
# This line sets up loggers basically.
|
15
|
+
if config.config_file_name is not None:
|
16
|
+
fileConfig(config.config_file_name)
|
17
|
+
|
18
|
+
# add your model's MetaData object here
|
19
|
+
target_metadata = Base.metadata
|
20
|
+
|
21
|
+
def run_migrations_offline() -> None:
|
22
|
+
"""Run migrations in 'offline' mode."""
|
23
|
+
url = config.get_main_option("sqlalchemy.url")
|
24
|
+
context.configure(
|
25
|
+
url=url,
|
26
|
+
target_metadata=target_metadata,
|
27
|
+
literal_binds=True,
|
28
|
+
dialect_opts={"paramstyle": "named"},
|
29
|
+
)
|
30
|
+
|
31
|
+
with context.begin_transaction():
|
32
|
+
context.run_migrations()
|
33
|
+
|
34
|
+
def run_migrations_online() -> None:
|
35
|
+
"""Run migrations in 'online' mode."""
|
36
|
+
connectable = engine_from_config(
|
37
|
+
config.get_section(config.config_ini_section, {}),
|
38
|
+
prefix="sqlalchemy.",
|
39
|
+
poolclass=pool.NullPool,
|
40
|
+
)
|
41
|
+
|
42
|
+
with connectable.connect() as connection:
|
43
|
+
context.configure(
|
44
|
+
connection=connection, target_metadata=target_metadata
|
45
|
+
)
|
46
|
+
|
47
|
+
with context.begin_transaction():
|
48
|
+
context.run_migrations()
|
49
|
+
|
50
|
+
if context.is_offline_mode():
|
51
|
+
run_migrations_offline()
|
52
|
+
else:
|
53
|
+
run_migrations_online()
|
@@ -0,0 +1,26 @@
|
|
1
|
+
"""${message}
|
2
|
+
|
3
|
+
Revision ID: ${up_revision}
|
4
|
+
Revises: ${down_revision | comma,n}
|
5
|
+
Create Date: ${create_date}
|
6
|
+
|
7
|
+
"""
|
8
|
+
from typing import Sequence, Union
|
9
|
+
|
10
|
+
from alembic import op
|
11
|
+
import sqlalchemy as sa
|
12
|
+
${imports if imports else ""}
|
13
|
+
|
14
|
+
# revision identifiers, used by Alembic.
|
15
|
+
revision: str = ${repr(up_revision)}
|
16
|
+
down_revision: Union[str, None] = ${repr(down_revision)}
|
17
|
+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
18
|
+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
19
|
+
|
20
|
+
|
21
|
+
def upgrade() -> None:
|
22
|
+
${upgrades if upgrades else "pass"}
|
23
|
+
|
24
|
+
|
25
|
+
def downgrade() -> None:
|
26
|
+
${downgrades if downgrades else "pass"}
|
@@ -0,0 +1,40 @@
|
|
1
|
+
"""Automatic database migrations manager."""
|
2
|
+
|
3
|
+
import os
|
4
|
+
from pathlib import Path
|
5
|
+
from alembic import command
|
6
|
+
from alembic.config import Config
|
7
|
+
from alembic.runtime.migration import MigrationContext
|
8
|
+
from alembic.script import ScriptDirectory
|
9
|
+
from sqlalchemy import create_engine
|
10
|
+
|
11
|
+
from .utils import get_default_db_path
|
12
|
+
|
13
|
+
def get_alembic_config():
|
14
|
+
"""Get Alembic config pointing to the package's migrations."""
|
15
|
+
package_dir = Path(__file__).parent
|
16
|
+
migrations_dir = package_dir / "migrations"
|
17
|
+
|
18
|
+
alembic_cfg = Config()
|
19
|
+
alembic_cfg.set_main_option("script_location", str(migrations_dir))
|
20
|
+
alembic_cfg.set_main_option("sqlalchemy.url", f"sqlite:///{get_default_db_path()}")
|
21
|
+
|
22
|
+
return alembic_cfg
|
23
|
+
|
24
|
+
def check_and_run_migrations():
|
25
|
+
"""Check if migrations are needed and run them automatically."""
|
26
|
+
engine = create_engine(f"sqlite:///{get_default_db_path()}")
|
27
|
+
|
28
|
+
# Create migrations table if it doesn't exist
|
29
|
+
with engine.connect() as conn:
|
30
|
+
context = MigrationContext.configure(conn)
|
31
|
+
current_rev = context.get_current_revision()
|
32
|
+
|
33
|
+
# Get latest available revision
|
34
|
+
config = get_alembic_config()
|
35
|
+
script = ScriptDirectory.from_config(config)
|
36
|
+
head_rev = script.get_current_head()
|
37
|
+
|
38
|
+
# Run migrations if needed
|
39
|
+
if current_rev != head_rev:
|
40
|
+
command.upgrade(config, "head")
|
@@ -0,0 +1,31 @@
|
|
1
|
+
from pydantic import BaseModel, Field
|
2
|
+
from typing import Dict, List
|
3
|
+
|
4
|
+
class TokenRate(BaseModel):
|
5
|
+
prompt: float = Field(..., description="Cost per prompt token")
|
6
|
+
completion: float = Field(..., description="Cost per completion token")
|
7
|
+
|
8
|
+
class TokenMetrics(BaseModel):
|
9
|
+
total_cost: float = Field(..., description="Total cost in USD")
|
10
|
+
total_tokens: int = Field(..., description="Total tokens used")
|
11
|
+
prompt_tokens: int = Field(..., description="Number of prompt tokens")
|
12
|
+
completion_tokens: int = Field(..., description="Number of completion tokens")
|
13
|
+
|
14
|
+
class ModelUsage(TokenMetrics):
|
15
|
+
model: str = Field(..., description="Model name")
|
16
|
+
|
17
|
+
class ProviderUsage(TokenMetrics):
|
18
|
+
provider: str = Field(..., description="Provider name")
|
19
|
+
models: List[ModelUsage] = Field(default_factory=list, description="Usage breakdown by model")
|
20
|
+
|
21
|
+
class TokenUsageReport(TokenMetrics):
|
22
|
+
providers: List[ProviderUsage] = Field(default_factory=list, description="Usage breakdown by provider")
|
23
|
+
|
24
|
+
class Usage(BaseModel):
|
25
|
+
prompt_tokens: int = 0
|
26
|
+
completion_tokens: int = 0
|
27
|
+
total_tokens: int = 0
|
28
|
+
|
29
|
+
class TokenUsageStats(BaseModel):
|
30
|
+
model: str
|
31
|
+
usage: Usage
|
@@ -0,0 +1,60 @@
|
|
1
|
+
"""SQLAlchemy models for tokenator."""
|
2
|
+
|
3
|
+
import uuid
|
4
|
+
from datetime import datetime
|
5
|
+
|
6
|
+
from sqlalchemy import create_engine, Column, Integer, String, DateTime, Float, Index
|
7
|
+
from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base
|
8
|
+
|
9
|
+
from .utils import get_default_db_path
|
10
|
+
|
11
|
+
Base = declarative_base()
|
12
|
+
|
13
|
+
def get_engine(db_path: str = None):
|
14
|
+
"""Create SQLAlchemy engine with the given database path."""
|
15
|
+
db_path = db_path or get_default_db_path()
|
16
|
+
return create_engine(f"sqlite:///{db_path}", echo=False)
|
17
|
+
|
18
|
+
def get_session(db_path: str = None):
|
19
|
+
"""Create a thread-safe session factory."""
|
20
|
+
engine = get_engine(db_path)
|
21
|
+
Base.metadata.create_all(engine)
|
22
|
+
session_factory = sessionmaker(bind=engine)
|
23
|
+
return scoped_session(session_factory)
|
24
|
+
|
25
|
+
class TokenUsage(Base):
|
26
|
+
"""Model for tracking token usage."""
|
27
|
+
|
28
|
+
__tablename__ = "token_usage"
|
29
|
+
|
30
|
+
id = Column(Integer, primary_key=True)
|
31
|
+
execution_id = Column(String, nullable=False)
|
32
|
+
provider = Column(String, nullable=False)
|
33
|
+
model = Column(String, nullable=False)
|
34
|
+
created_at = Column(DateTime, nullable=False, default=datetime.now)
|
35
|
+
updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
|
36
|
+
prompt_tokens = Column(Integer, nullable=False)
|
37
|
+
completion_tokens = Column(Integer, nullable=False)
|
38
|
+
total_tokens = Column(Integer, nullable=False)
|
39
|
+
|
40
|
+
# Create indexes
|
41
|
+
__table_args__ = (
|
42
|
+
Index('idx_created_at', 'created_at'),
|
43
|
+
Index('idx_execution_id', 'execution_id'),
|
44
|
+
Index('idx_provider', 'provider'),
|
45
|
+
Index('idx_model', 'model'),
|
46
|
+
)
|
47
|
+
|
48
|
+
def to_dict(self):
|
49
|
+
"""Convert model instance to dictionary."""
|
50
|
+
return {
|
51
|
+
'id': self.id,
|
52
|
+
'execution_id': self.execution_id,
|
53
|
+
'provider': self.provider,
|
54
|
+
'model': self.model,
|
55
|
+
'created_at': self.created_at,
|
56
|
+
'updated_at': self.updated_at,
|
57
|
+
'prompt_tokens': self.prompt_tokens,
|
58
|
+
'completion_tokens': self.completion_tokens,
|
59
|
+
'total_tokens': self.total_tokens
|
60
|
+
}
|
@@ -0,0 +1,202 @@
|
|
1
|
+
"""Cost analysis functions for token usage."""
|
2
|
+
|
3
|
+
from datetime import datetime, timedelta, timezone
|
4
|
+
from typing import Dict, Optional, Union
|
5
|
+
|
6
|
+
from sqlalchemy import and_
|
7
|
+
|
8
|
+
from .schemas import get_session, TokenUsage
|
9
|
+
from .models import TokenRate, TokenUsageReport, ModelUsage, ProviderUsage
|
10
|
+
|
11
|
+
import requests
|
12
|
+
import logging
|
13
|
+
|
14
|
+
logger = logging.getLogger(__name__)
|
15
|
+
|
16
|
+
def _get_model_costs() -> Dict[str, TokenRate]:
|
17
|
+
url = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"
|
18
|
+
response = requests.get(url)
|
19
|
+
data = response.json()
|
20
|
+
|
21
|
+
return {
|
22
|
+
model: TokenRate(
|
23
|
+
prompt=info["input_cost_per_token"],
|
24
|
+
completion=info["output_cost_per_token"]
|
25
|
+
)
|
26
|
+
for model, info in data.items()
|
27
|
+
if "input_cost_per_token" in info and "output_cost_per_token" in info
|
28
|
+
}
|
29
|
+
|
30
|
+
MODEL_COSTS = _get_model_costs()
|
31
|
+
|
32
|
+
def _calculate_cost(usages: list[TokenUsage], provider: Optional[str] = None) -> TokenUsageReport:
|
33
|
+
"""Calculate cost from token usage records."""
|
34
|
+
# Group usages by provider and model
|
35
|
+
provider_model_usages: Dict[str, Dict[str, list[TokenUsage]]] = {}
|
36
|
+
|
37
|
+
print(f"usages: {len(usages)}")
|
38
|
+
|
39
|
+
for usage in usages:
|
40
|
+
if usage.model not in MODEL_COSTS:
|
41
|
+
continue
|
42
|
+
|
43
|
+
provider = usage.provider
|
44
|
+
if provider not in provider_model_usages:
|
45
|
+
provider_model_usages[provider] = {}
|
46
|
+
|
47
|
+
if usage.model not in provider_model_usages[provider]:
|
48
|
+
provider_model_usages[provider][usage.model] = []
|
49
|
+
|
50
|
+
provider_model_usages[provider][usage.model].append(usage)
|
51
|
+
|
52
|
+
# Calculate totals for each level
|
53
|
+
providers_list = []
|
54
|
+
total_metrics = {"total_cost": 0.0, "total_tokens": 0, "prompt_tokens": 0, "completion_tokens": 0}
|
55
|
+
|
56
|
+
for provider, model_usages in provider_model_usages.items():
|
57
|
+
provider_metrics = {"total_cost": 0.0, "total_tokens": 0, "prompt_tokens": 0, "completion_tokens": 0}
|
58
|
+
models_list = []
|
59
|
+
|
60
|
+
for model, usages in model_usages.items():
|
61
|
+
model_cost = 0.0
|
62
|
+
model_total = 0
|
63
|
+
model_prompt = 0
|
64
|
+
model_completion = 0
|
65
|
+
|
66
|
+
for usage in usages:
|
67
|
+
model_prompt += usage.prompt_tokens
|
68
|
+
model_completion += usage.completion_tokens
|
69
|
+
model_total += usage.total_tokens
|
70
|
+
|
71
|
+
model_cost += (usage.prompt_tokens * MODEL_COSTS[usage.model].prompt)
|
72
|
+
model_cost += (usage.completion_tokens * MODEL_COSTS[usage.model].completion)
|
73
|
+
|
74
|
+
models_list.append(ModelUsage(
|
75
|
+
model=model,
|
76
|
+
total_cost=round(model_cost, 6),
|
77
|
+
total_tokens=model_total,
|
78
|
+
prompt_tokens=model_prompt,
|
79
|
+
completion_tokens=model_completion
|
80
|
+
))
|
81
|
+
|
82
|
+
# Add to provider totals
|
83
|
+
provider_metrics["total_cost"] += model_cost
|
84
|
+
provider_metrics["total_tokens"] += model_total
|
85
|
+
provider_metrics["prompt_tokens"] += model_prompt
|
86
|
+
provider_metrics["completion_tokens"] += model_completion
|
87
|
+
|
88
|
+
providers_list.append(ProviderUsage(
|
89
|
+
provider=provider,
|
90
|
+
models=models_list,
|
91
|
+
**{k: (round(v, 6) if k == "total_cost" else v) for k, v in provider_metrics.items()}
|
92
|
+
))
|
93
|
+
|
94
|
+
# Add to grand totals
|
95
|
+
for key in total_metrics:
|
96
|
+
total_metrics[key] += provider_metrics[key]
|
97
|
+
|
98
|
+
return TokenUsageReport(
|
99
|
+
providers=providers_list,
|
100
|
+
**{k: (round(v, 6) if k == "total_cost" else v) for k, v in total_metrics.items()}
|
101
|
+
)
|
102
|
+
|
103
|
+
def _query_usage(start_date: datetime, end_date: datetime,
|
104
|
+
provider: Optional[str] = None,
|
105
|
+
model: Optional[str] = None) -> TokenUsageReport:
|
106
|
+
"""Query token usage for a specific time period."""
|
107
|
+
session = get_session()()
|
108
|
+
try:
|
109
|
+
query = session.query(TokenUsage).filter(
|
110
|
+
TokenUsage.created_at.between(start_date, end_date)
|
111
|
+
)
|
112
|
+
|
113
|
+
if provider:
|
114
|
+
query = query.filter(TokenUsage.provider == provider)
|
115
|
+
if model:
|
116
|
+
query = query.filter(TokenUsage.model == model)
|
117
|
+
|
118
|
+
usages = query.all()
|
119
|
+
return _calculate_cost(usages, provider or "all")
|
120
|
+
finally:
|
121
|
+
session.close()
|
122
|
+
|
123
|
+
def last_hour(provider: Optional[str] = None, model: Optional[str] = None) -> TokenUsageReport:
|
124
|
+
"""Get cost analysis for the last hour."""
|
125
|
+
logger.debug(f"Getting cost analysis for last hour (provider={provider}, model={model})")
|
126
|
+
end = datetime.now()
|
127
|
+
start = end - timedelta(hours=1)
|
128
|
+
return _query_usage(start, end, provider, model)
|
129
|
+
|
130
|
+
def last_day(provider: Optional[str] = None, model: Optional[str] = None) -> TokenUsageReport:
|
131
|
+
"""Get cost analysis for the last 24 hours."""
|
132
|
+
logger.debug(f"Getting cost analysis for last 24 hours (provider={provider}, model={model})")
|
133
|
+
end = datetime.now()
|
134
|
+
start = end - timedelta(days=1)
|
135
|
+
return _query_usage(start, end, provider, model)
|
136
|
+
|
137
|
+
def last_week(provider: Optional[str] = None, model: Optional[str] = None) -> TokenUsageReport:
|
138
|
+
"""Get cost analysis for the last 7 days."""
|
139
|
+
logger.debug(f"Getting cost analysis for last 7 days (provider={provider}, model={model})")
|
140
|
+
end = datetime.now()
|
141
|
+
start = end - timedelta(weeks=1)
|
142
|
+
return _query_usage(start, end, provider, model)
|
143
|
+
|
144
|
+
def last_month(provider: Optional[str] = None, model: Optional[str] = None) -> TokenUsageReport:
|
145
|
+
"""Get cost analysis for the last 30 days."""
|
146
|
+
logger.debug(f"Getting cost analysis for last 30 days (provider={provider}, model={model})")
|
147
|
+
end = datetime.now()
|
148
|
+
start = end - timedelta(days=30)
|
149
|
+
return _query_usage(start, end, provider, model)
|
150
|
+
|
151
|
+
def between(
|
152
|
+
start_date: Union[datetime, str],
|
153
|
+
end_date: Union[datetime, str],
|
154
|
+
provider: Optional[str] = None,
|
155
|
+
model: Optional[str] = None
|
156
|
+
) -> TokenUsageReport:
|
157
|
+
"""Get cost analysis between two dates.
|
158
|
+
|
159
|
+
Args:
|
160
|
+
start_date: datetime object or string (format: YYYY-MM-DD or YYYY-MM-DD HH:MM:SS)
|
161
|
+
end_date: datetime object or string (format: YYYY-MM-DD or YYYY-MM-DD HH:MM:SS)
|
162
|
+
"""
|
163
|
+
logger.debug(f"Getting cost analysis between {start_date} and {end_date} (provider={provider}, model={model})")
|
164
|
+
|
165
|
+
if isinstance(start_date, str):
|
166
|
+
try:
|
167
|
+
start = datetime.strptime(start_date, "%Y-%m-%d %H:%M:%S")
|
168
|
+
except ValueError:
|
169
|
+
start = datetime.strptime(start_date, "%Y-%m-%d")
|
170
|
+
else:
|
171
|
+
start = start_date
|
172
|
+
|
173
|
+
if isinstance(end_date, str):
|
174
|
+
try:
|
175
|
+
end = datetime.strptime(end_date, "%Y-%m-%d %H:%M:%S")
|
176
|
+
except ValueError:
|
177
|
+
end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) # Include the end date
|
178
|
+
else:
|
179
|
+
end = end_date
|
180
|
+
|
181
|
+
return _query_usage(start, end, provider, model)
|
182
|
+
|
183
|
+
def for_execution(execution_id: str) -> TokenUsageReport:
|
184
|
+
"""Get cost analysis for a specific execution."""
|
185
|
+
logger.debug(f"Getting cost analysis for execution_id={execution_id}")
|
186
|
+
session = get_session()()
|
187
|
+
query = session.query(TokenUsage).filter(TokenUsage.execution_id == execution_id)
|
188
|
+
return _calculate_cost(query.all())
|
189
|
+
|
190
|
+
def last_execution() -> TokenUsageReport:
|
191
|
+
"""Get cost analysis for the last execution_id."""
|
192
|
+
logger.debug("Getting cost analysis for last execution")
|
193
|
+
session = get_session()()
|
194
|
+
query = session.query(TokenUsage).order_by(TokenUsage.created_at.desc()).first()
|
195
|
+
return for_execution(query.execution_id)
|
196
|
+
|
197
|
+
def all_time() -> TokenUsageReport:
|
198
|
+
"""Get cost analysis for all time."""
|
199
|
+
logger.warning("Getting cost analysis for all time. This may take a while...")
|
200
|
+
session = get_session()()
|
201
|
+
query = session.query(TokenUsage).all()
|
202
|
+
return for_execution(query.execution_id)
|
@@ -0,0 +1,35 @@
|
|
1
|
+
"""Shared utility functions for tokenator."""
|
2
|
+
|
3
|
+
import os
|
4
|
+
import platform
|
5
|
+
from pathlib import Path
|
6
|
+
from typing import Optional
|
7
|
+
|
8
|
+
def get_default_db_path() -> str:
|
9
|
+
"""Get the platform-specific default database path.
|
10
|
+
|
11
|
+
Returns:
|
12
|
+
str: Path to the SQLite database file
|
13
|
+
|
14
|
+
The path follows platform conventions:
|
15
|
+
- Linux/macOS: ~/.local/share/tokenator/usage.db (XDG spec)
|
16
|
+
- Windows: %LOCALAPPDATA%\\tokenator\\usage.db
|
17
|
+
- Others: ~/.tokenator/usage.db
|
18
|
+
"""
|
19
|
+
system = platform.system().lower()
|
20
|
+
|
21
|
+
if system == "linux" or system == "darwin":
|
22
|
+
# Follow XDG Base Directory Specification
|
23
|
+
xdg_data_home = os.environ.get("XDG_DATA_HOME", "")
|
24
|
+
if not xdg_data_home:
|
25
|
+
xdg_data_home = os.path.join(str(Path.home()), ".local", "share")
|
26
|
+
return os.path.join(xdg_data_home, "tokenator", "usage.db")
|
27
|
+
elif system == "windows":
|
28
|
+
# Use %LOCALAPPDATA% on Windows
|
29
|
+
local_app_data = os.environ.get("LOCALAPPDATA", "")
|
30
|
+
if not local_app_data:
|
31
|
+
local_app_data = os.path.join(str(Path.home()), "AppData", "Local")
|
32
|
+
return os.path.join(local_app_data, "tokenator", "usage.db")
|
33
|
+
else:
|
34
|
+
# Fallback for other systems
|
35
|
+
return os.path.join(str(Path.home()), ".tokenator", "usage.db")
|