paygent 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.
Files changed (49) hide show
  1. paygent-0.1.0/.gitignore +42 -0
  2. paygent-0.1.0/CONTRIBUTING.md +427 -0
  3. paygent-0.1.0/LICENSE +21 -0
  4. paygent-0.1.0/PKG-INFO +440 -0
  5. paygent-0.1.0/README.md +400 -0
  6. paygent-0.1.0/pyproject.toml +58 -0
  7. paygent-0.1.0/src/paygent/__init__.py +52 -0
  8. paygent-0.1.0/src/paygent/api_client.py +423 -0
  9. paygent-0.1.0/src/paygent/context.py +209 -0
  10. paygent-0.1.0/src/paygent/core.py +1834 -0
  11. paygent-0.1.0/src/paygent/event_queue.py +251 -0
  12. paygent-0.1.0/src/paygent/guardrails.py +396 -0
  13. paygent-0.1.0/src/paygent/instrumentor.py +149 -0
  14. paygent-0.1.0/src/paygent/integrations/__init__.py +29 -0
  15. paygent-0.1.0/src/paygent/integrations/crewai.py +192 -0
  16. paygent-0.1.0/src/paygent/integrations/langchain.py +292 -0
  17. paygent-0.1.0/src/paygent/local_cache.py +343 -0
  18. paygent-0.1.0/src/paygent/metering.py +369 -0
  19. paygent-0.1.0/src/paygent/models.py +333 -0
  20. paygent-0.1.0/src/paygent/patcher.py +812 -0
  21. paygent-0.1.0/src/paygent/providers.py +278 -0
  22. paygent-0.1.0/src/paygent/storage/__init__.py +0 -0
  23. paygent-0.1.0/src/paygent/storage/base.py +55 -0
  24. paygent-0.1.0/src/paygent/storage/sqlite.py +522 -0
  25. paygent-0.1.0/src/paygent/stream_wrapper.py +307 -0
  26. paygent-0.1.0/tests/__init__.py +0 -0
  27. paygent-0.1.0/tests/conftest.py +79 -0
  28. paygent-0.1.0/tests/test_aggressive.py +1555 -0
  29. paygent-0.1.0/tests/test_api_client.py +728 -0
  30. paygent-0.1.0/tests/test_callbacks.py +489 -0
  31. paygent-0.1.0/tests/test_context.py +257 -0
  32. paygent-0.1.0/tests/test_core.py +1423 -0
  33. paygent-0.1.0/tests/test_core_backend_wiring.py +357 -0
  34. paygent-0.1.0/tests/test_crewai_integration.py +373 -0
  35. paygent-0.1.0/tests/test_event_queue.py +341 -0
  36. paygent-0.1.0/tests/test_guardrails.py +818 -0
  37. paygent-0.1.0/tests/test_instrumentor.py +353 -0
  38. paygent-0.1.0/tests/test_langchain_integration.py +422 -0
  39. paygent-0.1.0/tests/test_local_cache.py +732 -0
  40. paygent-0.1.0/tests/test_metering.py +716 -0
  41. paygent-0.1.0/tests/test_models.py +257 -0
  42. paygent-0.1.0/tests/test_multi_call.py +844 -0
  43. paygent-0.1.0/tests/test_patcher.py +866 -0
  44. paygent-0.1.0/tests/test_providers.py +276 -0
  45. paygent-0.1.0/tests/test_refresh_merge.py +408 -0
  46. paygent-0.1.0/tests/test_reservations.py +602 -0
  47. paygent-0.1.0/tests/test_sqlite_storage.py +532 -0
  48. paygent-0.1.0/tests/test_stream_wrapper.py +326 -0
  49. paygent-0.1.0/tests/test_wrap.py +628 -0
