codex-lb 0.4.0__tar.gz → 0.5.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 (170) hide show
  1. {codex_lb-0.4.0 → codex_lb-0.5.0}/.all-contributorsrc +2 -1
  2. codex_lb-0.5.0/.github/release-please-manifest.json +3 -0
  3. {codex_lb-0.4.0 → codex_lb-0.5.0}/.gitignore +2 -1
  4. {codex_lb-0.4.0 → codex_lb-0.5.0}/CHANGELOG.md +21 -0
  5. {codex_lb-0.4.0 → codex_lb-0.5.0}/PKG-INFO +3 -2
  6. {codex_lb-0.4.0 → codex_lb-0.5.0}/README.md +1 -1
  7. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/config/settings.py +8 -8
  8. codex_lb-0.5.0/app/core/handlers/__init__.py +3 -0
  9. codex_lb-0.5.0/app/core/handlers/exceptions.py +39 -0
  10. codex_lb-0.5.0/app/core/middleware/__init__.py +9 -0
  11. codex_lb-0.5.0/app/core/middleware/api_errors.py +33 -0
  12. codex_lb-0.5.0/app/core/middleware/request_decompression.py +101 -0
  13. codex_lb-0.5.0/app/core/middleware/request_id.py +27 -0
  14. codex_lb-0.5.0/app/core/openai/chat_requests.py +172 -0
  15. codex_lb-0.5.0/app/core/openai/chat_responses.py +534 -0
  16. codex_lb-0.5.0/app/core/openai/message_coercion.py +60 -0
  17. codex_lb-0.5.0/app/core/openai/models_catalog.py +72 -0
  18. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/openai/requests.py +4 -4
  19. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/openai/v1_requests.py +4 -60
  20. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/db/session.py +25 -8
  21. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/dependencies.py +43 -16
  22. codex_lb-0.5.0/app/main.py +79 -0
  23. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/accounts/repository.py +21 -9
  24. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/proxy/api.py +58 -0
  25. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/proxy/load_balancer.py +75 -58
  26. codex_lb-0.5.0/app/modules/proxy/repo_bundle.py +23 -0
  27. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/proxy/service.py +98 -102
  28. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/request_logs/repository.py +3 -0
  29. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/usage/service.py +65 -4
  30. {codex_lb-0.4.0 → codex_lb-0.5.0}/docker-compose.yml +3 -0
  31. codex_lb-0.5.0/docs/plans/2026-01-27-v1-chat-bridge-implementation.md +390 -0
  32. {codex_lb-0.4.0 → codex_lb-0.5.0}/pyproject.toml +2 -1
  33. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/integration/test_load_balancer_integration.py +22 -2
  34. codex_lb-0.5.0/tests/integration/test_proxy_chat_completions.py +82 -0
  35. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/integration/test_repositories.py +24 -0
  36. codex_lb-0.5.0/tests/integration/test_request_decompression.py +56 -0
  37. codex_lb-0.5.0/tests/integration/test_v1_models.py +21 -0
  38. codex_lb-0.5.0/tests/unit/test_chat_request_mapping.py +148 -0
  39. codex_lb-0.5.0/tests/unit/test_chat_response_mapping.py +141 -0
  40. codex_lb-0.5.0/tests/unit/test_db_session.py +23 -0
  41. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/unit/test_openai_requests.py +3 -3
  42. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/unit/test_proxy_utils.py +2 -0
  43. codex_lb-0.5.0/tests/unit/test_request_decompression_middleware.py +45 -0
  44. codex_lb-0.5.0/tests/unit/test_request_logs_repository.py +35 -0
  45. {codex_lb-0.4.0 → codex_lb-0.5.0}/uv.lock +966 -924
  46. codex_lb-0.4.0/.github/release-please-manifest.json +0 -3
  47. codex_lb-0.4.0/app/main.py +0 -134
  48. {codex_lb-0.4.0 → codex_lb-0.5.0}/.dockerignore +0 -0
  49. {codex_lb-0.4.0 → codex_lb-0.5.0}/.env.example +0 -0
  50. {codex_lb-0.4.0 → codex_lb-0.5.0}/.github/release-please-config.json +0 -0
  51. {codex_lb-0.4.0 → codex_lb-0.5.0}/.github/workflows/ci.yml +0 -0
  52. {codex_lb-0.4.0 → codex_lb-0.5.0}/.github/workflows/release-please.yml +0 -0
  53. {codex_lb-0.4.0 → codex_lb-0.5.0}/.github/workflows/release.yml +0 -0
  54. {codex_lb-0.4.0 → codex_lb-0.5.0}/.pre-commit-config.yaml +0 -0
  55. {codex_lb-0.4.0 → codex_lb-0.5.0}/AGENTS.md +0 -0
  56. {codex_lb-0.4.0 → codex_lb-0.5.0}/Dockerfile +0 -0
  57. {codex_lb-0.4.0 → codex_lb-0.5.0}/LICENSE +0 -0
  58. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/__init__.py +0 -0
  59. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/cli.py +0 -0
  60. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/__init__.py +0 -0
  61. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/auth/__init__.py +0 -0
  62. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/auth/models.py +0 -0
  63. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/auth/refresh.py +0 -0
  64. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/balancer/__init__.py +0 -0
  65. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/balancer/logic.py +0 -0
  66. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/balancer/types.py +0 -0
  67. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/clients/__init__.py +0 -0
  68. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/clients/http.py +0 -0
  69. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/clients/oauth.py +0 -0
  70. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/clients/proxy.py +0 -0
  71. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/clients/usage.py +0 -0
  72. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/config/__init__.py +0 -0
  73. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/crypto.py +0 -0
  74. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/errors.py +0 -0
  75. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/openai/__init__.py +0 -0
  76. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/openai/models.py +0 -0
  77. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/openai/parsing.py +0 -0
  78. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/plan_types.py +0 -0
  79. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/types.py +0 -0
  80. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/usage/__init__.py +0 -0
  81. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/usage/logs.py +0 -0
  82. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/usage/models.py +0 -0
  83. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/usage/pricing.py +0 -0
  84. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/usage/quota.py +0 -0
  85. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/usage/types.py +0 -0
  86. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/utils/__init__.py +0 -0
  87. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/utils/request_id.py +0 -0
  88. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/utils/retry.py +0 -0
  89. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/utils/sse.py +0 -0
  90. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/core/utils/time.py +0 -0
  91. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/db/__init__.py +0 -0
  92. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/db/migrations/__init__.py +0 -0
  93. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/db/migrations/versions/__init__.py +0 -0
  94. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/db/migrations/versions/add_accounts_chatgpt_account_id.py +0 -0
  95. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/db/migrations/versions/add_accounts_reset_at.py +0 -0
  96. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/db/migrations/versions/add_dashboard_settings.py +0 -0
  97. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/db/migrations/versions/add_request_logs_reasoning_effort.py +0 -0
  98. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/db/migrations/versions/normalize_account_plan_types.py +0 -0
  99. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/db/models.py +0 -0
  100. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/__init__.py +0 -0
  101. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/accounts/__init__.py +0 -0
  102. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/accounts/api.py +0 -0
  103. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/accounts/auth_manager.py +0 -0
  104. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/accounts/schemas.py +0 -0
  105. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/accounts/service.py +0 -0
  106. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/health/__init__.py +0 -0
  107. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/health/api.py +0 -0
  108. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/health/schemas.py +0 -0
  109. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/oauth/__init__.py +0 -0
  110. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/oauth/api.py +0 -0
  111. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/oauth/schemas.py +0 -0
  112. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/oauth/service.py +0 -0
  113. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/oauth/templates/oauth_success.html +0 -0
  114. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/proxy/__init__.py +0 -0
  115. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/proxy/helpers.py +0 -0
  116. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/proxy/schemas.py +0 -0
  117. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/proxy/sticky_repository.py +0 -0
  118. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/proxy/types.py +0 -0
  119. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/request_logs/__init__.py +0 -0
  120. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/request_logs/api.py +0 -0
  121. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/request_logs/schemas.py +0 -0
  122. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/request_logs/service.py +0 -0
  123. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/settings/__init__.py +0 -0
  124. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/settings/api.py +0 -0
  125. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/settings/repository.py +0 -0
  126. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/settings/schemas.py +0 -0
  127. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/settings/service.py +0 -0
  128. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/shared/__init__.py +0 -0
  129. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/shared/schemas.py +0 -0
  130. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/usage/__init__.py +0 -0
  131. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/usage/api.py +0 -0
  132. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/usage/repository.py +0 -0
  133. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/usage/schemas.py +0 -0
  134. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/modules/usage/updater.py +0 -0
  135. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/static/index.css +0 -0
  136. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/static/index.html +0 -0
  137. {codex_lb-0.4.0 → codex_lb-0.5.0}/app/static/index.js +0 -0
  138. {codex_lb-0.4.0 → codex_lb-0.5.0}/docs/screenshots/accounts.jpg +0 -0
  139. {codex_lb-0.4.0 → codex_lb-0.5.0}/docs/screenshots/dashboard.jpg +0 -0
  140. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/__init__.py +0 -0
  141. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/conftest.py +0 -0
  142. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/integration/test_accounts_api.py +0 -0
  143. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/integration/test_accounts_api_extended.py +0 -0
  144. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/integration/test_codex_usage_api.py +0 -0
  145. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/integration/test_db_models.py +0 -0
  146. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/integration/test_health_and_errors.py +0 -0
  147. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/integration/test_migrations.py +0 -0
  148. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/integration/test_oauth_flow.py +0 -0
  149. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/integration/test_proxy_api_extended.py +0 -0
  150. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/integration/test_proxy_compact.py +0 -0
  151. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/integration/test_proxy_responses.py +0 -0
  152. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/integration/test_proxy_sticky_sessions.py +0 -0
  153. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/integration/test_request_logs_api.py +0 -0
  154. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/integration/test_request_logs_filters.py +0 -0
  155. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/integration/test_settings_api.py +0 -0
  156. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/integration/test_usage_api.py +0 -0
  157. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/integration/test_usage_summary.py +0 -0
  158. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/test_request_logs_options_api.py +0 -0
  159. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/unit/test_auth.py +0 -0
  160. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/unit/test_auth_manager.py +0 -0
  161. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/unit/test_auth_refresh.py +0 -0
  162. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/unit/test_load_balancer.py +0 -0
  163. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/unit/test_oauth_client.py +0 -0
  164. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/unit/test_pricing.py +0 -0
  165. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/unit/test_proxy_errors.py +0 -0
  166. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/unit/test_retry.py +0 -0
  167. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/unit/test_sse.py +0 -0
  168. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/unit/test_usage.py +0 -0
  169. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/unit/test_usage_client.py +0 -0
  170. {codex_lb-0.4.0 → codex_lb-0.5.0}/tests/unit/test_usage_updater.py +0 -0
