codex-lb 0.1.5__tar.gz → 0.3.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 (153) hide show
  1. codex_lb-0.3.0/.github/release-please-manifest.json +3 -0
  2. {codex_lb-0.1.5 → codex_lb-0.3.0}/.gitignore +2 -1
  3. codex_lb-0.3.0/.pre-commit-config.yaml +14 -0
  4. {codex_lb-0.1.5 → codex_lb-0.3.0}/AGENTS.md +17 -0
  5. codex_lb-0.3.0/CHANGELOG.md +101 -0
  6. {codex_lb-0.1.5 → codex_lb-0.3.0}/PKG-INFO +2 -2
  7. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/__init__.py +1 -1
  8. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/auth/__init__.py +12 -1
  9. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/balancer/logic.py +44 -7
  10. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/clients/proxy.py +2 -4
  11. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/config/settings.py +4 -1
  12. codex_lb-0.3.0/app/core/plan_types.py +64 -0
  13. codex_lb-0.3.0/app/core/types.py +6 -0
  14. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/usage/__init__.py +5 -2
  15. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/usage/logs.py +12 -2
  16. codex_lb-0.3.0/app/core/usage/quota.py +64 -0
  17. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/usage/types.py +3 -2
  18. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/utils/sse.py +6 -2
  19. codex_lb-0.3.0/app/db/migrations/__init__.py +91 -0
  20. codex_lb-0.3.0/app/db/migrations/versions/__init__.py +1 -0
  21. codex_lb-0.3.0/app/db/migrations/versions/add_accounts_chatgpt_account_id.py +29 -0
  22. codex_lb-0.3.0/app/db/migrations/versions/add_accounts_reset_at.py +29 -0
  23. codex_lb-0.3.0/app/db/migrations/versions/add_dashboard_settings.py +31 -0
  24. codex_lb-0.3.0/app/db/migrations/versions/add_request_logs_reasoning_effort.py +21 -0
  25. codex_lb-0.3.0/app/db/migrations/versions/normalize_account_plan_types.py +17 -0
  26. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/db/models.py +33 -0
  27. codex_lb-0.3.0/app/db/session.py +136 -0
  28. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/dependencies.py +27 -9
  29. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/main.py +15 -6
  30. codex_lb-0.3.0/app/modules/accounts/auth_manager.py +121 -0
  31. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/accounts/repository.py +14 -6
  32. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/accounts/service.py +14 -9
  33. codex_lb-0.3.0/app/modules/health/api.py +12 -0
  34. codex_lb-0.3.0/app/modules/health/schemas.py +9 -0
  35. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/oauth/service.py +9 -4
  36. codex_lb-0.3.0/app/modules/proxy/helpers.py +285 -0
  37. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/proxy/load_balancer.py +86 -41
  38. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/proxy/service.py +172 -318
  39. codex_lb-0.3.0/app/modules/proxy/sticky_repository.py +56 -0
  40. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/request_logs/repository.py +6 -3
  41. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/request_logs/schemas.py +2 -0
  42. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/request_logs/service.py +12 -3
  43. codex_lb-0.3.0/app/modules/settings/__init__.py +1 -0
  44. codex_lb-0.3.0/app/modules/settings/api.py +37 -0
  45. codex_lb-0.3.0/app/modules/settings/repository.py +40 -0
  46. codex_lb-0.3.0/app/modules/settings/schemas.py +13 -0
  47. codex_lb-0.3.0/app/modules/settings/service.py +33 -0
  48. codex_lb-0.3.0/app/modules/shared/schemas.py +22 -0
  49. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/usage/schemas.py +1 -0
  50. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/usage/service.py +23 -6
  51. codex_lb-0.1.5/app/modules/proxy/usage_updater.py → codex_lb-0.3.0/app/modules/usage/updater.py +37 -8
  52. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/static/7.css +73 -0
  53. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/static/index.css +33 -4
  54. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/static/index.html +51 -4
  55. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/static/index.js +254 -32
  56. {codex_lb-0.1.5 → codex_lb-0.3.0}/docker-compose.yml +2 -0
  57. {codex_lb-0.1.5 → codex_lb-0.3.0}/pyproject.toml +7 -3
  58. {codex_lb-0.1.5 → codex_lb-0.3.0}/tests/conftest.py +6 -2
  59. {codex_lb-0.1.5 → codex_lb-0.3.0}/tests/integration/test_accounts_api.py +18 -10
  60. {codex_lb-0.1.5 → codex_lb-0.3.0}/tests/integration/test_accounts_api_extended.py +7 -4
  61. {codex_lb-0.1.5 → codex_lb-0.3.0}/tests/integration/test_db_models.py +1 -1
  62. codex_lb-0.3.0/tests/integration/test_migrations.py +56 -0
  63. {codex_lb-0.1.5 → codex_lb-0.3.0}/tests/integration/test_oauth_flow.py +8 -3
  64. {codex_lb-0.1.5 → codex_lb-0.3.0}/tests/integration/test_proxy_api_extended.py +17 -13
  65. {codex_lb-0.1.5 → codex_lb-0.3.0}/tests/integration/test_proxy_compact.py +13 -5
  66. {codex_lb-0.1.5 → codex_lb-0.3.0}/tests/integration/test_proxy_responses.py +9 -3
  67. codex_lb-0.3.0/tests/integration/test_proxy_sticky_sessions.py +298 -0
  68. {codex_lb-0.1.5 → codex_lb-0.3.0}/tests/integration/test_repositories.py +24 -0
  69. {codex_lb-0.1.5 → codex_lb-0.3.0}/tests/integration/test_request_logs_api.py +1 -0
  70. {codex_lb-0.1.5 → codex_lb-0.3.0}/tests/integration/test_request_logs_filters.py +3 -0
  71. codex_lb-0.3.0/tests/integration/test_settings_api.py +32 -0
  72. {codex_lb-0.1.5 → codex_lb-0.3.0}/tests/integration/test_usage_api.py +27 -0
  73. {codex_lb-0.1.5 → codex_lb-0.3.0}/tests/unit/test_auth.py +3 -0
  74. codex_lb-0.3.0/tests/unit/test_auth_manager.py +86 -0
  75. codex_lb-0.3.0/tests/unit/test_load_balancer.py +305 -0
  76. {codex_lb-0.1.5 → codex_lb-0.3.0}/tests/unit/test_usage_client.py +21 -12
  77. codex_lb-0.3.0/tests/unit/test_usage_updater.py +124 -0
  78. {codex_lb-0.1.5 → codex_lb-0.3.0}/uv.lock +165 -122
  79. codex_lb-0.1.5/.github/release-please-manifest.json +0 -3
  80. codex_lb-0.1.5/.pre-commit-config.yaml +0 -7
  81. codex_lb-0.1.5/CHANGELOG.md +0 -51
  82. codex_lb-0.1.5/app/core/types.py +0 -4
  83. codex_lb-0.1.5/app/db/session.py +0 -62
  84. codex_lb-0.1.5/app/modules/health/api.py +0 -10
  85. codex_lb-0.1.5/app/modules/proxy/auth_manager.py +0 -51
  86. codex_lb-0.1.5/app/modules/shared/schemas.py +0 -8
  87. codex_lb-0.1.5/tests/unit/test_load_balancer.py +0 -71
  88. {codex_lb-0.1.5 → codex_lb-0.3.0}/.dockerignore +0 -0
  89. {codex_lb-0.1.5 → codex_lb-0.3.0}/.env.example +0 -0
  90. {codex_lb-0.1.5 → codex_lb-0.3.0}/.github/release-please-config.json +0 -0
  91. {codex_lb-0.1.5 → codex_lb-0.3.0}/.github/workflows/ci.yml +0 -0
  92. {codex_lb-0.1.5 → codex_lb-0.3.0}/.github/workflows/release-please.yml +0 -0
  93. {codex_lb-0.1.5 → codex_lb-0.3.0}/.github/workflows/release.yml +0 -0
  94. {codex_lb-0.1.5 → codex_lb-0.3.0}/Dockerfile +0 -0
  95. {codex_lb-0.1.5 → codex_lb-0.3.0}/LICENSE +0 -0
  96. {codex_lb-0.1.5 → codex_lb-0.3.0}/README.md +0 -0
  97. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/cli.py +0 -0
  98. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/__init__.py +0 -0
  99. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/auth/models.py +0 -0
  100. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/auth/refresh.py +0 -0
  101. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/balancer/__init__.py +0 -0
  102. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/balancer/types.py +0 -0
  103. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/clients/__init__.py +0 -0
  104. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/clients/http.py +0 -0
  105. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/clients/oauth.py +0 -0
  106. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/clients/usage.py +0 -0
  107. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/config/__init__.py +0 -0
  108. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/crypto.py +0 -0
  109. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/errors.py +0 -0
  110. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/openai/__init__.py +0 -0
  111. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/openai/models.py +0 -0
  112. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/openai/parsing.py +0 -0
  113. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/openai/requests.py +0 -0
  114. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/usage/models.py +0 -0
  115. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/usage/pricing.py +0 -0
  116. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/utils/__init__.py +0 -0
  117. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/utils/request_id.py +0 -0
  118. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/utils/retry.py +0 -0
  119. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/core/utils/time.py +0 -0
  120. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/db/__init__.py +0 -0
  121. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/__init__.py +0 -0
  122. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/accounts/__init__.py +0 -0
  123. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/accounts/api.py +0 -0
  124. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/accounts/schemas.py +0 -0
  125. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/health/__init__.py +0 -0
  126. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/oauth/__init__.py +0 -0
  127. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/oauth/api.py +0 -0
  128. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/oauth/schemas.py +0 -0
  129. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/oauth/templates/oauth_success.html +0 -0
  130. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/proxy/__init__.py +0 -0
  131. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/proxy/api.py +0 -0
  132. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/proxy/schemas.py +0 -0
  133. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/proxy/types.py +0 -0
  134. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/request_logs/__init__.py +0 -0
  135. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/request_logs/api.py +0 -0
  136. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/shared/__init__.py +0 -0
  137. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/usage/__init__.py +0 -0
  138. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/usage/api.py +0 -0
  139. {codex_lb-0.1.5 → codex_lb-0.3.0}/app/modules/usage/repository.py +0 -0
  140. {codex_lb-0.1.5 → codex_lb-0.3.0}/docs/screenshots/accounts.jpeg +0 -0
  141. {codex_lb-0.1.5 → codex_lb-0.3.0}/docs/screenshots/dashboard.jpeg +0 -0
  142. {codex_lb-0.1.5 → codex_lb-0.3.0}/tests/__init__.py +0 -0
  143. {codex_lb-0.1.5 → codex_lb-0.3.0}/tests/integration/test_codex_usage_api.py +0 -0
  144. {codex_lb-0.1.5 → codex_lb-0.3.0}/tests/integration/test_health_and_errors.py +0 -0
  145. {codex_lb-0.1.5 → codex_lb-0.3.0}/tests/integration/test_load_balancer_integration.py +0 -0
  146. {codex_lb-0.1.5 → codex_lb-0.3.0}/tests/integration/test_usage_summary.py +0 -0
  147. {codex_lb-0.1.5 → codex_lb-0.3.0}/tests/unit/test_auth_refresh.py +0 -0
  148. {codex_lb-0.1.5 → codex_lb-0.3.0}/tests/unit/test_oauth_client.py +0 -0
  149. {codex_lb-0.1.5 → codex_lb-0.3.0}/tests/unit/test_pricing.py +0 -0
  150. {codex_lb-0.1.5 → codex_lb-0.3.0}/tests/unit/test_proxy_utils.py +0 -0
  151. {codex_lb-0.1.5 → codex_lb-0.3.0}/tests/unit/test_retry.py +0 -0
  152. {codex_lb-0.1.5 → codex_lb-0.3.0}/tests/unit/test_sse.py +0 -0
  153. {codex_lb-0.1.5 → codex_lb-0.3.0}/tests/unit/test_usage.py +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.3.0"