@@ -0,0 +1,42 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.egg-info/
6
+ *.egg
7
+ dist/
8
+ build/
9
+
10
+ # Virtual environments
11
+ venv/
12
+ .venv/
13
+
14
+ # Environment variables
15
+ .env
16
+ .env.local
17
+ .env.test
18
+
19
+ # IDE
20
+ .vscode/
21
+ .idea/
22
+ *.sw?
23
+ *.suo
24
+ *.ntvs*
25
+ *.njsproj
26
+ *.sln
27
+
28
+ # OS
29
+ .DS_Store
30
+ Thumbs.db
31
+
32
+ # Testing
33
+ .pytest_cache/
34
+ .coverage
35
+ htmlcov/
36
+
37
+ # SQLite
38
+ *.sqlite
39
+ *.db
40
+
41
+ # Logs
42
+ *.log
@@ -0,0 +1,427 @@
1
+ # Contributing to Paygent
2
+
3
+ This doc is for people working on the Paygent SDK itself — setup, architecture,
4
+ running the full stack, tests, and publishing releases. For end-user docs see
5
+ the [README](./README.md).
6
+
7
+ ---
8
+
9
+ ## Table of contents
10
+
11
+ - [Repository layout](#repository-layout)
12
+ - [Dev environment setup](#dev-environment-setup)
13
+ - [Running the tests](#running-the-tests)
14
+ - [Running the backend locally](#running-the-backend-locally)
15
+ - [Architecture](#architecture)
16
+ - [Call path](#call-path)
17
+ - [Two-phase reservation (concurrency safety)](#two-phase-reservation-concurrency-safety)
18
+ - [Event queue and background sync](#event-queue-and-background-sync)
19
+ - [Local storage (SQLite)](#local-storage-sqlite)
20
+ - [Schema](#schema)
21
+ - [Debugging with SQLite](#debugging-with-sqlite)
22
+ - [Coding standards](#coding-standards)
23
+ - [Known gotchas](#known-gotchas)
24
+ - [Release process](#release-process)
25
+
26
+ ---
27
+
28
+ ## Repository layout
29
+
30
+ Paygent lives in one monorepo with three workspaces:
31
+
32
+ ```
33
+ code/
34
+ ├── sdk/ # The Python SDK (published to PyPI)
35
+ │ ├── src/paygent/ # SDK source
36
+ │ ├── tests/ # SDK unit tests
37
+ │ └── pyproject.toml
38
+ ├── backend/ # FastAPI backend (not published)
39
+ │ ├── src/paygent_api/
40
+ │ ├── tests/
41
+ │ ├── alembic/ # DB migrations
42
+ │ └── docker-compose.yml
43
+ └── tests/e2e/ # End-to-end tests that exercise SDK ↔ backend
44
+ ```
45
+
46
+ ## Dev environment setup
47
+
48
+ ### Prerequisites
49
+
50
+ - Python 3.10+
51
+ - Docker (for PostgreSQL when working on the backend or running e2e tests)
52
+ - `OPENAI_API_KEY` and `ANTHROPIC_API_KEY` if you want to run real-LLM e2e tests
53
+
54
+ ### Install
55
+
56
+ ```bash
57
+ git clone https://github.com/paygent/paygent.git
58
+ cd paygent
59
+
60
+ # SDK dev install
61
+ cd sdk
62
+ python3 -m venv venv
63
+ venv/bin/pip install -e '.[dev]'
64
+
65
+ # Backend dev install (separate venv; the two codebases are independent)
66
+ cd ../backend
67
+ python3 -m venv venv
68
+ venv/bin/pip install -e '.[dev]'
69
+ ```
70
+
71
+ ### Optional framework extras
72
+
73
+ Install these only if you need to run the LangChain/LangGraph/CrewAI test
74
+ paths locally:
75
+
76
+ ```bash
77
+ sdk/venv/bin/pip install langchain-openai langchain-anthropic langgraph crewai
78
+ ```
79
+
80
+ ## Running the tests
81
+
82
+ The project has three test suites. Each has its own runner.
83
+
84
+ ### SDK unit tests (no backend, no real LLM)
85
+
86
+ ```bash
87
+ cd sdk
88
+ venv/bin/python -m pytest tests/ -q
89
+ ```
90
+
91
+ Expect ~500 tests in under 10 seconds.
92
+
93
+ ### Backend unit tests (no SDK, no real LLM, needs PostgreSQL)
94
+
95
+ ```bash
96
+ cd backend
97
+ docker compose up -d # start PostgreSQL
98
+ venv/bin/python -m pytest tests/ -q
99
+ ```
100
+
101
+ ### End-to-end tests (full stack — backend + real LLM calls)
102
+
103
+ The e2e suite has its own runner that spins up a dedicated test backend on
104
+ port 8001 against the `paygent_test` database:
105
+
106
+ ```bash
107
+ ./tests/e2e/run_e2e.sh # everything
108
+ ./tests/e2e/run_e2e.sh tests/e2e/test_phase3_langchain.py
109
+ ./tests/e2e/run_e2e.sh -k "hard_gate"
110
+ ```
111
+
112
+ See [`tests/README.md`](../tests/README.md) for the full e2e reference —
113
+ phases, fixtures, markers, env vars.
114
+
115
+ ## Running the backend locally
116
+
117
+ If you're working on the SDK and want to point it at a local backend:
118
+
119
+ ```bash
120
+ # Start PostgreSQL
121
+ cd backend
122
+ docker compose up -d
123
+
124
+ # Run migrations
125
+ PYTHONPATH=src venv/bin/python -m alembic upgrade head
126
+
127
+ # Start the server on port 8000 (default)
128
+ PYTHONPATH=src venv/bin/uvicorn paygent_api.main:app --reload
129
+ ```
130
+
131
+ Health check: `curl http://localhost:8000/api/v1/health`
132
+
133
+ PgAdmin is available at [http://localhost:8080](http://localhost:8080) via
134
+ docker-compose.
135
+
136
+ ## Architecture
137
+
138
+ ### Call path
139
+
140
+ Every metered LLM call (via auto-instrumentation or `pg.wrap()`) goes
141
+ through the same four-step pipeline:
142
+
143
+ ```
144
+ User sends prompt
145
+ |
146
+ Paygent SDK intercepts LLM call
147
+ |
148
+ Guard check + reservation (under per-user lock, microseconds)
149
+ | |
150
+ | +--> hard_gate? raise PaygentLimitExceeded
151
+ | +--> soft_gate? fire callback, continue
152
+ | +--> ok? reserve estimated cost
153
+ |
154
+ Original LLM call executes (lock released, concurrent calls interleave)
155
+ |
156
+ Metering: extract tokens, finalize reservation (under lock, microseconds)
157
+ |
158
+ Event pushed to background queue (non-blocking)
159
+ |
160
+ Response returned IMMEDIATELY
161
+
162
+ [Background thread] Events batched and synced to the Paygent backend
163
+ ```
164
+
165
+ The per-user lock is held only for the two short windows around the LLM
166
+ call — never across the network I/O. Concurrent calls for the same user
167
+ run their HTTP round-trips in parallel.
168
+
169
+ ### Two-phase reservation (concurrency safety)
170
+
171
+ Without a reservation, two concurrent calls at the boundary of a cap can
172
+ both pass the guard (both see the same stale `period_cost`) and both
173
+ execute — doubling the overrun.
174
+
175
+ The reservation pattern fixes this:
176
+
177
+ 1. **Reserve** — after the guard passes, tentatively add the estimated
178
+ cost to a separate `reserved_cost` field. Subsequent concurrent guard
179
+ checks see `period_cost + reserved_cost` and can block.
180
+ 2. **Execute** — lock released, LLM call runs.
181
+ 3. **Finalize** — atomic swap under the lock: subtract the reservation,
182
+ add the actual cost from the response. Net recorded spend = actual.
183
+
184
+ The `reservation_safety_factor` (default 1.2) absorbs estimation drift.
185
+ A 20% multiplier on the reservation hold means 20% of bonus headroom is
186
+ held during the await — prevents small token-counting errors from
187
+ overshooting. Actual spend is never inflated.
188
+
189
+ Errors roll back the reservation:
190
+ - LLM call raises → `release_reservation` → re-raise
191
+ - Token extraction fails → `release_reservation` → swallow (fail-open)
192
+ - Event creation fails → `release_reservation` → swallow
193
+
194
+ ### Event queue and background sync
195
+
196
+ A single `threading.Thread` (daemon) runs the flush loop:
197
+
198
+ - Every `flush_interval` seconds (default 5s), drain the in-memory queue
199
+ and push to the backend in batches.
200
+ - Every `sync_pending_interval` (default 30s), retry previously-unsynced
201
+ events stored in SQLite.
202
+ - Every `refresh_interval` (default 60s), fetch fresh user state from
203
+ the backend for every cached user.
204
+
205
+ If the backend is unreachable, events stay in SQLite marked `synced=0`.
206
+ They retry on the next `sync_pending` cycle when the backend returns.
207
+
208
+ ## Local storage (SQLite)
209
+
210
+ Paygent maintains a local SQLite database at `~/.paygent/local.db` by
211
+ default (configurable via `db_path`). This database serves two purposes:
212
+
213
+ 1. **Offline fallback** — events are written locally before the backend
214
+ sync attempt. If the backend is down, they queue here and sync on
215
+ reconnect.
216
+ 2. **Local-only mode** — when no API key is provided, this is the sole
217
+ storage for usage data.
218
+
219
+ ### Schema
220
+
221
+ ```sql
222
+ -- Every metered event
223
+ CREATE TABLE usage_events (
224
+ id TEXT PRIMARY KEY, -- UUID (idempotency key)
225
+ user_id TEXT NOT NULL,
226
+ session_id TEXT, -- Optional
227
+ timestamp TEXT NOT NULL, -- ISO 8601
228
+ model TEXT, -- e.g. "gpt-4o"
229
+ input_tokens INTEGER DEFAULT 0,
230
+ output_tokens INTEGER DEFAULT 0,
231
+ total_tokens INTEGER DEFAULT 0,
232
+ tool_calls TEXT DEFAULT '[]', -- JSON array
233
+ cost_tokens REAL DEFAULT 0,
234
+ cost_tools REAL DEFAULT 0,
235
+ cost_total REAL DEFAULT 0,
236
+ metadata TEXT DEFAULT '{}', -- JSON blob
237
+ synced INTEGER DEFAULT 0, -- 0 = pending, 1 = synced
238
+ created_at TEXT DEFAULT (datetime('now'))
239
+ );
240
+
241
+ CREATE INDEX idx_events_user ON usage_events(user_id, timestamp);
242
+ CREATE INDEX idx_events_synced ON usage_events(synced);
243
+
244
+ -- Per-user cached state for offline recovery
245
+ CREATE TABLE user_snapshots (
246
+ user_id TEXT PRIMARY KEY,
247
+ plan TEXT NOT NULL,
248
+ plan_config TEXT NOT NULL, -- JSON (PlanConfig)
249
+ current_usage TEXT NOT NULL, -- JSON (CurrentUsage — Pydantic model_dump_json)
250
+ billing_period_start TEXT, -- ISO 8601 or NULL
251
+ billing_period_end TEXT,
252
+ snapshot_at TEXT DEFAULT (datetime('now'))
253
+ );
254
+ ```
255
+
256
+ The `current_usage` JSON carries `period_cost`, `session_cost`, per-model
257
+ breakdowns, and the in-flight `reserved_cost` / `reserved_tokens_by_model`
258
+ fields. `plan_config` uses the sentinel `"__INF__"` for `float('inf')`
259
+ limits (stdlib JSON can't encode infinity).
260
+
261
+ ### Debugging with SQLite
262
+
263
+ Inspect the database directly for debugging:
264
+
265
+ ```bash
266
+ sqlite3 ~/.paygent/local.db
267
+
268
+ -- Total events + sync status
269
+ SELECT synced, COUNT(*) FROM usage_events GROUP BY synced;
270
+
271
+ -- Recent events for a user
272
+ SELECT id, model, total_tokens, cost_total, synced, created_at
273
+ FROM usage_events WHERE user_id = 'user_123'
274
+ ORDER BY timestamp DESC LIMIT 10;
275
+
276
+ -- Cost breakdown by model
277
+ SELECT model, SUM(total_tokens) AS tokens, ROUND(SUM(cost_total), 4) AS cost
278
+ FROM usage_events WHERE user_id = 'user_123' GROUP BY model;
279
+
280
+ -- Stuck unsynced events
281
+ SELECT COUNT(*) FROM usage_events WHERE synced = 0;
282
+
283
+ -- Snapshot for a user
284
+ SELECT user_id, plan, current_usage FROM user_snapshots WHERE user_id = 'user_123';
285
+ ```
286
+
287
+ ## Coding standards
288
+
289
+ - **Python 3.10+.** Use the newer union syntax (`str | None`, not
290
+ `Optional[str]`) everywhere.
291
+ - **Pydantic v2** for all data models. Use `PrivateAttr` for fields
292
+ that must not serialize (e.g. locks).
293
+ - **Logging**: use `logging.getLogger("paygent")` for SDK code,
294
+ `"paygent_api"` for backend code. No `print` statements.
295
+ - **Fail-open on every path that intercepts an LLM call.** Every try
296
+ block in `patcher.py`, `wrap()`, and `awrap()` should either return
297
+ the original response or re-raise `PaygentLimitExceeded` — never
298
+ propagate internal errors into the dev's call stack.
299
+ - **Every test verifies both the happy path AND the fail-open path**
300
+ when applicable.
301
+ - **UUIDs for event IDs** (`uuid4()`). These are idempotency keys on
302
+ the backend — duplicates are silently ignored.
303
+ - **Docstrings on every public method.**
304
+ - **pytest + pytest-asyncio** for testing. Mock external services in
305
+ unit tests; no real API calls below the e2e tier.
306
+
307
+ ## Known gotchas
308
+
309
+ ### Shutdown hang on some test files
310
+
311
+ A few SDK test files (`test_wrap.py`, `test_callbacks.py`,
312
+ `test_multi_call.py`, `test_sqlite_storage.py`) run to completion but
313
+ pytest hangs on interpreter shutdown, waiting for a background daemon
314
+ thread to join. If you see "N passed" and then the process stalls, the
315
+ tests passed — just `Ctrl+C`. Pre-existing issue; tracked as tech debt.
316
+
317
+ ### Multi-process drift
318
+
319
+ The in-memory cache is per-process. Multi-worker deployments (Gunicorn
320
+ with `workers > 1`, multi-replica Kubernetes) drift between refreshes.
321
+ See the [README "Known Limitations"](./README.md#known-limitations)
322
+ section for the math and mitigations.
323
+
324
+ ### `datetime.utcnow()` deprecation
325
+
326
+ The codebase uses `datetime.utcnow()` in several places. Python 3.12+
327
+ deprecates this in favor of `datetime.now(timezone.utc)`. Currently
328
+ fine on Python 3.10/3.11. Revisit when we bump the minimum supported
329
+ Python version.
330
+
331
+ ### Thread safety of `_sessions` dict
332
+
333
+ The cache dict is protected from concurrent iteration via snapshotting
334
+ (`list(self._sessions.items())`). Per-user state is now protected by
335
+ the per-user lock introduced with the reservation pattern. The
336
+ remaining gap is pure dict mutation concurrency — single-op mutations
337
+ are safe under the GIL, but compound check-then-set patterns would need
338
+ `_user_locks_lock` (used only for lazy lock creation). Not a production
339
+ risk today.
340
+
341
+ ## Release process
342
+
343
+ Paygent is published to PyPI as the `paygent` package.
344
+
345
+ ### Before release
346
+
347
+ 1. Run the full test suite and make sure it's green:
348
+ ```bash
349
+ cd sdk && venv/bin/python -m pytest tests/ -q
350
+ cd ../backend && venv/bin/python -m pytest tests/ -q
351
+ ./tests/e2e/run_e2e.sh
352
+ ```
353
+ 2. Bump `version` in `sdk/pyproject.toml` (semantic versioning).
354
+ 3. Update any version references in docs.
355
+ 4. Commit + tag: `git tag v<version> && git push --tags`.
356
+
357
+ ### Publishing to TestPyPI first (recommended)
358
+
359
+ TestPyPI is a sandbox with the same API as PyPI. Always verify the
360
+ package installs and imports correctly before pushing to real PyPI —
361
+ once a version is published to real PyPI it cannot be re-uploaded
362
+ (even after yanking).
363
+
364
+ 1. Create accounts on [TestPyPI](https://test.pypi.org) and [PyPI](https://pypi.org).
365
+ 2. Generate API tokens on each, stored in `~/.pypirc`:
366
+ ```ini
367
+ [distutils]
368
+ index-servers = pypi testpypi
369
+
370
+ [pypi]
371
+ username = __token__
372
+ password = pypi-...
373
+
374
+ [testpypi]
375
+ repository = https://test.pypi.org/legacy/
376
+ username = __token__
377
+ password = pypi-...
378
+ ```
379
+ 3. Install build tools:
380
+ ```bash
381
+ cd sdk
382
+ venv/bin/pip install build twine
383
+ ```
384
+ 4. Build:
385
+ ```bash
386
+ rm -rf dist/
387
+ venv/bin/python -m build
388
+ ```
389
+ 5. Verify the artifacts:
390
+ ```bash
391
+ venv/bin/twine check dist/*
392
+ unzip -l dist/paygent-*.whl # spot-check the wheel contents
393
+ ```
394
+ 6. Upload to TestPyPI:
395
+ ```bash
396
+ venv/bin/twine upload --repository testpypi dist/*
397
+ ```
398
+ 7. Test-install into a clean venv:
399
+ ```bash
400
+ python -m venv /tmp/test-paygent
401
+ /tmp/test-paygent/bin/pip install \
402
+ --index-url https://test.pypi.org/simple/ \
403
+ --extra-index-url https://pypi.org/simple/ \
404
+ paygent
405
+ /tmp/test-paygent/bin/python -c "
406
+ from paygent import Paygent, paygent_context, PlanConfig
407
+ pg = Paygent.init()
408
+ print('OK:', pg.is_initialized)
409
+ pg.shutdown()
410
+ "
411
+ ```
412
+
413
+ ### Publishing to PyPI
414
+
415
+ Once the TestPyPI smoke passes:
416
+
417
+ ```bash
418
+ cd sdk
419
+ venv/bin/twine upload dist/*
420
+ ```
421
+
422
+ ### Post-publish
423
+
424
+ - Visit `https://pypi.org/project/paygent/` — verify the README renders
425
+ correctly.
426
+ - `pip install paygent` into a fresh venv and run a quick smoke.
427
+ - Announce the release (changelog, blog, etc.).
paygent-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Paygent
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.