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.
@@ -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