@@ -52,7 +52,8 @@
52
52
  "profile": "https://github.com/hhsw2015",
53
53
  "contributions": [
54
54
  "code",
55
- "test"
55
+ "test",
56
+ "maintenance"
56
57
  ]
57
58
  },
58
59
  {
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.5.0"
3
+ }
@@ -33,6 +33,7 @@ node_modules/
33
33
 
34
34
  # Local
35
35
  .local/
36
+ .worktrees/
36
37
 
37
38
  app/static/components
38
- .codex-lb/
39
+ .codex-lb/
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.0](https://github.com/Soju06/codex-lb/compare/v0.4.0...v0.5.0) (2026-01-29)
4
+
5
+
6
+ ### Features
7
+
8
+ * **db:** add configurable pool settings ([#44](https://github.com/Soju06/codex-lb/issues/44)) ([e2e553d](https://github.com/Soju06/codex-lb/commit/e2e553debfac1ab51c691a883b16812db6acdd9e))
9
+ * **proxy:** add v1 chat and models endpoints ([#39](https://github.com/Soju06/codex-lb/issues/39)) ([c242304](https://github.com/Soju06/codex-lb/commit/c242304304583821afebb9e2c0b2803012d4a7aa))
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * **accounts:** update upsert for duplicate email ([#35](https://github.com/Soju06/codex-lb/issues/35)) ([5f68773](https://github.com/Soju06/codex-lb/commit/5f6877342d81abca82e800dbf0b21458e78cb1d9))
15
+ * **core:** support zstd request decompression and modularize middleware ([#42](https://github.com/Soju06/codex-lb/issues/42)) ([d0eebb7](https://github.com/Soju06/codex-lb/commit/d0eebb7b9c8c16b1a1293279db42633ba75b1867))
16
+ * **proxy:** use short-lived sessions for streaming ([#38](https://github.com/Soju06/codex-lb/issues/38)) ([cb48757](https://github.com/Soju06/codex-lb/commit/cb48757bfbf66d3fb2598523d66c6b5bda44a55d))
17
+ * **usage:** coalesce refresh requests ([#36](https://github.com/Soju06/codex-lb/issues/36)) ([04d8fab](https://github.com/Soju06/codex-lb/commit/04d8fab891236e4d4b6bb46c5219730acbabd822))
18
+
19
+
20
+ ### Documentation
21
+
22
+ * add hhsw2015 as a contributor for maintenance ([#43](https://github.com/Soju06/codex-lb/issues/43)) ([1651968](https://github.com/Soju06/codex-lb/commit/1651968e2c8605190fe8647c755f2ab97a7db3d3))
23
+
3
24
  ## [0.4.0](https://github.com/Soju06/codex-lb/compare/v0.3.1...v0.4.0) (2026-01-26)
4
25
 
5
26
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-lb
3
- Version: 0.4.0
3
+ Version: 0.5.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>
@@ -49,6 +49,7 @@ Requires-Dist: pydantic>=2.12.5
49
49
  Requires-Dist: python-dotenv>=1.2.1
50
50
  Requires-Dist: python-multipart>=0.0.21
51
51
  Requires-Dist: sqlalchemy>=2.0.45
52
+ Requires-Dist: zstandard>=0.25.0
52
53
  Description-Content-Type: text/markdown
53
54
 
54
55
  <!--
@@ -158,7 +159,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
158
159
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/Soju06"><img src="https://avatars.githubusercontent.com/u/34199905?v=4?s=100" width="100px;" alt="Soju06"/><br /><sub><b>Soju06</b></sub></a><br /><a href="https://github.com/Soju06/codex-lb/commits?author=Soju06" title="Code">💻</a> <a href="https://github.com/Soju06/codex-lb/commits?author=Soju06" title="Tests">⚠️</a> <a href="#maintenance-Soju06" title="Maintenance">🚧</a> <a href="#infra-Soju06" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
159
160
  <td align="center" valign="top" width="14.28%"><a href="http://jonas.kamsker.at/"><img src="https://avatars.githubusercontent.com/u/11245306?v=4?s=100" width="100px;" alt="Jonas Kamsker"/><br /><sub><b>Jonas Kamsker</b></sub></a><br /><a href="https://github.com/Soju06/codex-lb/commits?author=JKamsker" title="Code">💻</a> <a href="https://github.com/Soju06/codex-lb/issues?q=author%3AJKamsker" title="Bug reports">🐛</a> <a href="#maintenance-JKamsker" title="Maintenance">🚧</a></td>
160
161
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/Quack6765"><img src="https://avatars.githubusercontent.com/u/5446230?v=4?s=100" width="100px;" alt="Quack"/><br /><sub><b>Quack</b></sub></a><br /><a href="https://github.com/Soju06/codex-lb/commits?author=Quack6765" title="Code">💻</a> <a href="https://github.com/Soju06/codex-lb/issues?q=author%3AQuack6765" title="Bug reports">🐛</a> <a href="#maintenance-Quack6765" title="Maintenance">🚧</a> <a href="#design-Quack6765" title="Design">🎨</a></td>
161
- <td align="center" valign="top" width="14.28%"><a href="https://github.com/hhsw2015"><img src="https://avatars.githubusercontent.com/u/103614420?v=4?s=100" width="100px;" alt="Jill Kok, San Mou"/><br /><sub><b>Jill Kok, San Mou</b></sub></a><br /><a href="https://github.com/Soju06/codex-lb/commits?author=hhsw2015" title="Code">💻</a> <a href="https://github.com/Soju06/codex-lb/commits?author=hhsw2015" title="Tests">⚠️</a></td>
162
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/hhsw2015"><img src="https://avatars.githubusercontent.com/u/103614420?v=4?s=100" width="100px;" alt="Jill Kok, San Mou"/><br /><sub><b>Jill Kok, San Mou</b></sub></a><br /><a href="https://github.com/Soju06/codex-lb/commits?author=hhsw2015" title="Code">💻</a> <a href="https://github.com/Soju06/codex-lb/commits?author=hhsw2015" title="Tests">⚠️</a> <a href="#maintenance-hhsw2015" title="Maintenance">🚧</a></td>
162
163
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/pcy06"><img src="https://avatars.githubusercontent.com/u/44970486?v=4?s=100" width="100px;" alt="PARK CHANYOUNG"/><br /><sub><b>PARK CHANYOUNG</b></sub></a><br /><a href="https://github.com/Soju06/codex-lb/commits?author=pcy06" title="Documentation">📖</a></td>
163
164
  </tr>
164
165
  </tbody>
@@ -105,7 +105,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
105
105
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/Soju06"><img src="https://avatars.githubusercontent.com/u/34199905?v=4?s=100" width="100px;" alt="Soju06"/><br /><sub><b>Soju06</b></sub></a><br /><a href="https://github.com/Soju06/codex-lb/commits?author=Soju06" title="Code">💻</a> <a href="https://github.com/Soju06/codex-lb/commits?author=Soju06" title="Tests">⚠️</a> <a href="#maintenance-Soju06" title="Maintenance">🚧</a> <a href="#infra-Soju06" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
106
106
  <td align="center" valign="top" width="14.28%"><a href="http://jonas.kamsker.at/"><img src="https://avatars.githubusercontent.com/u/11245306?v=4?s=100" width="100px;" alt="Jonas Kamsker"/><br /><sub><b>Jonas Kamsker</b></sub></a><br /><a href="https://github.com/Soju06/codex-lb/commits?author=JKamsker" title="Code">💻</a> <a href="https://github.com/Soju06/codex-lb/issues?q=author%3AJKamsker" title="Bug reports">🐛</a> <a href="#maintenance-JKamsker" title="Maintenance">🚧</a></td>
107
107
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/Quack6765"><img src="https://avatars.githubusercontent.com/u/5446230?v=4?s=100" width="100px;" alt="Quack"/><br /><sub><b>Quack</b></sub></a><br /><a href="https://github.com/Soju06/codex-lb/commits?author=Quack6765" title="Code">💻</a> <a href="https://github.com/Soju06/codex-lb/issues?q=author%3AQuack6765" title="Bug reports">🐛</a> <a href="#maintenance-Quack6765" title="Maintenance">🚧</a> <a href="#design-Quack6765" title="Design">🎨</a></td>
108
- <td align="center" valign="top" width="14.28%"><a href="https://github.com/hhsw2015"><img src="https://avatars.githubusercontent.com/u/103614420?v=4?s=100" width="100px;" alt="Jill Kok, San Mou"/><br /><sub><b>Jill Kok, San Mou</b></sub></a><br /><a href="https://github.com/Soju06/codex-lb/commits?author=hhsw2015" title="Code">💻</a> <a href="https://github.com/Soju06/codex-lb/commits?author=hhsw2015" title="Tests">⚠️</a></td>
108
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/hhsw2015"><img src="https://avatars.githubusercontent.com/u/103614420?v=4?s=100" width="100px;" alt="Jill Kok, San Mou"/><br /><sub><b>Jill Kok, San Mou</b></sub></a><br /><a href="https://github.com/Soju06/codex-lb/commits?author=hhsw2015" title="Code">💻</a> <a href="https://github.com/Soju06/codex-lb/commits?author=hhsw2015" title="Tests">⚠️</a> <a href="#maintenance-hhsw2015" title="Maintenance">🚧</a></td>
109
109
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/pcy06"><img src="https://avatars.githubusercontent.com/u/44970486?v=4?s=100" width="100px;" alt="PARK CHANYOUNG"/><br /><sub><b>PARK CHANYOUNG</b></sub></a><br /><a href="https://github.com/Soju06/codex-lb/commits?author=pcy06" title="Documentation">📖</a></td>
110
110
  </tr>
111
111
  </tbody>
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from functools import lru_cache
4
4
  from pathlib import Path
5
5
 
6
- from pydantic import field_validator
6
+ from pydantic import Field, field_validator
7
7
  from pydantic_settings import BaseSettings, SettingsConfigDict
8
8
 
9
9
  BASE_DIR = Path(__file__).resolve().parents[3]
@@ -22,6 +22,9 @@ class Settings(BaseSettings):
22
22
  )
23
23
 
24
24
  database_url: str = f"sqlite+aiosqlite:///{DEFAULT_DB_PATH}"
25
+ database_pool_size: int = Field(default=15, gt=0)
26
+ database_max_overflow: int = Field(default=10, ge=0)
27
+ database_pool_timeout_seconds: float = Field(default=30.0, gt=0)
25
28
  upstream_base_url: str = "https://chatgpt.com/backend-api"
26
29
  upstream_connect_timeout_seconds: float = 30.0
27
30
  stream_idle_timeout_seconds: float = 300.0
@@ -43,24 +46,21 @@ class Settings(BaseSettings):
43
46
  log_proxy_request_shape: bool = False
44
47
  log_proxy_request_shape_raw_cache_key: bool = False
45
48
  log_proxy_request_payload: bool = False
49
+ max_decompressed_body_bytes: int = Field(default=32 * 1024 * 1024, gt=0)
46
50
 
47
51
  @field_validator("database_url")
48
52
  @classmethod
49
- def _normalize_database_url(cls, value: str) -> str:
50
- if not isinstance(value, str):
51
- return value
52
-
53
+ def _expand_database_url(cls, value: str) -> str:
53
54
  for prefix in ("sqlite+aiosqlite:///", "sqlite:///"):
54
55
  if value.startswith(prefix):
55
56
  path = value[len(prefix) :]
56
57
  if path.startswith("~"):
57
- expanded = str(Path(path).expanduser())
58
- return f"{prefix}{expanded}"
58
+ return f"{prefix}{Path(path).expanduser()}"
59
59
  return value
60
60
 
61
61
  @field_validator("encryption_key_file", mode="before")
62
62
  @classmethod
63
- def _normalize_encryption_key_file(cls, value: object) -> Path:
63
+ def _expand_encryption_key_file(cls, value: str | Path) -> Path:
64
64
  if isinstance(value, Path):
65
65
  return value.expanduser()
66
66
  if isinstance(value, str):
@@ -0,0 +1,3 @@
1
+ from app.core.handlers.exceptions import add_exception_handlers
2
+
3
+ __all__ = ["add_exception_handlers"]
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import FastAPI, Request
4
+ from fastapi.exception_handlers import (
5
+ http_exception_handler,
6
+ request_validation_exception_handler,
7
+ )
8
+ from fastapi.exceptions import RequestValidationError
9
+ from fastapi.responses import JSONResponse, Response
10
+ from starlette.exceptions import HTTPException as StarletteHTTPException
11
+
12
+ from app.core.errors import dashboard_error
13
+
14
+
15
+ def add_exception_handlers(app: FastAPI) -> None:
16
+ @app.exception_handler(RequestValidationError)
17
+ async def validation_error_handler(
18
+ request: Request,
19
+ exc: RequestValidationError,
20
+ ) -> Response:
21
+ if request.url.path.startswith("/api/"):
22
+ return JSONResponse(
23
+ status_code=422,
24
+ content=dashboard_error("validation_error", "Invalid request payload"),
25
+ )
26
+ return await request_validation_exception_handler(request, exc)
27
+
28
+ @app.exception_handler(StarletteHTTPException)
29
+ async def http_error_handler(
30
+ request: Request,
31
+ exc: StarletteHTTPException,
32
+ ) -> Response:
33
+ if request.url.path.startswith("/api/"):
34
+ detail = exc.detail if isinstance(exc.detail, str) else "Request failed"
35
+ return JSONResponse(
36
+ status_code=exc.status_code,
37
+ content=dashboard_error(f"http_{exc.status_code}", detail),
38
+ )
39
+ return await http_exception_handler(request, exc)
@@ -0,0 +1,9 @@
1
+ from app.core.middleware.api_errors import add_api_unhandled_error_middleware
2
+ from app.core.middleware.request_decompression import add_request_decompression_middleware
3
+ from app.core.middleware.request_id import add_request_id_middleware
4
+
5
+ __all__ = [
6
+ "add_api_unhandled_error_middleware",
7
+ "add_request_decompression_middleware",
8
+ "add_request_id_middleware",
9
+ ]
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import Awaitable, Callable
5
+
6
+ from fastapi import FastAPI, Request
7
+ from fastapi.responses import JSONResponse, Response
8
+
9
+ from app.core.errors import dashboard_error
10
+ from app.core.utils.request_id import get_request_id
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def add_api_unhandled_error_middleware(app: FastAPI) -> None:
16
+ @app.middleware("http")
17
+ async def api_unhandled_error_middleware(
18
+ request: Request,
19
+ call_next: Callable[[Request], Awaitable[Response]],
20
+ ) -> Response:
21
+ try:
22
+ return await call_next(request)
23
+ except Exception:
24
+ if request.url.path.startswith("/api/"):
25
+ logger.exception(
26
+ "Unhandled API error request_id=%s",
27
+ get_request_id(),
28
+ )
29
+ return JSONResponse(
30
+ status_code=500,
31
+ content=dashboard_error("internal_error", "Unexpected error"),
32
+ )
33
+ raise
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ from collections.abc import Awaitable, Callable
5
+ from typing import Protocol
6
+
7
+ import zstandard as zstd
8
+ from fastapi import FastAPI, Request
9
+ from fastapi.responses import JSONResponse, Response
10
+
11
+ from app.core.config.settings import get_settings
12
+ from app.core.errors import dashboard_error
13
+
14
+
15
+ class _DecompressedBodyTooLarge(Exception):
16
+ def __init__(self, max_size: int) -> None:
17
+ super().__init__(f"Decompressed body exceeded {max_size} bytes")
18
+ self.max_size = max_size
19
+
20
+
21
+ class _Readable(Protocol):
22
+ def read(self, size: int = ...) -> bytes: ...
23
+
24
+
25
+ def _read_limited(reader: _Readable, max_size: int) -> bytes:
26
+ buffer = bytearray()
27
+ total = 0
28
+ chunk_size = 64 * 1024
29
+ while True:
30
+ chunk = reader.read(chunk_size)
31
+ if not chunk:
32
+ break
33
+ total += len(chunk)
34
+ if total > max_size:
35
+ raise _DecompressedBodyTooLarge(max_size)
36
+ buffer.extend(chunk)
37
+ return bytes(buffer)
38
+
39
+
40
+ def _replace_request_body(request: Request, body: bytes) -> None:
41
+ request._body = body
42
+ headers: list[tuple[bytes, bytes]] = []
43
+ for key, value in request.scope.get("headers", []):
44
+ if key.lower() in (b"content-encoding", b"content-length"):
45
+ continue
46
+ headers.append((key, value))
47
+ headers.append((b"content-length", str(len(body)).encode("ascii")))
48
+ request.scope["headers"] = headers
49
+ # Ensure subsequent request.headers reflects the updated scope headers.
50
+ request._headers = None
51
+
52
+
53
+ def add_request_decompression_middleware(app: FastAPI) -> None:
54
+ @app.middleware("http")
55
+ async def request_decompression_middleware(
56
+ request: Request,
57
+ call_next: Callable[[Request], Awaitable[Response]],
58
+ ) -> Response:
59
+ content_encoding = request.headers.get("content-encoding")
60
+ if not content_encoding:
61
+ return await call_next(request)
62
+ encodings = [enc.strip().lower() for enc in content_encoding.split(",") if enc.strip()]
63
+ if encodings != ["zstd"]:
64
+ return await call_next(request)
65
+ body = await request.body()
66
+ settings = get_settings()
67
+ max_size = settings.max_decompressed_body_bytes
68
+ try:
69
+ decompressed = zstd.ZstdDecompressor().decompress(body, max_output_size=max_size)
70
+ if len(decompressed) > max_size:
71
+ raise _DecompressedBodyTooLarge(max_size)
72
+ except _DecompressedBodyTooLarge:
73
+ return JSONResponse(
74
+ status_code=413,
75
+ content=dashboard_error(
76
+ "payload_too_large",
77
+ "Request body exceeds the maximum allowed size",
78
+ ),
79
+ )
80
+ except Exception:
81
+ try:
82
+ with zstd.ZstdDecompressor().stream_reader(io.BytesIO(body)) as reader:
83
+ decompressed = _read_limited(reader, max_size)
84
+ except _DecompressedBodyTooLarge:
85
+ return JSONResponse(
86
+ status_code=413,
87
+ content=dashboard_error(
88
+ "payload_too_large",
89
+ "Request body exceeds the maximum allowed size",
90
+ ),
91
+ )
92
+ except Exception:
93
+ return JSONResponse(
94
+ status_code=400,
95
+ content=dashboard_error(
96
+ "invalid_request",
97
+ "Request body is zstd-compressed but could not be decompressed",
98
+ ),
99
+ )
100
+ _replace_request_body(request, decompressed)
101
+ return await call_next(request)
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Awaitable, Callable
4
+ from uuid import uuid4
5
+
6
+ from fastapi import FastAPI, Request
7
+ from fastapi.responses import JSONResponse
8
+
9
+ from app.core.utils.request_id import reset_request_id, set_request_id
10
+
11
+
12
+ def add_request_id_middleware(app: FastAPI) -> None:
13
+ @app.middleware("http")
14
+ async def request_id_middleware(
15
+ request: Request,
16
+ call_next: Callable[[Request], Awaitable[JSONResponse]],
17
+ ) -> JSONResponse:
18
+ inbound_request_id = request.headers.get("x-request-id") or request.headers.get("request-id")
19
+ request_id = inbound_request_id or str(uuid4())
20
+ token = set_request_id(request_id)
21
+ try:
22
+ response = await call_next(request)
23
+ except Exception:
24
+ reset_request_id(token)
25
+ raise
26
+ response.headers.setdefault("x-request-id", request_id)
27
+ return response
@@ -0,0 +1,172 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import cast
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
7
+
8
+ from app.core.openai.message_coercion import coerce_messages
9
+ from app.core.openai.requests import ResponsesRequest, ResponsesTextControls, ResponsesTextFormat
10
+ from app.core.types import JsonValue
11
+
12
+
13
+ class ChatCompletionsRequest(BaseModel):
14
+ model_config = ConfigDict(extra="allow")
15
+
16
+ model: str = Field(min_length=1)
17
+ messages: list[dict[str, JsonValue]]
18
+ tools: list[JsonValue] = Field(default_factory=list)
19
+ tool_choice: str | dict[str, JsonValue] | None = None
20
+ parallel_tool_calls: bool | None = None
21
+ stream: bool | None = None
22
+ temperature: float | None = None
23
+ top_p: float | None = None
24
+ stop: str | list[str] | None = None
25
+ n: int | None = None
26
+ presence_penalty: float | None = None
27
+ frequency_penalty: float | None = None
28
+ logprobs: bool | None = None
29
+ top_logprobs: int | None = None
30
+ seed: int | None = None
31
+ response_format: JsonValue | None = None
32
+ max_tokens: int | None = None
33
+ max_completion_tokens: int | None = None
34
+ store: bool | None = None
35
+
36
+ @model_validator(mode="after")
37
+ def _validate_messages(self) -> "ChatCompletionsRequest":
38
+ if not self.messages:
39
+ raise ValueError("'messages' must be a non-empty list.")
40
+ return self
41
+
42
+ def to_responses_request(self) -> ResponsesRequest:
43
+ data = self.model_dump(mode="json", exclude_none=True)
44
+ messages = data.pop("messages")
45
+ data.pop("store", None)
46
+ data.pop("max_tokens", None)
47
+ data.pop("max_completion_tokens", None)
48
+ response_format = data.pop("response_format", None)
49
+ tools = _normalize_chat_tools(data.pop("tools", []))
50
+ tool_choice = _normalize_tool_choice(data.pop("tool_choice", None))
51
+ reasoning_effort = data.pop("reasoning_effort", None)
52
+ if reasoning_effort is not None and "reasoning" not in data:
53
+ data["reasoning"] = {"effort": reasoning_effort}
54
+ if response_format is not None:
55
+ _apply_response_format(data, response_format)
56
+ instructions, input_items = coerce_messages("", messages)
57
+ data["instructions"] = instructions
58
+ data["input"] = input_items
59
+ data["tools"] = tools
60
+ if tool_choice is not None:
61
+ data["tool_choice"] = tool_choice
62
+ return ResponsesRequest.model_validate(data)
63
+
64
+
65
+ class ChatResponseFormatJsonSchema(BaseModel):
66
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
67
+
68
+ name: str | None = None
69
+ schema_: JsonValue | None = Field(default=None, alias="schema")
70
+ strict: bool | None = None
71
+
72
+
73
+ class ChatResponseFormat(BaseModel):
74
+ model_config = ConfigDict(extra="allow")
75
+
76
+ type: str = Field(min_length=1)
77
+ json_schema: ChatResponseFormatJsonSchema | None = None
78
+
79
+ @model_validator(mode="after")
80
+ def _validate_schema(self) -> "ChatResponseFormat":
81
+ if self.type == "json_schema" and self.json_schema is None:
82
+ raise ValueError("'response_format.json_schema' is required when type is 'json_schema'.")
83
+ return self
84
+
85
+
86
+ def _normalize_chat_tools(tools: list[JsonValue]) -> list[JsonValue]:
87
+ normalized: list[JsonValue] = []
88
+ for tool in tools:
89
+ if not isinstance(tool, dict):
90
+ continue
91
+ tool_type = tool.get("type")
92
+ function = tool.get("function")
93
+ if isinstance(function, dict):
94
+ name = function.get("name")
95
+ if not isinstance(name, str) or not name:
96
+ continue
97
+ normalized.append(
98
+ {
99
+ "type": tool_type or "function",
100
+ "name": name,
101
+ "description": function.get("description"),
102
+ "parameters": function.get("parameters"),
103
+ }
104
+ )
105
+ continue
106
+ name = tool.get("name")
107
+ if isinstance(name, str) and name:
108
+ normalized.append(tool)
109
+ return normalized
110
+
111
+
112
+ def _normalize_tool_choice(tool_choice: JsonValue | None) -> JsonValue | None:
113
+ if not isinstance(tool_choice, dict):
114
+ return tool_choice
115
+ tool_type = tool_choice.get("type")
116
+ function = tool_choice.get("function")
117
+ if isinstance(function, dict):
118
+ name = function.get("name")
119
+ if isinstance(name, str) and name:
120
+ return {"type": tool_type or "function", "name": name}
121
+ return tool_choice
122
+
123
+
124
+ def _apply_response_format(data: dict[str, JsonValue], response_format: JsonValue) -> None:
125
+ text_controls = _parse_text_controls(data.get("text"))
126
+ if text_controls is None:
127
+ text_controls = ResponsesTextControls()
128
+ if text_controls.format is not None:
129
+ raise ValueError("Provide either 'response_format' or 'text.format', not both.")
130
+ text_controls.format = _response_format_to_text_format(response_format)
131
+ data["text"] = cast(JsonValue, text_controls.model_dump(mode="json", exclude_none=True))
132
+
133
+
134
+ def _parse_text_controls(text: JsonValue | None) -> ResponsesTextControls | None:
135
+ if text is None:
136
+ return None
137
+ if not isinstance(text, Mapping):
138
+ raise ValueError("'text' must be an object when using 'response_format'.")
139
+ return ResponsesTextControls.model_validate(text)
140
+
141
+
142
+ def _response_format_to_text_format(response_format: JsonValue) -> ResponsesTextFormat:
143
+ if isinstance(response_format, str):
144
+ return _text_format_from_type(response_format)
145
+ if isinstance(response_format, Mapping):
146
+ parsed = ChatResponseFormat.model_validate(response_format)
147
+ return _text_format_from_parsed(parsed)
148
+ raise ValueError("'response_format' must be a string or object.")
149
+
150
+
151
+ def _text_format_from_type(format_type: str) -> ResponsesTextFormat:
152
+ if format_type in ("json_object", "text"):
153
+ return ResponsesTextFormat(type=format_type)
154
+ if format_type == "json_schema":
155
+ raise ValueError("'response_format' must include 'json_schema' when type is 'json_schema'.")
156
+ raise ValueError(f"Unsupported response_format.type: {format_type}")
157
+
158
+
159
+ def _text_format_from_parsed(parsed: ChatResponseFormat) -> ResponsesTextFormat:
160
+ if parsed.type == "json_schema":
161
+ json_schema = parsed.json_schema
162
+ if json_schema is None:
163
+ raise ValueError("'response_format.json_schema' is required when type is 'json_schema'.")
164
+ return ResponsesTextFormat(
165
+ type=parsed.type,
166
+ schema_=json_schema.schema_,
167
+ name=json_schema.name,
168
+ strict=json_schema.strict,
169
+ )
170
+ if parsed.type in ("json_object", "text"):
171
+ return ResponsesTextFormat(type=parsed.type)
172
+ raise ValueError(f"Unsupported response_format.type: {parsed.type}")