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.
@@ -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,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")