tokenator 0.1.0__tar.gz

Sign up to get free protection for your applications and to get access to all the features.
@@ -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")