3
+ }
@@ -34,4 +34,5 @@ node_modules/
34
34
  # Local
35
35
  .local/
36
36
 
37
- app/static/components
37
+ app/static/components
38
+ .codex-lb/
@@ -0,0 +1,14 @@
1
+ repos:
2
+ - repo: https://github.com/astral-sh/ruff-pre-commit
3
+ rev: v0.14.10
4
+ hooks:
5
+ - id: ruff
6
+ args: ["--fix"]
7
+ - id: ruff-format
8
+ - repo: local
9
+ hooks:
10
+ - id: ty
11
+ name: ty
12
+ entry: .venv/bin/python -m ty check
13
+ language: system
14
+ pass_filenames: false
@@ -33,3 +33,20 @@
33
33
  - Services should be instantiated inside context providers and receive repositories via constructor injection.
34
34
  - Background tasks or standalone scripts must create and manage their own session; do not reuse request contexts.
35
35
  - When adding a new module, define `api.py` endpoints that depend on a module-specific context provider.
36
+
37
+ ## Git Workflow & Contribution
38
+ 1. **Important**: Create branches, commits, or PRs **only upon explicit user request**. Implicit actions are not allowed.
39
+ 2. **Branch Naming**: Use prefixes like `feature/`, `fix/`, `chore/` (e.g., `feature/add-login`).
40
+ 3. **Commit Messages**: Follow [Conventional Commits](https://www.conventionalcommits.org/).
41
+ - Format: `<type>(<scope>): <description>`
42
+ - Types: `feat`, `fix`, `docs`, `refactor`, `chore`, `test`
43
+ - Example: `feat(api): add auth endpoint`
44
+ 4. **Workflow**:
45
+ ```bash
46
+ git checkout -b feature/add-login
47
+ git commit -m "feat(api): add auth endpoint"
48
+ # Only on explicit request:
49
+ git push -u origin feature/add-login
50
+ gh pr create --title "feat(api): add auth" --body "..."
51
+ ```
52
+ 5. **Best Practices**: Commit often in small units. Do not commit directly to `main`. Always check `git diff` before pushing.
@@ -0,0 +1,101 @@
1
+ # Changelog
2
+
3
+ ## [0.3.0](https://github.com/Soju06/codex-lb/compare/v0.2.0...v0.3.0) (2026-01-21)
4
+
5
+
6
+ ### Features
7
+
8
+ * add cached input tokens handling and update related metrics in … ([5bf6609](https://github.com/Soju06/codex-lb/commit/5bf66095b8000ffc8fbdf8d989f60171604f69d3))
9
+ * add cached input tokens handling and update related metrics in logs and usage schemas ([c965036](https://github.com/Soju06/codex-lb/commit/c9650367c1a2d14e63e3440788b7cd44b08ebd9a))
10
+ * add formatting for cached input tokens metadata in metrics display ([53feaa6](https://github.com/Soju06/codex-lb/commit/53feaa62f7c5c282508f37c3fd42d9af655c2fa9))
11
+ * add secondary usage tracking and selection logic for accounts in load balancer ([d66cf69](https://github.com/Soju06/codex-lb/commit/d66cf69b2834b42fefbbfa646d82477f9832fdda))
12
+ * add ty type checking and refactors ([41fa811](https://github.com/Soju06/codex-lb/commit/41fa8112ba9b900ffa5dbee3a39d94267e2caa75))
13
+ * **app:** add migrations and reasoning effort support ([9eae590](https://github.com/Soju06/codex-lb/commit/9eae5903a08363291e397f983a531ddf325658d7))
14
+ * implement dashboard settings for sticky threads and reset preferences ([cd04812](https://github.com/Soju06/codex-lb/commit/cd0481247f0ceffdd92173ea84773960e52a7253))
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * **app:** tune sqlite pragmas and usage UI ([a44a4fd](https://github.com/Soju06/codex-lb/commit/a44a4fd6fe5771282a12ee62a34c9be819254322))
20
+ * **app:** update effort display format in history ([0796740](https://github.com/Soju06/codex-lb/commit/0796740ab570cf476b2285a615559a9a6318082f))
21
+ * **app:** update effort display format to include parentheses ([6fbae96](https://github.com/Soju06/codex-lb/commit/6fbae960f393ff92cae0feb614ca0e811a855851))
22
+ * **dashboard:** fallback primary remaining to summary ([02b3d39](https://github.com/Soju06/codex-lb/commit/02b3d39c2b734271af7c420fc52b7e87350177e1))
23
+ * **db:** avoid leaked async connection in migration ([9aa1d03](https://github.com/Soju06/codex-lb/commit/9aa1d0395481a96a21db2d0add18ee1753f183b2))
24
+ * **db:** use returning for dml checks ([4ec7c7a](https://github.com/Soju06/codex-lb/commit/4ec7c7a6615e6e5852b0865e09184544f09ebedc))
25
+ * **ui:** style and label settings checkboxes ([722cad8](https://github.com/Soju06/codex-lb/commit/722cad851706e2784815dad4069902cc95b3f662))
26
+
27
+
28
+ ### Documentation
29
+
30
+ * expand 0.2.0 changelog ([32148dc](https://github.com/Soju06/codex-lb/commit/32148dc2d195cec0dd85f61fc0a13d8cbef24e24))
31
+
32
+ ## [0.2.0](https://github.com/Soju06/codex-lb/compare/v0.1.5...v0.2.0) (2026-01-19)
33
+
34
+
35
+ ### Features
36
+
37
+ * add ty type checking and pre-commit hook
38
+ * add health response schema and typed context cleanup
39
+
40
+
41
+ ### Bug Fixes
42
+
43
+ * normalize stored plan types (pro/team/business/enterprise/edu) so accounts no longer show as unknown
44
+ * prevent rate-limit status when usage is below 100% by using cooldown/backoff and primary-window quota checks
45
+ * surface per-account quota reset times by applying primary/secondary reset windows with fallbacks
46
+
47
+
48
+ ### Refactor
49
+
50
+ * move auth/usage helpers into module boundaries and extract proxy helpers
51
+ * tighten typing across services and tests
52
+
53
+ ## [0.1.5](https://github.com/Soju06/codex-lb/compare/v0.1.4...v0.1.5) (2026-01-14)
54
+
55
+
56
+ ### Bug Fixes
57
+
58
+ * align rate-limit backoff and reset handling ([4d59650](https://github.com/Soju06/codex-lb/commit/4d596508e5ad13e68aa6e64f9cb32324bd38f07b))
59
+
60
+ ## [0.1.4](https://github.com/Soju06/codex-lb/compare/v0.1.3...v0.1.4) (2026-01-13)
61
+
62
+
63
+ ### Bug Fixes
64
+
65
+ * **db:** harden session cleanup on cancellation ([dee3916](https://github.com/Soju06/codex-lb/commit/dee3916efa83dedec1d5ad43e1e14950b8c6e4a7))
66
+
67
+ ## [0.1.3](https://github.com/Soju06/codex-lb/compare/v0.1.2...v0.1.3) (2026-01-12)
68
+
69
+
70
+ ### Documentation
71
+
72
+ * use absolute image URLs for PyPI ([5fa65a5](https://github.com/Soju06/codex-lb/commit/5fa65a572980f356738f49be3adf2c62fdc38466))
73
+
74
+ ## [0.1.2](https://github.com/Soju06/codex-lb/compare/v0.1.1...v0.1.2) (2026-01-12)
75
+
76
+
77
+ ### Bug Fixes
78
+
79
+ * sync package __version__ ([3dd97e6](https://github.com/Soju06/codex-lb/commit/3dd97e6397a8ea9d3528c166d1e729936f98f737))
80
+
81
+ ## [0.1.1](https://github.com/Soju06/codex-lb/compare/v0.1.0...v0.1.1) (2026-01-12)
82
+
83
+
84
+ ### Bug Fixes
85
+
86
+ * address lint warnings ([7c3cc06](https://github.com/Soju06/codex-lb/commit/7c3cc06c9a6a9a9a8895c1dd5fcc57b3c0eebdb3))
87
+ * reactivate accounts when secondary quota clears ([58a4263](https://github.com/Soju06/codex-lb/commit/58a42630d644559f96f045a96c25d0126810542e))
88
+ * skip project install in docker build ([64e9156](https://github.com/Soju06/codex-lb/commit/64e9156075c256ef48c0587ea1abb7cc092b97a5))
89
+
90
+
91
+ ### Documentation
92
+
93
+ * add dashboard hero and accounts view ([3522654](https://github.com/Soju06/codex-lb/commit/3522654fe5d09adbe32895d4b24e8b00faac9dfe))
94
+
95
+ ## [0.1.0](https://github.com/Soju06/codex-lb/releases/tag/v0.1.0) (2026-01-07)
96
+
97
+
98
+ ### Bug Fixes
99
+
100
+ * address lint warnings ([7c3cc06](https://github.com/Soju06/codex-lb/commit/7c3cc06c9a6a9a8895c1dd5fcc57b3c0eebdb3))
101
+ * skip project install in docker build ([64e9156](https://github.com/Soju06/codex-lb/commit/64e9156075c256ef48c0587ea1abb7cc092b97a5))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-lb
3
- Version: 0.1.5
3
+ Version: 0.3.0
4
4
  Summary: Codex load balancer and proxy for ChatGPT accounts with usage dashboard
5
5
  Author-email: Soju06 <qlskssk@gmail.com>
6
6
  Maintainer-email: Soju06 <qlskssk@gmail.com>
@@ -39,7 +39,7 @@ Classifier: Topic :: Software Development :: Libraries
39
39
  Classifier: Topic :: System :: Networking
40
40
  Requires-Python: >=3.13
41
41
  Requires-Dist: aiohttp-retry>=2.9.1
42
- Requires-Dist: aiohttp>=3.13.2
42
+ Requires-Dist: aiohttp>=3.13.3
43
43
  Requires-Dist: aiosqlite>=0.22.1
44
44
  Requires-Dist: cryptography>=46.0.3
45
45
  Requires-Dist: fastapi[standard]>=0.128.0
@@ -1,4 +1,4 @@
1
- __version__ = "0.1.1"
1
+ __version__ = "0.2.0"
2
2
 
3
3
  from app.main import app as app
4
4
 
@@ -82,14 +82,25 @@ def extract_id_token_claims(id_token: str) -> IdTokenClaims:
82
82
  def claims_from_auth(auth: AuthFile) -> AccountClaims:
83
83
  claims = extract_id_token_claims(auth.tokens.id_token)
84
84
  auth_claims = claims.auth or OpenAIAuthClaims()
85
+ plan_type = auth_claims.chatgpt_plan_type or claims.chatgpt_plan_type
85
86
  return AccountClaims(
86
87
  account_id=auth.tokens.account_id or auth_claims.chatgpt_account_id or claims.chatgpt_account_id,
87
88
  email=claims.email,
88
- plan_type=auth_claims.chatgpt_plan_type or claims.chatgpt_plan_type,
89
+ plan_type=plan_type,
89
90
  )
90
91
 
91
92
 
93
+ def generate_unique_account_id(account_id: str | None, email: str | None) -> str:
94
+ if account_id and email and email != DEFAULT_EMAIL:
95
+ email_hash = hashlib.sha256(email.encode()).hexdigest()[:8]
96
+ return f"{account_id}_{email_hash}"
97
+ if account_id:
98
+ return account_id
99
+ return fallback_account_id(email)
100
+
101
+
92
102
  def fallback_account_id(email: str | None) -> str:
103
+ """Generate a fallback account ID when no OpenAI account ID is available."""
93
104
  if email and email != DEFAULT_EMAIL:
94
105
  digest = hashlib.sha256(email.encode()).hexdigest()[:12]
95
106
  return f"email_{digest}"
@@ -16,6 +16,9 @@ PERMANENT_FAILURE_CODES = {
16
16
  "account_deleted": "Account has been deleted",
17
17
  }
18
18
 
19
+ SECONDS_PER_DAY = 60 * 60 * 24
20
+ UNKNOWN_RESET_BUCKET_DAYS = 10_000
21
+
19
22
 
20
23
  @dataclass
21
24
  class AccountState:
@@ -23,6 +26,9 @@ class AccountState:
23
26
  status: AccountStatus
24
27
  used_percent: float | None = None
25
28
  reset_at: float | None = None
29
+ cooldown_until: float | None = None
30
+ secondary_used_percent: float | None = None
31
+ secondary_reset_at: int | None = None
26
32
  last_error_at: float | None = None
27
33
  last_selected_at: float | None = None
28
34
  error_count: int = 0
@@ -35,7 +41,12 @@ class SelectionResult:
35
41
  error_message: str | None
36
42
 
37
43
 
38
- def select_account(states: Iterable[AccountState], now: float | None = None) -> SelectionResult:
44
+ def select_account(
45
+ states: Iterable[AccountState],
46
+ now: float | None = None,
47
+ *,
48
+ prefer_earlier_reset: bool = False,
49
+ ) -> SelectionResult:
39
50
  current = now or time.time()
40
51
  available: list[AccountState] = []
41
52
  all_states = list(states)
@@ -59,6 +70,12 @@ def select_account(states: Iterable[AccountState], now: float | None = None) ->
59
70
  state.reset_at = None
60
71
  else:
61
72
  continue
73
+ if state.cooldown_until and current >= state.cooldown_until:
74
+ state.cooldown_until = None
75
+ state.last_error_at = None
76
+ state.error_count = 0
77
+ if state.cooldown_until and current < state.cooldown_until:
78
+ continue
62
79
  if state.error_count >= 3:
63
80
  backoff = min(300, 30 * (2 ** (state.error_count - 3)))
64
81
  if state.last_error_at and current - state.last_error_at < backoff:
@@ -82,14 +99,29 @@ def select_account(states: Iterable[AccountState], now: float | None = None) ->
82
99
  if reset_candidates:
83
100
  wait_seconds = max(0, min(reset_candidates) - int(current))
84
101
  return SelectionResult(None, f"Rate limit exceeded. Try again in {wait_seconds:.0f}s")
102
+ cooldowns = [s.cooldown_until for s in all_states if s.cooldown_until and s.cooldown_until > current]
103
+ if cooldowns:
104
+ wait_seconds = max(0.0, min(cooldowns) - current)
105
+ return SelectionResult(None, f"Rate limit exceeded. Try again in {wait_seconds:.0f}s")
85
106
  return SelectionResult(None, "No available accounts")
86
107
 
87
- def _sort_key(state: AccountState) -> tuple[float, float, str]:
88
- used = state.used_percent if state.used_percent is not None else 0.0
108
+ def _usage_sort_key(state: AccountState) -> tuple[float, float, float, str]:
109
+ primary_used = state.used_percent if state.used_percent is not None else 0.0
110
+ secondary_used = state.secondary_used_percent if state.secondary_used_percent is not None else primary_used
89
111
  last_selected = state.last_selected_at or 0.0
90
- return used, last_selected, state.account_id
91
-
92
- selected = min(available, key=_sort_key)
112
+ return secondary_used, primary_used, last_selected, state.account_id
113
+
114
+ def _reset_first_sort_key(state: AccountState) -> tuple[int, float, float, float, str]:
115
+ reset_bucket_days = UNKNOWN_RESET_BUCKET_DAYS
116
+ if state.secondary_reset_at is not None:
117
+ reset_bucket_days = max(
118
+ 0,
119
+ int((state.secondary_reset_at - current) // SECONDS_PER_DAY),
120
+ )
121
+ secondary_used, primary_used, last_selected, account_id = _usage_sort_key(state)
122
+ return reset_bucket_days, secondary_used, primary_used, last_selected, account_id
123
+
124
+ selected = min(available, key=_reset_first_sort_key if prefer_earlier_reset else _usage_sort_key)
93
125
  return SelectionResult(selected, None)
94
126
 
95
127
 
@@ -97,11 +129,16 @@ def handle_rate_limit(state: AccountState, error: UpstreamError) -> None:
97
129
  state.status = AccountStatus.RATE_LIMITED
98
130
  state.error_count += 1
99
131
  state.last_error_at = time.time()
132
+
133
+ reset_at = _extract_reset_at(error)
134
+ if reset_at is not None:
135
+ state.reset_at = reset_at
136
+
100
137
  message = error.get("message")
101
138
  delay = parse_retry_after(message) if message else None
102
139
  if delay is None:
103
140
  delay = backoff_seconds(state.error_count)
104
- state.reset_at = time.time() + delay
141
+ state.cooldown_until = time.time() + delay
105
142
 
106
143
 
107
144
  def handle_quota_exceeded(state: AccountState, error: UpstreamError) -> None:
@@ -18,7 +18,6 @@ IGNORE_INBOUND_HEADERS = {"authorization", "chatgpt-account-id", "content-length
18
18
 
19
19
  _ERROR_TYPE_CODE_MAP = {
20
20
  "rate_limit_exceeded": "rate_limit_exceeded",
21
- "usage_limit_reached": "rate_limit_exceeded",
22
21
  "usage_not_included": "usage_not_included",
23
22
  "insufficient_quota": "insufficient_quota",
24
23
  "quota_exceeded": "quota_exceeded",
@@ -64,12 +63,11 @@ def _normalize_error_code(code: str | None, error_type: str | None) -> str:
64
63
  if code:
65
64
  normalized_code = code.lower()
66
65
  mapped = _ERROR_TYPE_CODE_MAP.get(normalized_code)
67
- return mapped or code
66
+ return mapped or normalized_code
68
67
  normalized_type = error_type.lower() if error_type else None
69
68
  if normalized_type:
70
69
  mapped = _ERROR_TYPE_CODE_MAP.get(normalized_type)
71
- if mapped:
72
- return mapped
70
+ return mapped or normalized_type
73
71
  return "upstream_error"
74
72
 
75
73
 
@@ -39,6 +39,9 @@ class Settings(BaseSettings):
39
39
  usage_refresh_enabled: bool = True
40
40
  usage_refresh_interval_seconds: int = 60
41
41
  encryption_key_file: Path = DEFAULT_ENCRYPTION_KEY_FILE
42
+ database_migrations_fail_fast: bool = True
43
+ log_proxy_request_shape: bool = False
44
+ log_proxy_request_shape_raw_cache_key: bool = False
42
45
 
43
46
  @field_validator("database_url")
44
47
  @classmethod
@@ -61,7 +64,7 @@ class Settings(BaseSettings):
61
64
  return value.expanduser()
62
65
  if isinstance(value, str):
63
66
  return Path(value).expanduser()
64
- return value
67
+ raise TypeError("encryption_key_file must be a path")
65
68
 
66
69
 
67
70
  @lru_cache(maxsize=1)
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Final
4
+
5
+ ACCOUNT_PLAN_TYPES: Final[set[str]] = {
6
+ "free",
7
+ "plus",
8
+ "pro",
9
+ "team",
10
+ "business",
11
+ "enterprise",
12
+ "edu",
13
+ }
14
+
15
+ RATE_LIMIT_PLAN_TYPES: Final[set[str]] = {
16
+ *ACCOUNT_PLAN_TYPES,
17
+ "guest",
18
+ "go",
19
+ "free_workspace",
20
+ "education",
21
+ "quorum",
22
+ "k12",
23
+ }
24
+
25
+
26
+ def _clean_plan_type(value: str | None) -> str | None:
27
+ if value is None:
28
+ return None
29
+ cleaned = value.strip()
30
+ return cleaned or None
31
+
32
+
33
+ def normalize_account_plan_type(value: str | None) -> str | None:
34
+ cleaned = _clean_plan_type(value)
35
+ if not cleaned:
36
+ return None
37
+ normalized = cleaned.lower()
38
+ return normalized if normalized in ACCOUNT_PLAN_TYPES else None
39
+
40
+
41
+ def canonicalize_account_plan_type(value: str | None) -> str | None:
42
+ cleaned = _clean_plan_type(value)
43
+ if not cleaned:
44
+ return None
45
+ normalized = cleaned.lower()
46
+ if normalized in ACCOUNT_PLAN_TYPES:
47
+ return normalized
48
+ return cleaned
49
+
50
+
51
+ def coerce_account_plan_type(value: str | None, default: str) -> str:
52
+ cleaned = _clean_plan_type(value)
53
+ if cleaned is None:
54
+ return default
55
+ canonical = canonicalize_account_plan_type(cleaned)
56
+ return canonical if canonical is not None else default
57
+
58
+
59
+ def normalize_rate_limit_plan_type(value: str | None) -> str | None:
60
+ cleaned = _clean_plan_type(value)
61
+ if not cleaned:
62
+ return None
63
+ normalized = cleaned.lower()
64
+ return normalized if normalized in RATE_LIMIT_PLAN_TYPES else None
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+
5
+ type JsonValue = bool | int | float | str | None | list[JsonValue] | Mapping[str, JsonValue]
6
+ type JsonObject = Mapping[str, JsonValue]
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from typing import Iterable, Mapping
4
4
 
5
+ from app.core.plan_types import normalize_account_plan_type
5
6
  from app.core.usage.types import (
6
7
  UsageCostSummary,
7
8
  UsageHistoryPayload,
@@ -16,12 +17,14 @@ from app.db.models import Account
16
17
  PLAN_CAPACITY_CREDITS_PRIMARY = {
17
18
  "plus": 225.0,
18
19
  "business": 225.0,
20
+ "team": 225.0,
19
21
  "pro": 1500.0,
20
22
  }
21
23
 
22
24
  PLAN_CAPACITY_CREDITS_SECONDARY = {
23
25
  "plus": 7560.0,
24
26
  "business": 7560.0,
27
+ "team": 7560.0,
25
28
  "pro": 50400.0,
26
29
  }
27
30
 
@@ -134,9 +137,9 @@ def summarize_usage_window(
134
137
 
135
138
 
136
139
  def capacity_for_plan(plan_type: str | None, window: str) -> float | None:
137
- if not plan_type:
140
+ normalized = normalize_account_plan_type(plan_type)
141
+ if not normalized:
138
142
  return None
139
- normalized = plan_type.lower()
140
143
  window_key = _normalize_window_key(window)
141
144
  if window_key == "primary":
142
145
  return PLAN_CAPACITY_CREDITS_PRIMARY.get(normalized)
@@ -13,6 +13,17 @@ class RequestLogLike(Protocol):
13
13
  reasoning_tokens: int | None
14
14
 
15
15
 
16
+ def cached_input_tokens_from_log(log: RequestLogLike) -> int | None:
17
+ cached_tokens = log.cached_input_tokens
18
+ if cached_tokens is None:
19
+ return None
20
+ cached_tokens = max(0, int(cached_tokens))
21
+ input_tokens = log.input_tokens
22
+ if input_tokens is not None:
23
+ cached_tokens = min(cached_tokens, int(input_tokens))
24
+ return cached_tokens
25
+
26
+
16
27
  def usage_tokens_from_log(log: RequestLogLike) -> UsageTokens | None:
17
28
  input_tokens = log.input_tokens
18
29
  if input_tokens is None:
@@ -20,8 +31,7 @@ def usage_tokens_from_log(log: RequestLogLike) -> UsageTokens | None:
20
31
  output_tokens = log.output_tokens if log.output_tokens is not None else log.reasoning_tokens
21
32
  if output_tokens is None:
22
33
  return None
23
- cached_tokens = log.cached_input_tokens or 0
24
- cached_tokens = max(0, min(cached_tokens, input_tokens))
34
+ cached_tokens = cached_input_tokens_from_log(log) or 0
25
35
  return UsageTokens(
26
36
  input_tokens=float(input_tokens),
27
37
  output_tokens=float(output_tokens),
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+
5
+ from app.core import usage as usage_core
6
+ from app.db.models import AccountStatus
7
+
8
+
9
+ def apply_usage_quota(
10
+ *,
11
+ status: AccountStatus,
12
+ primary_used: float | None,
13
+ primary_reset: int | None,
14
+ primary_window_minutes: int | None,
15
+ runtime_reset: float | None,
16
+ secondary_used: float | None,
17
+ secondary_reset: int | None,
18
+ ) -> tuple[AccountStatus, float | None, float | None]:
19
+ used_percent = primary_used
20
+ reset_at = runtime_reset
21
+
22
+ if status in (AccountStatus.DEACTIVATED, AccountStatus.PAUSED):
23
+ return status, used_percent, reset_at
24
+
25
+ if secondary_used is not None:
26
+ if secondary_used >= 100.0:
27
+ status = AccountStatus.QUOTA_EXCEEDED
28
+ used_percent = 100.0
29
+ if secondary_reset is not None:
30
+ reset_at = secondary_reset
31
+ return status, used_percent, reset_at
32
+ if status == AccountStatus.QUOTA_EXCEEDED:
33
+ if runtime_reset and runtime_reset > time.time():
34
+ reset_at = runtime_reset
35
+ else:
36
+ status = AccountStatus.ACTIVE
37
+ reset_at = None
38
+ elif status == AccountStatus.QUOTA_EXCEEDED and secondary_reset is not None:
39
+ reset_at = secondary_reset
40
+
41
+ if primary_used is not None:
42
+ if primary_used >= 100.0:
43
+ status = AccountStatus.RATE_LIMITED
44
+ used_percent = 100.0
45
+ if primary_reset is not None:
46
+ reset_at = primary_reset
47
+ else:
48
+ reset_at = _fallback_primary_reset(primary_window_minutes) or reset_at
49
+ return status, used_percent, reset_at
50
+ if status == AccountStatus.RATE_LIMITED:
51
+ if runtime_reset and runtime_reset > time.time():
52
+ reset_at = runtime_reset
53
+ else:
54
+ status = AccountStatus.ACTIVE
55
+ reset_at = None
56
+
57
+ return status, used_percent, reset_at
58
+
59
+
60
+ def _fallback_primary_reset(primary_window_minutes: int | None) -> float | None:
61
+ window_minutes = primary_window_minutes or usage_core.default_window_minutes("primary")
62
+ if not window_minutes:
63
+ return None
64
+ return time.time() + float(window_minutes) * 60.0
@@ -67,8 +67,9 @@ class UsageCostSummary:
67
67
  class UsageMetricsSummary:
68
68
  requests_7d: int | None
69
69
  tokens_secondary_window: int | None
70
- error_rate_7d: float | None
71
- top_error: str | None
70
+ cached_tokens_secondary_window: int | None = None
71
+ error_rate_7d: float | None = None
72
+ top_error: str | None = None
72
73
 
73
74
 
74
75
  @dataclass(frozen=True)
@@ -1,11 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ from collections.abc import Mapping
4
5
 
5
- from app.core.types import JsonObject
6
+ from app.core.errors import ResponseFailedEvent
7
+ from app.core.types import JsonValue
6
8
 
9
+ type JsonPayload = Mapping[str, JsonValue] | ResponseFailedEvent
7
10
 
8
- def format_sse_event(payload: JsonObject) -> str:
11
+
12
+ def format_sse_event(payload: JsonPayload) -> str:
9
13
  data = json.dumps(payload, ensure_ascii=True, separators=(",", ":"))
10
14
  event_type = payload.get("type")
11
15
  if isinstance(event_type, str) and event_type:
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import asynccontextmanager
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timezone
6
+ from typing import Awaitable, Callable, Final
7
+
8
+ from sqlalchemy import text
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from app.db.migrations.versions import (
12
+ add_accounts_chatgpt_account_id,
13
+ add_accounts_reset_at,
14
+ add_dashboard_settings,
15
+ add_request_logs_reasoning_effort,
16
+ normalize_account_plan_types,
17
+ )
18
+
19
+ _CREATE_MIGRATIONS_TABLE = """
20
+ CREATE TABLE IF NOT EXISTS schema_migrations (
21
+ name TEXT PRIMARY KEY,
22
+ applied_at TEXT NOT NULL
23
+ )
24
+ """
25
+
26
+ _INSERT_MIGRATION = """
27
+ INSERT INTO schema_migrations (name, applied_at)
28
+ VALUES (:name, :applied_at)
29
+ ON CONFLICT(name) DO NOTHING
30
+ RETURNING name
31
+ """
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Migration:
36
+ name: str
37
+ run: Callable[[AsyncSession], Awaitable[None]]
38
+
39
+
40
+ MIGRATIONS: Final[tuple[Migration, ...]] = (
41
+ Migration("001_normalize_account_plan_types", normalize_account_plan_types.run),
42
+ Migration("002_add_request_logs_reasoning_effort", add_request_logs_reasoning_effort.run),
43
+ Migration("003_add_accounts_reset_at", add_accounts_reset_at.run),
44
+ Migration("004_add_accounts_chatgpt_account_id", add_accounts_chatgpt_account_id.run),
45
+ Migration("005_add_dashboard_settings", add_dashboard_settings.run),
46
+ )
47
+
48
+
49
+ async def run_migrations(session: AsyncSession) -> int:
50
+ await _ensure_schema_migrations(session)
51
+ applied_count = 0
52
+ for migration in MIGRATIONS:
53
+ applied_now = await _apply_migration(session, migration)
54
+ if applied_now:
55
+ applied_count += 1
56
+ return applied_count
57
+
58
+
59
+ async def _apply_migration(session: AsyncSession, migration: Migration) -> bool:
60
+ async with _migration_transaction(session):
61
+ result = await session.execute(
62
+ text(_INSERT_MIGRATION),
63
+ {
64
+ "name": migration.name,
65
+ "applied_at": _utcnow_iso(),
66
+ },
67
+ )
68
+ inserted = result.scalar_one_or_none()
69
+ if inserted is None:
70
+ return False
71
+ await migration.run(session)
72
+ return True
73
+
74
+
75
+ async def _ensure_schema_migrations(session: AsyncSession) -> None:
76
+ async with _migration_transaction(session):
77
+ await session.execute(text(_CREATE_MIGRATIONS_TABLE))
78
+
79
+
80
+ @asynccontextmanager
81
+ async def _migration_transaction(session: AsyncSession):
82
+ if session.in_transaction():
83
+ async with session.begin_nested():
84
+ yield
85
+ else:
86
+ async with session.begin():
87
+ yield
88
+
89
+
90
+ def _utcnow_iso() -> str:
91
+ return datetime.now(timezone.utc).isoformat()
@@ -0,0 +1 @@
1
+ from __future__ import annotations