tokenator 0.1.0__py3-none-any.whl
Sign up to get free protection for your applications and to get access to all the features.
- tokenator/__init__.py +12 -0
- tokenator/base_wrapper.py +61 -0
- tokenator/client_anthropic.py +148 -0
- tokenator/client_openai.py +149 -0
- tokenator/create_migrations.py +21 -0
- tokenator/migrations/env.py +53 -0
- tokenator/migrations/script.py.mako +26 -0
- tokenator/migrations.py +40 -0
- tokenator/models.py +31 -0
- tokenator/schemas.py +60 -0
- tokenator/usage.py +202 -0
- tokenator/utils.py +35 -0
- tokenator-0.1.0.dist-info/LICENSE +21 -0
- tokenator-0.1.0.dist-info/METADATA +100 -0
- tokenator-0.1.0.dist-info/RECORD +16 -0
- tokenator-0.1.0.dist-info/WHEEL +4 -0
tokenator/__init__.py
ADDED
@@ -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"}
|
tokenator/migrations.py
ADDED
@@ -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")
|
tokenator/models.py
ADDED
@@ -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
|
tokenator/schemas.py
ADDED
@@ -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
|
+
}
|
tokenator/usage.py
ADDED
@@ -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)
|
tokenator/utils.py
ADDED
@@ -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")
|
@@ -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.
|
@@ -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,16 @@
|
|
1
|
+
tokenator/__init__.py,sha256=yBw4D1VQWe9fJfDvGavy1OFEEW0BIM2714dVk70HenE,363
|
2
|
+
tokenator/base_wrapper.py,sha256=vSu_pStKYulho7_5g0jMCNf84KRxC4kTKep0v8YE61M,2377
|
3
|
+
tokenator/client_anthropic.py,sha256=1ejWIZBxtk-mWTVaKWeMUvS2hZ_Dn-vNKYa3yopdjAU,6714
|
4
|
+
tokenator/client_openai.py,sha256=1xZuRA90kwlflTwEuFkXJHHN584XTeNh1CfEBMLELbQ,6308
|
5
|
+
tokenator/create_migrations.py,sha256=C_3WqB0tOGKXOA4JmvWuLpcyGEysWyRSiSttxX-Kie4,606
|
6
|
+
tokenator/migrations/env.py,sha256=eFTw66gG464JV53740RKU32wqEL8uZFReS_INrvkFrU,1414
|
7
|
+
tokenator/migrations/script.py.mako,sha256=nJL-tbLQE0Qy4P9S4r4ntNAcikPtoFUlvXe6xvm9ot8,635
|
8
|
+
tokenator/migrations.py,sha256=GH84M6AzidTcscMP0nQspBaQ1v6bjx00V4FeN-v5XAo,1354
|
9
|
+
tokenator/models.py,sha256=EprE_MMJxDS-YXlcIQLZzfekH7xTYbeOC3bx3B2osVw,1171
|
10
|
+
tokenator/schemas.py,sha256=KQrKv_9Euoai3hBZcuiVclU3BlxQkdfEbwYoFV8MKXY,2112
|
11
|
+
tokenator/usage.py,sha256=aHjGwzDzaiVznahNk5HqVyk3IxDo5FtFVfOUCeE7DZ4,7833
|
12
|
+
tokenator/utils.py,sha256=VF-t6eL16_KokICMfZvO1rIJX9Hm7rexUcaeuLI2iRA,1311
|
13
|
+
tokenator-0.1.0.dist-info/LICENSE,sha256=wdG-B6-ODk8RQ4jq5uXSn0w1UWTzCH_MMyvh7AwtGns,1074
|
14
|
+
tokenator-0.1.0.dist-info/METADATA,sha256=dlsCQKC_2qVw4OEfj8ISrt5QwIY774PO_H2S3ilj92g,2444
|
15
|
+
tokenator-0.1.0.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
|
16
|
+
tokenator-0.1.0.dist-info/RECORD,,
|