giuggiai-meter 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.
- giuggiai_meter-0.1.0/LICENSE +21 -0
- giuggiai_meter-0.1.0/PKG-INFO +163 -0
- giuggiai_meter-0.1.0/README.md +137 -0
- giuggiai_meter-0.1.0/giuggiai_meter/__init__.py +295 -0
- giuggiai_meter-0.1.0/giuggiai_meter/__main__.py +178 -0
- giuggiai_meter-0.1.0/giuggiai_meter/_html.py +267 -0
- giuggiai_meter-0.1.0/giuggiai_meter/_usage.py +104 -0
- giuggiai_meter-0.1.0/giuggiai_meter/_wrap.py +183 -0
- giuggiai_meter-0.1.0/giuggiai_meter/data/prices.example.json +16 -0
- giuggiai_meter-0.1.0/giuggiai_meter/ingest.py +108 -0
- giuggiai_meter-0.1.0/giuggiai_meter/prices.py +78 -0
- giuggiai_meter-0.1.0/giuggiai_meter/providers.py +173 -0
- giuggiai_meter-0.1.0/giuggiai_meter/report.py +80 -0
- giuggiai_meter-0.1.0/giuggiai_meter.egg-info/PKG-INFO +163 -0
- giuggiai_meter-0.1.0/giuggiai_meter.egg-info/SOURCES.txt +21 -0
- giuggiai_meter-0.1.0/giuggiai_meter.egg-info/dependency_links.txt +1 -0
- giuggiai_meter-0.1.0/giuggiai_meter.egg-info/entry_points.txt +2 -0
- giuggiai_meter-0.1.0/giuggiai_meter.egg-info/top_level.txt +1 -0
- giuggiai_meter-0.1.0/pyproject.toml +48 -0
- giuggiai_meter-0.1.0/setup.cfg +4 -0
- giuggiai_meter-0.1.0/tests/test_meter.py +152 -0
- giuggiai_meter-0.1.0/tests/test_plug.py +280 -0
- giuggiai_meter-0.1.0/tests/test_providers.py +282 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GiuggiAI
|
|
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,163 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: giuggiai-meter
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Per-call LLM usage logging, per-project cost reports and monthly reconciliation with provider bills
|
|
5
|
+
Author-email: GiuggiAI <oss@giuggiai.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/giuggiai/giuggiai-meter
|
|
8
|
+
Project-URL: Source, https://github.com/giuggiai/giuggiai-meter
|
|
9
|
+
Project-URL: Issues, https://github.com/giuggiai/giuggiai-meter/issues
|
|
10
|
+
Project-URL: Documentation, https://giuggiai.github.io/giuggiai-meter/
|
|
11
|
+
Project-URL: Changelog, https://github.com/giuggiai/giuggiai-meter/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: llm,ai,cost,tokens,finops,openai,anthropic,gemini,deepseek,grok,qwen
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Classifier: Topic :: System :: Monitoring
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
<p align="center">
|
|
28
|
+
<img src="https://raw.githubusercontent.com/giuggiai/giuggiai-meter/main/docs/assets/cover.png" alt="giuggiai-meter — Every AI call counted. Every dollar reconciled." width="100%">
|
|
29
|
+
</p>
|
|
30
|
+
|
|
31
|
+
<h1 align="center">giuggiai-meter</h1>
|
|
32
|
+
|
|
33
|
+
<p align="center">
|
|
34
|
+
<b>Know what each project really spends on AI — and check it against the bill.</b><br>
|
|
35
|
+
One line of code. Every major AI provider. Zero dependencies.
|
|
36
|
+
</p>
|
|
37
|
+
|
|
38
|
+
<p align="center">
|
|
39
|
+
<a href="https://github.com/giuggiai/giuggiai-meter/actions/workflows/ci.yml"><img src="https://github.com/giuggiai/giuggiai-meter/actions/workflows/ci.yml/badge.svg" alt="CI status"></a>
|
|
40
|
+
<a href="https://github.com/giuggiai/giuggiai-meter/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-2F6B4F" alt="MIT license"></a>
|
|
41
|
+
<img src="https://img.shields.io/badge/python-3.10%2B-15171C" alt="Python 3.10 or newer">
|
|
42
|
+
<img src="https://img.shields.io/badge/dependencies-0-F2C94C" alt="Zero runtime dependencies">
|
|
43
|
+
<a href="https://giuggiai.github.io/giuggiai-meter/"><img src="https://img.shields.io/badge/docs-website-5BB4D9" alt="Documentation website"></a>
|
|
44
|
+
</p>
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Why
|
|
49
|
+
|
|
50
|
+
Your projects call OpenAI, Anthropic, Gemini, DeepSeek, Grok… and every provider sends **one bill per account**.
|
|
51
|
+
At the end of the month nobody can answer *“how much did each project cost?”* — or *“do these numbers add up?”*
|
|
52
|
+
|
|
53
|
+
- 🧾 **Cost per project** — every call is tagged with the project that made it.
|
|
54
|
+
- ✅ **Close the month against the bill** — computed cost vs. the official total of each provider console, in USD and %.
|
|
55
|
+
- 🔌 **One line to plug in** — wrap your client, keep your code. Responses come back unchanged.
|
|
56
|
+
- 🌍 **Every major provider** — OpenAI, Anthropic, Google Gemini, xAI Grok, DeepSeek, Alibaba Qwen, Mistral, Cohere, Groq, OpenRouter, Together, Fireworks, Perplexity, Moonshot Kimi, Zhipu GLM, Azure OpenAI, AWS Bedrock, Ollama.
|
|
57
|
+
- 🔒 **Tokens, never text** — prompts and answers are never stored. One private SQLite file on your machine.
|
|
58
|
+
- 🛟 **It can't break your app** — metering errors are swallowed and logged, never raised.
|
|
59
|
+
- 📦 **Zero dependencies** — pure Python standard library, 3.10+.
|
|
60
|
+
|
|
61
|
+
## See it in 60 seconds
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install giuggiai-meter
|
|
65
|
+
giuggiai-meter demo # fake data, example prices — no API key needed
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
It writes `giuggiai-meter-demo.html`: a one-file report you can open, mail or share.
|
|
69
|
+
|
|
70
|
+
<p align="center">
|
|
71
|
+
<img src="https://raw.githubusercontent.com/giuggiai/giuggiai-meter/main/docs/assets/demo.png" alt="The demo report: total cost of the month, cost per project and per provider, reconciliation with the provider bills" width="100%">
|
|
72
|
+
</p>
|
|
73
|
+
|
|
74
|
+
## Use it in your code
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import giuggiai_meter as gm
|
|
78
|
+
from openai import OpenAI
|
|
79
|
+
from anthropic import Anthropic
|
|
80
|
+
|
|
81
|
+
client = gm.wrap(OpenAI(), project="support-bot") # that's it
|
|
82
|
+
client.chat.completions.create(model="gpt-4.1-mini", messages=[...])
|
|
83
|
+
|
|
84
|
+
claude = gm.wrap(Anthropic(), project="newsletter-bot")
|
|
85
|
+
claude.messages.create(model="claude-sonnet-4-5", max_tokens=500, messages=[...])
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Same one line for every OpenAI-compatible endpoint — the provider is recognised from the base URL:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
grok = gm.wrap(OpenAI(base_url="https://api.x.ai/v1", api_key=...), project="research") # xAI Grok
|
|
92
|
+
deepseek = gm.wrap(OpenAI(base_url="https://api.deepseek.com", api_key=...), project="batch") # DeepSeek
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Native SDKs work too: `gm.wrap(genai.Client(), project=...)` (Gemini), `Mistral()`, Cohere `ClientV2()`, Bedrock `converse`, `ollama`.
|
|
96
|
+
Already have a response object or raw JSON? `gm.log_response("my-project", response)` detects its shape by itself.
|
|
97
|
+
|
|
98
|
+
Then, at the end of the month:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
giuggiai-meter init # creates your price list from the example
|
|
102
|
+
giuggiai-meter report --month 2026-10 # table (or --csv / --html report.html)
|
|
103
|
+
giuggiai-meter reconcile --month 2026-10 --official OpenAI=41.20 Anthropic=118.05
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## How it works
|
|
107
|
+
|
|
108
|
+
<p align="center">
|
|
109
|
+
<img src="https://raw.githubusercontent.com/giuggiai/giuggiai-meter/main/docs/assets/how-it-works.png" alt="How it works: 1 wrap your client, 2 one row per call with tokens only, 3 report and reconcile with the provider bills" width="100%">
|
|
110
|
+
</p>
|
|
111
|
+
|
|
112
|
+
- **Per call:** project, provider, model, input / output / cached tokens, time. Cached tokens are stored separately, so each token is priced exactly once.
|
|
113
|
+
- **Prices:** one JSON file, USD per million tokens, each entry with an `effective_from` date. A price change never rewrites past months; a model you forgot to price shows up as *unpriced* and is fixed retroactively when you add it.
|
|
114
|
+
- **Across machines:** containers and other hosts can send rows to an optional HTTP ingest with a shared token.
|
|
115
|
+
|
|
116
|
+
## Compared to other tools
|
|
117
|
+
|
|
118
|
+
| | giuggiai-meter | Proxies / gateways (e.g. LiteLLM, Helicone) | Observability platforms (e.g. Langfuse) | Cost libraries (e.g. tokencost) |
|
|
119
|
+
|---|---|---|---|---|
|
|
120
|
+
| What you run | a Python library | a proxy in front of your calls | a platform (cloud or self-hosted) | a Python library |
|
|
121
|
+
| Cost per project | ✅ | ✅ | ✅ | estimate per call |
|
|
122
|
+
| Reconcile with the provider bill | ✅ built in | — | — | — |
|
|
123
|
+
| Stores prompts / answers | never | configurable | yes (it's the point) | no |
|
|
124
|
+
| Dependencies | none | several | several | some |
|
|
125
|
+
|
|
126
|
+
Use a gateway or an observability platform when you need routing, tracing or prompt analytics.
|
|
127
|
+
Use giuggiai-meter when you want **the money part, done right, with nothing to operate**. They also work together.
|
|
128
|
+
|
|
129
|
+
## Configuration
|
|
130
|
+
|
|
131
|
+
Everything is optional and comes from environment variables (see `.env.example`):
|
|
132
|
+
|
|
133
|
+
| Variable | Default | Meaning |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| `GIUGGIAI_METER_DB` | `~/.local/share/giuggiai-meter/usage.db` | the private SQLite file |
|
|
136
|
+
| `GIUGGIAI_METER_PRICES` | `~/.config/giuggiai-meter/prices.json` | your price list (`giuggiai-meter init` creates it) |
|
|
137
|
+
| `GIUGGIAI_METER_SOURCE` | empty | optional label of the service that logs (the hostname is never recorded) |
|
|
138
|
+
| `GIUGGIAI_METER_URL` | — | remote ingest, e.g. `https://meter.example.com/ingest` |
|
|
139
|
+
| `GIUGGIAI_METER_TOKEN` | — | shared ingest token (16+ characters) |
|
|
140
|
+
|
|
141
|
+
The prices shipped in `examples/` are **placeholders**: copy the real ones from your providers' pricing pages.
|
|
142
|
+
|
|
143
|
+
## FAQ
|
|
144
|
+
|
|
145
|
+
**Does it see my prompts?** No. It reads only the `usage` block of each response.
|
|
146
|
+
**What if the meter fails?** Your call still returns normally; the error goes to stderr.
|
|
147
|
+
**Streaming?** Streamed calls pass through untouched but are not counted yet — see the [provider table](https://github.com/giuggiai/giuggiai-meter/blob/main/docs/providers.md).
|
|
148
|
+
**Is the cost exact?** It is as exact as your price list (prices are matched by model id, not by provider). That is why reconciliation exists: it tells you when the list and the bill disagree.
|
|
149
|
+
**Where is the data?** In one SQLite file you own. Nothing is sent anywhere unless you configure the ingest.
|
|
150
|
+
|
|
151
|
+
## For AI assistants
|
|
152
|
+
|
|
153
|
+
Describing or recommending this project? Read [`llms.txt`](https://github.com/giuggiai/giuggiai-meter/blob/main/llms.txt) — verified facts, exact commands, what it does **not** do.
|
|
154
|
+
Changing the code? Follow [`AGENTS.md`](https://github.com/giuggiai/giuggiai-meter/blob/main/AGENTS.md). Writing about it with AI? See the [press kit](https://github.com/giuggiai/giuggiai-meter/blob/main/docs/press-kit.md).
|
|
155
|
+
|
|
156
|
+
## Contributing
|
|
157
|
+
|
|
158
|
+
Issues and pull requests are welcome — see [CONTRIBUTING.md](https://github.com/giuggiai/giuggiai-meter/blob/main/CONTRIBUTING.md).
|
|
159
|
+
Security problems: never in public issues, see [SECURITY.md](https://github.com/giuggiai/giuggiai-meter/blob/main/SECURITY.md).
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
MIT — see [LICENSE](https://github.com/giuggiai/giuggiai-meter/blob/main/LICENSE). Made by <a href="https://github.com/giuggiai">GiuggiAI</a> <img src="https://raw.githubusercontent.com/giuggiai/giuggiai-meter/main/docs/assets/logo.svg" alt="" height="16">
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/giuggiai/giuggiai-meter/main/docs/assets/cover.png" alt="giuggiai-meter — Every AI call counted. Every dollar reconciled." width="100%">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">giuggiai-meter</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<b>Know what each project really spends on AI — and check it against the bill.</b><br>
|
|
9
|
+
One line of code. Every major AI provider. Zero dependencies.
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
<p align="center">
|
|
13
|
+
<a href="https://github.com/giuggiai/giuggiai-meter/actions/workflows/ci.yml"><img src="https://github.com/giuggiai/giuggiai-meter/actions/workflows/ci.yml/badge.svg" alt="CI status"></a>
|
|
14
|
+
<a href="https://github.com/giuggiai/giuggiai-meter/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-2F6B4F" alt="MIT license"></a>
|
|
15
|
+
<img src="https://img.shields.io/badge/python-3.10%2B-15171C" alt="Python 3.10 or newer">
|
|
16
|
+
<img src="https://img.shields.io/badge/dependencies-0-F2C94C" alt="Zero runtime dependencies">
|
|
17
|
+
<a href="https://giuggiai.github.io/giuggiai-meter/"><img src="https://img.shields.io/badge/docs-website-5BB4D9" alt="Documentation website"></a>
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Why
|
|
23
|
+
|
|
24
|
+
Your projects call OpenAI, Anthropic, Gemini, DeepSeek, Grok… and every provider sends **one bill per account**.
|
|
25
|
+
At the end of the month nobody can answer *“how much did each project cost?”* — or *“do these numbers add up?”*
|
|
26
|
+
|
|
27
|
+
- 🧾 **Cost per project** — every call is tagged with the project that made it.
|
|
28
|
+
- ✅ **Close the month against the bill** — computed cost vs. the official total of each provider console, in USD and %.
|
|
29
|
+
- 🔌 **One line to plug in** — wrap your client, keep your code. Responses come back unchanged.
|
|
30
|
+
- 🌍 **Every major provider** — OpenAI, Anthropic, Google Gemini, xAI Grok, DeepSeek, Alibaba Qwen, Mistral, Cohere, Groq, OpenRouter, Together, Fireworks, Perplexity, Moonshot Kimi, Zhipu GLM, Azure OpenAI, AWS Bedrock, Ollama.
|
|
31
|
+
- 🔒 **Tokens, never text** — prompts and answers are never stored. One private SQLite file on your machine.
|
|
32
|
+
- 🛟 **It can't break your app** — metering errors are swallowed and logged, never raised.
|
|
33
|
+
- 📦 **Zero dependencies** — pure Python standard library, 3.10+.
|
|
34
|
+
|
|
35
|
+
## See it in 60 seconds
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install giuggiai-meter
|
|
39
|
+
giuggiai-meter demo # fake data, example prices — no API key needed
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
It writes `giuggiai-meter-demo.html`: a one-file report you can open, mail or share.
|
|
43
|
+
|
|
44
|
+
<p align="center">
|
|
45
|
+
<img src="https://raw.githubusercontent.com/giuggiai/giuggiai-meter/main/docs/assets/demo.png" alt="The demo report: total cost of the month, cost per project and per provider, reconciliation with the provider bills" width="100%">
|
|
46
|
+
</p>
|
|
47
|
+
|
|
48
|
+
## Use it in your code
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import giuggiai_meter as gm
|
|
52
|
+
from openai import OpenAI
|
|
53
|
+
from anthropic import Anthropic
|
|
54
|
+
|
|
55
|
+
client = gm.wrap(OpenAI(), project="support-bot") # that's it
|
|
56
|
+
client.chat.completions.create(model="gpt-4.1-mini", messages=[...])
|
|
57
|
+
|
|
58
|
+
claude = gm.wrap(Anthropic(), project="newsletter-bot")
|
|
59
|
+
claude.messages.create(model="claude-sonnet-4-5", max_tokens=500, messages=[...])
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Same one line for every OpenAI-compatible endpoint — the provider is recognised from the base URL:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
grok = gm.wrap(OpenAI(base_url="https://api.x.ai/v1", api_key=...), project="research") # xAI Grok
|
|
66
|
+
deepseek = gm.wrap(OpenAI(base_url="https://api.deepseek.com", api_key=...), project="batch") # DeepSeek
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Native SDKs work too: `gm.wrap(genai.Client(), project=...)` (Gemini), `Mistral()`, Cohere `ClientV2()`, Bedrock `converse`, `ollama`.
|
|
70
|
+
Already have a response object or raw JSON? `gm.log_response("my-project", response)` detects its shape by itself.
|
|
71
|
+
|
|
72
|
+
Then, at the end of the month:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
giuggiai-meter init # creates your price list from the example
|
|
76
|
+
giuggiai-meter report --month 2026-10 # table (or --csv / --html report.html)
|
|
77
|
+
giuggiai-meter reconcile --month 2026-10 --official OpenAI=41.20 Anthropic=118.05
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## How it works
|
|
81
|
+
|
|
82
|
+
<p align="center">
|
|
83
|
+
<img src="https://raw.githubusercontent.com/giuggiai/giuggiai-meter/main/docs/assets/how-it-works.png" alt="How it works: 1 wrap your client, 2 one row per call with tokens only, 3 report and reconcile with the provider bills" width="100%">
|
|
84
|
+
</p>
|
|
85
|
+
|
|
86
|
+
- **Per call:** project, provider, model, input / output / cached tokens, time. Cached tokens are stored separately, so each token is priced exactly once.
|
|
87
|
+
- **Prices:** one JSON file, USD per million tokens, each entry with an `effective_from` date. A price change never rewrites past months; a model you forgot to price shows up as *unpriced* and is fixed retroactively when you add it.
|
|
88
|
+
- **Across machines:** containers and other hosts can send rows to an optional HTTP ingest with a shared token.
|
|
89
|
+
|
|
90
|
+
## Compared to other tools
|
|
91
|
+
|
|
92
|
+
| | giuggiai-meter | Proxies / gateways (e.g. LiteLLM, Helicone) | Observability platforms (e.g. Langfuse) | Cost libraries (e.g. tokencost) |
|
|
93
|
+
|---|---|---|---|---|
|
|
94
|
+
| What you run | a Python library | a proxy in front of your calls | a platform (cloud or self-hosted) | a Python library |
|
|
95
|
+
| Cost per project | ✅ | ✅ | ✅ | estimate per call |
|
|
96
|
+
| Reconcile with the provider bill | ✅ built in | — | — | — |
|
|
97
|
+
| Stores prompts / answers | never | configurable | yes (it's the point) | no |
|
|
98
|
+
| Dependencies | none | several | several | some |
|
|
99
|
+
|
|
100
|
+
Use a gateway or an observability platform when you need routing, tracing or prompt analytics.
|
|
101
|
+
Use giuggiai-meter when you want **the money part, done right, with nothing to operate**. They also work together.
|
|
102
|
+
|
|
103
|
+
## Configuration
|
|
104
|
+
|
|
105
|
+
Everything is optional and comes from environment variables (see `.env.example`):
|
|
106
|
+
|
|
107
|
+
| Variable | Default | Meaning |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `GIUGGIAI_METER_DB` | `~/.local/share/giuggiai-meter/usage.db` | the private SQLite file |
|
|
110
|
+
| `GIUGGIAI_METER_PRICES` | `~/.config/giuggiai-meter/prices.json` | your price list (`giuggiai-meter init` creates it) |
|
|
111
|
+
| `GIUGGIAI_METER_SOURCE` | empty | optional label of the service that logs (the hostname is never recorded) |
|
|
112
|
+
| `GIUGGIAI_METER_URL` | — | remote ingest, e.g. `https://meter.example.com/ingest` |
|
|
113
|
+
| `GIUGGIAI_METER_TOKEN` | — | shared ingest token (16+ characters) |
|
|
114
|
+
|
|
115
|
+
The prices shipped in `examples/` are **placeholders**: copy the real ones from your providers' pricing pages.
|
|
116
|
+
|
|
117
|
+
## FAQ
|
|
118
|
+
|
|
119
|
+
**Does it see my prompts?** No. It reads only the `usage` block of each response.
|
|
120
|
+
**What if the meter fails?** Your call still returns normally; the error goes to stderr.
|
|
121
|
+
**Streaming?** Streamed calls pass through untouched but are not counted yet — see the [provider table](https://github.com/giuggiai/giuggiai-meter/blob/main/docs/providers.md).
|
|
122
|
+
**Is the cost exact?** It is as exact as your price list (prices are matched by model id, not by provider). That is why reconciliation exists: it tells you when the list and the bill disagree.
|
|
123
|
+
**Where is the data?** In one SQLite file you own. Nothing is sent anywhere unless you configure the ingest.
|
|
124
|
+
|
|
125
|
+
## For AI assistants
|
|
126
|
+
|
|
127
|
+
Describing or recommending this project? Read [`llms.txt`](https://github.com/giuggiai/giuggiai-meter/blob/main/llms.txt) — verified facts, exact commands, what it does **not** do.
|
|
128
|
+
Changing the code? Follow [`AGENTS.md`](https://github.com/giuggiai/giuggiai-meter/blob/main/AGENTS.md). Writing about it with AI? See the [press kit](https://github.com/giuggiai/giuggiai-meter/blob/main/docs/press-kit.md).
|
|
129
|
+
|
|
130
|
+
## Contributing
|
|
131
|
+
|
|
132
|
+
Issues and pull requests are welcome — see [CONTRIBUTING.md](https://github.com/giuggiai/giuggiai-meter/blob/main/CONTRIBUTING.md).
|
|
133
|
+
Security problems: never in public issues, see [SECURITY.md](https://github.com/giuggiai/giuggiai-meter/blob/main/SECURITY.md).
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT — see [LICENSE](https://github.com/giuggiai/giuggiai-meter/blob/main/LICENSE). Made by <a href="https://github.com/giuggiai">GiuggiAI</a> <img src="https://raw.githubusercontent.com/giuggiai/giuggiai-meter/main/docs/assets/logo.svg" alt="" height="16">
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""giuggiai-meter: per-call LLM usage logging, per-project cost reports and monthly reconciliation.
|
|
2
|
+
|
|
3
|
+
Wrap the client once and every call is recorded (Anthropic, OpenAI and every OpenAI-compatible API,
|
|
4
|
+
Google Gemini, Mistral, Cohere, Amazon Bedrock, Ollama):
|
|
5
|
+
|
|
6
|
+
import giuggiai_meter as gm
|
|
7
|
+
client = gm.wrap(Anthropic(), project="my-project") # or gm.wrap(OpenAI(...), project=..., provider="DeepSeek")
|
|
8
|
+
client.messages.create(...) # recorded, response unchanged
|
|
9
|
+
|
|
10
|
+
Or log one line after each model call; log_response recognises the response by its shape:
|
|
11
|
+
|
|
12
|
+
resp = client.messages.create(...)
|
|
13
|
+
gm.log_response("my-project", resp) # any supported SDK object or raw JSON dict
|
|
14
|
+
gm.log_anthropic("my-project", resp) # Anthropic Messages API
|
|
15
|
+
gm.log_openai("my-project", resp) # OpenAI-compatible APIs (OpenAI, DeepSeek, Perplexity, ...)
|
|
16
|
+
|
|
17
|
+
Design rules:
|
|
18
|
+
- the logger never raises: metering must never break the caller;
|
|
19
|
+
- it stores tokens and metadata only (no prompt or completion text) in a local SQLite file;
|
|
20
|
+
- prices live in one price list (a JSON file), applied when you build reports, not when you log.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import contextlib
|
|
25
|
+
import datetime
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import sqlite3
|
|
29
|
+
import sys
|
|
30
|
+
import threading
|
|
31
|
+
import time
|
|
32
|
+
import urllib.parse
|
|
33
|
+
import urllib.request
|
|
34
|
+
import uuid
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.0"
|
|
38
|
+
__all__ = ["connect", "db_path", "log_anthropic", "log_bedrock", "log_cohere", "log_deepseek", "log_gemini", "log_grok",
|
|
39
|
+
"log_mistral", "log_ollama", "log_openai", "log_perplexity", "log_qwen", "log_response", "log_usage", "wrap"]
|
|
40
|
+
|
|
41
|
+
_LOCK = threading.Lock()
|
|
42
|
+
|
|
43
|
+
_DDL = """
|
|
44
|
+
CREATE TABLE IF NOT EXISTS usage(
|
|
45
|
+
request_id TEXT PRIMARY KEY,
|
|
46
|
+
log_time TEXT NOT NULL,
|
|
47
|
+
slug TEXT NOT NULL,
|
|
48
|
+
provider TEXT,
|
|
49
|
+
model TEXT,
|
|
50
|
+
input_tokens INTEGER DEFAULT 0,
|
|
51
|
+
output_tokens INTEGER DEFAULT 0,
|
|
52
|
+
cache_creation_tokens INTEGER DEFAULT 0,
|
|
53
|
+
cache_read_tokens INTEGER DEFAULT 0,
|
|
54
|
+
workspace TEXT,
|
|
55
|
+
source TEXT,
|
|
56
|
+
synced INTEGER DEFAULT 0,
|
|
57
|
+
sync_error TEXT,
|
|
58
|
+
created_at TEXT NOT NULL
|
|
59
|
+
);
|
|
60
|
+
CREATE INDEX IF NOT EXISTS ix_usage_synced ON usage(synced);
|
|
61
|
+
CREATE INDEX IF NOT EXISTS ix_usage_time ON usage(log_time);
|
|
62
|
+
"""
|
|
63
|
+
FIELDS = ("request_id", "log_time", "slug", "provider", "model", "input_tokens", "output_tokens",
|
|
64
|
+
"cache_creation_tokens", "cache_read_tokens", "workspace", "source", "created_at")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def db_path() -> str:
|
|
68
|
+
"""SQLite file: $GIUGGIAI_METER_DB, otherwise ~/.local/share/giuggiai-meter/usage.db."""
|
|
69
|
+
return os.environ.get("GIUGGIAI_METER_DB") or str(Path.home() / ".local/share/giuggiai-meter/usage.db")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def connect(path: str | None = None) -> sqlite3.Connection:
|
|
73
|
+
"""Open (and create if needed) the usage database. The file is made readable by its owner only."""
|
|
74
|
+
p = path or db_path()
|
|
75
|
+
os.makedirs(os.path.dirname(os.path.abspath(p)), mode=0o700, exist_ok=True)
|
|
76
|
+
# create the database, -wal and -shm files as owner-only before SQLite opens them (no process-wide umask change)
|
|
77
|
+
for f in (p, p + "-wal", p + "-shm"):
|
|
78
|
+
try:
|
|
79
|
+
os.close(os.open(f, os.O_CREAT | os.O_WRONLY, 0o600))
|
|
80
|
+
except OSError:
|
|
81
|
+
pass
|
|
82
|
+
c = sqlite3.connect(p, timeout=10)
|
|
83
|
+
c.execute("PRAGMA journal_mode=WAL;")
|
|
84
|
+
c.executescript(_DDL)
|
|
85
|
+
for f in (p, p + "-wal", p + "-shm"):
|
|
86
|
+
try:
|
|
87
|
+
os.chmod(f, 0o600)
|
|
88
|
+
except OSError:
|
|
89
|
+
pass
|
|
90
|
+
return c
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _norm_provider(p):
|
|
94
|
+
from .providers import normalize
|
|
95
|
+
return normalize(p)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _now() -> str:
|
|
99
|
+
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _warn(msg: str) -> None:
|
|
103
|
+
"""Write a diagnostic line to stderr. Never raises (stderr may be None under pythonw or some daemons)."""
|
|
104
|
+
with contextlib.suppress(Exception):
|
|
105
|
+
sys.stderr.write(msg if msg.endswith("\n") else msg + "\n")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
109
|
+
"""Never follow redirects: the ingest token must not travel to another URL."""
|
|
110
|
+
|
|
111
|
+
def redirect_request(self, *args, **kwargs):
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
_OPENER = urllib.request.build_opener(_NoRedirect)
|
|
116
|
+
_HTTP_PAUSE_S = 60 # after a failure the ingest is skipped for this long (rows go to local storage)
|
|
117
|
+
_http_down_until = 0.0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _emit_http(row: dict) -> bool:
|
|
121
|
+
global _http_down_until
|
|
122
|
+
url, token = os.environ.get("GIUGGIAI_METER_URL"), os.environ.get("GIUGGIAI_METER_TOKEN")
|
|
123
|
+
if not (url and token):
|
|
124
|
+
return False
|
|
125
|
+
if not url.lower().startswith(("http://", "https://")):
|
|
126
|
+
_warn("[giuggiai_meter] GIUGGIAI_METER_URL must be http(s); falling back to local storage")
|
|
127
|
+
return False
|
|
128
|
+
if time.monotonic() < _http_down_until:
|
|
129
|
+
return False
|
|
130
|
+
host = (urllib.parse.urlsplit(url).hostname or "").lower()
|
|
131
|
+
if url.lower().startswith("http://") and host not in ("localhost", "127.0.0.1", "::1"):
|
|
132
|
+
_warn("[giuggiai_meter] warning: the ingest token is sent over plain http to a remote host; use https")
|
|
133
|
+
try:
|
|
134
|
+
req = urllib.request.Request(url, data=json.dumps(row).encode(), method="POST",
|
|
135
|
+
headers={"Content-Type": "application/json", "X-Meter-Token": token})
|
|
136
|
+
with _OPENER.open(req, timeout=2) as r:
|
|
137
|
+
r.read()
|
|
138
|
+
return True
|
|
139
|
+
except Exception as e: # noqa: BLE001 - never break the caller
|
|
140
|
+
_http_down_until = time.monotonic() + _HTTP_PAUSE_S
|
|
141
|
+
_warn(f"[giuggiai_meter] HTTP ingest failed ({type(e).__name__}); local storage for the next {_HTTP_PAUSE_S} s")
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def insert_row(row: dict, path: str | None = None) -> None:
|
|
146
|
+
"""Insert a usage row (idempotent on request_id)."""
|
|
147
|
+
with _LOCK, contextlib.closing(connect(path)) as c, c:
|
|
148
|
+
c.execute(f"INSERT OR IGNORE INTO usage({','.join(FIELDS)},synced) VALUES({','.join('?' * len(FIELDS))},0)",
|
|
149
|
+
[row.get(k) for k in FIELDS])
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def log_usage(slug, provider=None, model=None, input_tokens=0, output_tokens=0, cache_creation_tokens=0,
|
|
153
|
+
cache_read_tokens=0, request_id=None, workspace=None, source=None, log_time=None):
|
|
154
|
+
"""Record one model call. Idempotent on request_id. Never raises: returns the request_id or None."""
|
|
155
|
+
try:
|
|
156
|
+
row = {
|
|
157
|
+
"request_id": request_id or f"gm-{uuid.uuid4().hex}",
|
|
158
|
+
"log_time": log_time or _now(),
|
|
159
|
+
"slug": str(slug),
|
|
160
|
+
"provider": _norm_provider(provider),
|
|
161
|
+
"model": model,
|
|
162
|
+
"input_tokens": int(input_tokens or 0),
|
|
163
|
+
"output_tokens": int(output_tokens or 0),
|
|
164
|
+
"cache_creation_tokens": int(cache_creation_tokens or 0),
|
|
165
|
+
"cache_read_tokens": int(cache_read_tokens or 0),
|
|
166
|
+
"workspace": workspace,
|
|
167
|
+
"source": source or os.environ.get("GIUGGIAI_METER_SOURCE"), # no hostname by default (privacy)
|
|
168
|
+
"created_at": _now(),
|
|
169
|
+
}
|
|
170
|
+
if _emit_http(row): # remote ingest first when configured (containers, other hosts)
|
|
171
|
+
return row["request_id"]
|
|
172
|
+
insert_row(row)
|
|
173
|
+
return row["request_id"]
|
|
174
|
+
except Exception as e: # noqa: BLE001 - never break the caller
|
|
175
|
+
_warn(f"[giuggiai_meter] log_usage error: {e}\n")
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _g(obj, *names, default=0):
|
|
180
|
+
for n in names:
|
|
181
|
+
v = getattr(obj, n, None)
|
|
182
|
+
if v is None and isinstance(obj, dict):
|
|
183
|
+
v = obj.get(n)
|
|
184
|
+
if v is not None:
|
|
185
|
+
return v
|
|
186
|
+
return default
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _rid(response):
|
|
190
|
+
rid = getattr(response, "id", None) or (response.get("id") if isinstance(response, dict) else None)
|
|
191
|
+
return "gm-" + (str(rid) if rid else uuid.uuid4().hex)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def log_anthropic(slug, response, model=None, workspace=None):
|
|
195
|
+
"""Read `usage` from an Anthropic Messages response and record it. Never raises."""
|
|
196
|
+
try:
|
|
197
|
+
u = _g(response, "usage", default={}) or {}
|
|
198
|
+
return log_usage(slug, provider="Anthropic", model=model or _g(response, "model", default=None),
|
|
199
|
+
input_tokens=_g(u, "input_tokens"), output_tokens=_g(u, "output_tokens"),
|
|
200
|
+
cache_creation_tokens=_g(u, "cache_creation_input_tokens"),
|
|
201
|
+
cache_read_tokens=_g(u, "cache_read_input_tokens"), request_id=_rid(response),
|
|
202
|
+
workspace=workspace)
|
|
203
|
+
except Exception as e: # noqa: BLE001 - never break the caller
|
|
204
|
+
_warn(f"[giuggiai_meter] log_anthropic error: {e}\n")
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def log_openai(slug, response, provider="OpenAI", model=None, workspace=None):
|
|
209
|
+
"""Read `usage` from an OpenAI-compatible response (OpenAI, DeepSeek, Perplexity, ...) and record it. Never raises.
|
|
210
|
+
Works with Chat Completions (prompt/completion tokens) and the Responses API (input/output tokens)."""
|
|
211
|
+
try:
|
|
212
|
+
u = _g(response, "usage", default={}) or {}
|
|
213
|
+
details = _g(u, "prompt_tokens_details", "input_tokens_details", default={}) or {}
|
|
214
|
+
cached = _g(details, "cached_tokens", default=0) or _g(u, "prompt_cache_hit_tokens", default=0)
|
|
215
|
+
return log_usage(slug, provider=provider, model=model or _g(response, "model", default=None),
|
|
216
|
+
# convention: input_tokens = uncached input (OpenAI-style totals include the cached part)
|
|
217
|
+
input_tokens=max(0, int(_g(u, "prompt_tokens", "input_tokens") or 0) - int(cached or 0)),
|
|
218
|
+
output_tokens=_g(u, "completion_tokens", "output_tokens"),
|
|
219
|
+
cache_read_tokens=cached, request_id=_rid(response), workspace=workspace)
|
|
220
|
+
except Exception as e: # noqa: BLE001 - never break the caller
|
|
221
|
+
_warn(f"[giuggiai_meter] log_openai error: {e}\n")
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def log_deepseek(slug, response, model=None, workspace=None):
|
|
226
|
+
return log_openai(slug, response, provider="DeepSeek", model=model, workspace=workspace)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def log_perplexity(slug, response, model=None, workspace=None):
|
|
230
|
+
return log_openai(slug, response, provider="Perplexity", model=model, workspace=workspace)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def log_response(project, response, provider=None, model=None, workspace=None, fallback_model=None):
|
|
234
|
+
"""Record any supported LLM response, SDK object or raw JSON dict, recognised by its shape. Never raises.
|
|
235
|
+
|
|
236
|
+
Shapes: OpenAI Chat Completions and every OpenAI-compatible API (prompt/completion tokens), OpenAI Responses API,
|
|
237
|
+
Anthropic Messages, Google Gemini (usage_metadata, also camelCase JSON), Cohere (billed_units), Amazon Bedrock
|
|
238
|
+
Converse (inputTokens/outputTokens), Ollama (prompt_eval_count/eval_count), Alibaba DashScope native.
|
|
239
|
+
`provider`: who bills the call; when omitted it comes from the shape or the model id (see providers.py).
|
|
240
|
+
`model` overrides the model in the response; `fallback_model` is used only when the response names none.
|
|
241
|
+
Returns the request_id, or None when nothing was recorded (no usage in the response, or an error)."""
|
|
242
|
+
try:
|
|
243
|
+
from ._usage import extract, resolve_provider
|
|
244
|
+
|
|
245
|
+
u = extract(response)
|
|
246
|
+
if u is None:
|
|
247
|
+
_warn("[giuggiai_meter] log_response: no token usage found in the response, nothing recorded\n")
|
|
248
|
+
return None
|
|
249
|
+
mdl = model or u["model"] or fallback_model
|
|
250
|
+
rid = "gm-" + (str(u["id"]) if u["id"] else uuid.uuid4().hex)
|
|
251
|
+
return log_usage(project, provider=resolve_provider(provider, mdl, u["shape"]), model=mdl,
|
|
252
|
+
input_tokens=u["input_tokens"], output_tokens=u["output_tokens"],
|
|
253
|
+
cache_creation_tokens=u["cache_creation_tokens"], cache_read_tokens=u["cache_read_tokens"],
|
|
254
|
+
request_id=rid, workspace=workspace)
|
|
255
|
+
except Exception as e: # noqa: BLE001 - never break the caller
|
|
256
|
+
_warn(f"[giuggiai_meter] log_response error: {e}\n")
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def log_gemini(project, response, model=None, workspace=None):
|
|
261
|
+
"""Google Gemini (google-genai / google-generativeai objects, or REST JSON)."""
|
|
262
|
+
return log_response(project, response, provider="Google", model=model, workspace=workspace)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def log_grok(project, response, model=None, workspace=None):
|
|
266
|
+
"""xAI Grok (OpenAI-compatible API)."""
|
|
267
|
+
return log_response(project, response, provider="xAI", model=model, workspace=workspace)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def log_qwen(project, response, model=None, workspace=None):
|
|
271
|
+
"""Alibaba Qwen: DashScope native or OpenAI-compatible mode."""
|
|
272
|
+
return log_response(project, response, provider="Alibaba Qwen", model=model, workspace=workspace)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def log_mistral(project, response, model=None, workspace=None):
|
|
276
|
+
"""Mistral (mistralai SDK `chat.complete`, or OpenAI-compatible JSON)."""
|
|
277
|
+
return log_response(project, response, provider="Mistral", model=model, workspace=workspace)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def log_cohere(project, response, model=None, workspace=None):
|
|
281
|
+
"""Cohere chat (v1 meta.billed_units or v2 usage.billed_units)."""
|
|
282
|
+
return log_response(project, response, provider="Cohere", model=model, workspace=workspace)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def log_bedrock(project, response, model=None, workspace=None):
|
|
286
|
+
"""Amazon Bedrock Converse API (boto3 `converse`); pass model= (the Converse response does not name it)."""
|
|
287
|
+
return log_response(project, response, provider="AWS Bedrock", model=model, workspace=workspace)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def log_ollama(project, response, model=None, workspace=None):
|
|
291
|
+
"""Ollama native API (`chat` / `generate`)."""
|
|
292
|
+
return log_response(project, response, provider="Ollama", model=model, workspace=workspace)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
from ._wrap import wrap # imported last: it needs the log_* functions above
|