codex-lb 0.5.0__tar.gz → 0.5.1__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 (171) hide show
  1. {codex_lb-0.5.0 → codex_lb-0.5.1}/.all-contributorsrc +11 -0
  2. codex_lb-0.5.1/.github/release-please-manifest.json +3 -0
  3. {codex_lb-0.5.0 → codex_lb-0.5.1}/CHANGELOG.md +12 -0
  4. {codex_lb-0.5.0 → codex_lb-0.5.1}/PKG-INFO +2 -1
  5. {codex_lb-0.5.0 → codex_lb-0.5.1}/README.md +1 -0
  6. codex_lb-0.5.1/app/core/middleware/request_decompression.py +158 -0
  7. {codex_lb-0.5.0 → codex_lb-0.5.1}/pyproject.toml +1 -1
  8. codex_lb-0.5.1/tests/unit/test_request_decompression_middleware.py +165 -0
  9. codex_lb-0.5.0/.github/release-please-manifest.json +0 -3
  10. codex_lb-0.5.0/app/core/middleware/request_decompression.py +0 -101
  11. codex_lb-0.5.0/tests/unit/test_request_decompression_middleware.py +0 -45
  12. {codex_lb-0.5.0 → codex_lb-0.5.1}/.dockerignore +0 -0
  13. {codex_lb-0.5.0 → codex_lb-0.5.1}/.env.example +0 -0
  14. {codex_lb-0.5.0 → codex_lb-0.5.1}/.github/release-please-config.json +0 -0
  15. {codex_lb-0.5.0 → codex_lb-0.5.1}/.github/workflows/ci.yml +0 -0
  16. {codex_lb-0.5.0 → codex_lb-0.5.1}/.github/workflows/release-please.yml +0 -0
  17. {codex_lb-0.5.0 → codex_lb-0.5.1}/.github/workflows/release.yml +0 -0
  18. {codex_lb-0.5.0 → codex_lb-0.5.1}/.gitignore +0 -0
  19. {codex_lb-0.5.0 → codex_lb-0.5.1}/.pre-commit-config.yaml +0 -0
  20. {codex_lb-0.5.0 → codex_lb-0.5.1}/AGENTS.md +0 -0
  21. {codex_lb-0.5.0 → codex_lb-0.5.1}/Dockerfile +0 -0
  22. {codex_lb-0.5.0 → codex_lb-0.5.1}/LICENSE +0 -0
  23. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/__init__.py +0 -0
  24. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/cli.py +0 -0
  25. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/__init__.py +0 -0
  26. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/auth/__init__.py +0 -0
  27. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/auth/models.py +0 -0
  28. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/auth/refresh.py +0 -0
  29. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/balancer/__init__.py +0 -0
  30. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/balancer/logic.py +0 -0
  31. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/balancer/types.py +0 -0
  32. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/clients/__init__.py +0 -0
  33. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/clients/http.py +0 -0
  34. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/clients/oauth.py +0 -0
  35. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/clients/proxy.py +0 -0
  36. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/clients/usage.py +0 -0
  37. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/config/__init__.py +0 -0
  38. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/config/settings.py +0 -0
  39. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/crypto.py +0 -0
  40. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/errors.py +0 -0
  41. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/handlers/__init__.py +0 -0
  42. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/handlers/exceptions.py +0 -0
  43. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/middleware/__init__.py +0 -0
  44. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/middleware/api_errors.py +0 -0
  45. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/middleware/request_id.py +0 -0
  46. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/openai/__init__.py +0 -0
  47. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/openai/chat_requests.py +0 -0
  48. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/openai/chat_responses.py +0 -0
  49. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/openai/message_coercion.py +0 -0
  50. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/openai/models.py +0 -0
  51. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/openai/models_catalog.py +0 -0
  52. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/openai/parsing.py +0 -0
  53. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/openai/requests.py +0 -0
  54. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/openai/v1_requests.py +0 -0
  55. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/plan_types.py +0 -0
  56. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/types.py +0 -0
  57. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/usage/__init__.py +0 -0
  58. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/usage/logs.py +0 -0
  59. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/usage/models.py +0 -0
  60. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/usage/pricing.py +0 -0
  61. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/usage/quota.py +0 -0
  62. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/usage/types.py +0 -0
  63. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/utils/__init__.py +0 -0
  64. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/utils/request_id.py +0 -0
  65. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/utils/retry.py +0 -0
  66. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/utils/sse.py +0 -0
  67. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/core/utils/time.py +0 -0
  68. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/db/__init__.py +0 -0
  69. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/db/migrations/__init__.py +0 -0
  70. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/db/migrations/versions/__init__.py +0 -0
  71. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/db/migrations/versions/add_accounts_chatgpt_account_id.py +0 -0
  72. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/db/migrations/versions/add_accounts_reset_at.py +0 -0
  73. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/db/migrations/versions/add_dashboard_settings.py +0 -0
  74. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/db/migrations/versions/add_request_logs_reasoning_effort.py +0 -0
  75. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/db/migrations/versions/normalize_account_plan_types.py +0 -0
  76. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/db/models.py +0 -0
  77. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/db/session.py +0 -0
  78. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/dependencies.py +0 -0
  79. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/main.py +0 -0
  80. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/__init__.py +0 -0
  81. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/accounts/__init__.py +0 -0
  82. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/accounts/api.py +0 -0
  83. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/accounts/auth_manager.py +0 -0
  84. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/accounts/repository.py +0 -0
  85. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/accounts/schemas.py +0 -0
  86. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/accounts/service.py +0 -0
  87. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/health/__init__.py +0 -0
  88. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/health/api.py +0 -0
  89. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/health/schemas.py +0 -0
  90. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/oauth/__init__.py +0 -0
  91. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/oauth/api.py +0 -0
  92. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/oauth/schemas.py +0 -0
  93. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/oauth/service.py +0 -0
  94. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/oauth/templates/oauth_success.html +0 -0
  95. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/proxy/__init__.py +0 -0
  96. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/proxy/api.py +0 -0
  97. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/proxy/helpers.py +0 -0
  98. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/proxy/load_balancer.py +0 -0
  99. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/proxy/repo_bundle.py +0 -0
  100. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/proxy/schemas.py +0 -0
  101. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/proxy/service.py +0 -0
  102. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/proxy/sticky_repository.py +0 -0
  103. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/proxy/types.py +0 -0
  104. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/request_logs/__init__.py +0 -0
  105. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/request_logs/api.py +0 -0
  106. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/request_logs/repository.py +0 -0
  107. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/request_logs/schemas.py +0 -0
  108. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/request_logs/service.py +0 -0
  109. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/settings/__init__.py +0 -0
  110. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/settings/api.py +0 -0
  111. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/settings/repository.py +0 -0
  112. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/settings/schemas.py +0 -0
  113. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/settings/service.py +0 -0
  114. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/shared/__init__.py +0 -0
  115. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/shared/schemas.py +0 -0
  116. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/usage/__init__.py +0 -0
  117. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/usage/api.py +0 -0
  118. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/usage/repository.py +0 -0
  119. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/usage/schemas.py +0 -0
  120. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/usage/service.py +0 -0
  121. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/modules/usage/updater.py +0 -0
  122. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/static/index.css +0 -0
  123. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/static/index.html +0 -0
  124. {codex_lb-0.5.0 → codex_lb-0.5.1}/app/static/index.js +0 -0
  125. {codex_lb-0.5.0 → codex_lb-0.5.1}/docker-compose.yml +0 -0
  126. {codex_lb-0.5.0 → codex_lb-0.5.1}/docs/plans/2026-01-27-v1-chat-bridge-implementation.md +0 -0
  127. {codex_lb-0.5.0 → codex_lb-0.5.1}/docs/screenshots/accounts.jpg +0 -0
  128. {codex_lb-0.5.0 → codex_lb-0.5.1}/docs/screenshots/dashboard.jpg +0 -0
  129. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/__init__.py +0 -0
  130. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/conftest.py +0 -0
  131. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/integration/test_accounts_api.py +0 -0
  132. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/integration/test_accounts_api_extended.py +0 -0
  133. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/integration/test_codex_usage_api.py +0 -0
  134. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/integration/test_db_models.py +0 -0
  135. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/integration/test_health_and_errors.py +0 -0
  136. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/integration/test_load_balancer_integration.py +0 -0
  137. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/integration/test_migrations.py +0 -0
  138. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/integration/test_oauth_flow.py +0 -0
  139. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/integration/test_proxy_api_extended.py +0 -0
  140. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/integration/test_proxy_chat_completions.py +0 -0
  141. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/integration/test_proxy_compact.py +0 -0
  142. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/integration/test_proxy_responses.py +0 -0
  143. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/integration/test_proxy_sticky_sessions.py +0 -0
  144. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/integration/test_repositories.py +0 -0
  145. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/integration/test_request_decompression.py +0 -0
  146. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/integration/test_request_logs_api.py +0 -0
  147. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/integration/test_request_logs_filters.py +0 -0
  148. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/integration/test_settings_api.py +0 -0
  149. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/integration/test_usage_api.py +0 -0
  150. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/integration/test_usage_summary.py +0 -0
  151. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/integration/test_v1_models.py +0 -0
  152. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/test_request_logs_options_api.py +0 -0
  153. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/unit/test_auth.py +0 -0
  154. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/unit/test_auth_manager.py +0 -0
  155. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/unit/test_auth_refresh.py +0 -0
  156. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/unit/test_chat_request_mapping.py +0 -0
  157. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/unit/test_chat_response_mapping.py +0 -0
  158. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/unit/test_db_session.py +0 -0
  159. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/unit/test_load_balancer.py +0 -0
  160. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/unit/test_oauth_client.py +0 -0
  161. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/unit/test_openai_requests.py +0 -0
  162. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/unit/test_pricing.py +0 -0
  163. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/unit/test_proxy_errors.py +0 -0
  164. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/unit/test_proxy_utils.py +0 -0
  165. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/unit/test_request_logs_repository.py +0 -0
  166. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/unit/test_retry.py +0 -0
  167. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/unit/test_sse.py +0 -0
  168. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/unit/test_usage.py +0 -0
  169. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/unit/test_usage_client.py +0 -0
  170. {codex_lb-0.5.0 → codex_lb-0.5.1}/tests/unit/test_usage_updater.py +0 -0
  171. {codex_lb-0.5.0 → codex_lb-0.5.1}/uv.lock +0 -0
@@ -64,6 +64,17 @@
64
64
  "contributions": [
65
65
  "doc"
66
66
  ]
67
+ },
68
+ {
69
+ "login": "choi138",
70
+ "name": "Choi138",
71
+ "avatar_url": "https://avatars.githubusercontent.com/u/84369321?v=4",
72
+ "profile": "https://github.com/choi138",
73
+ "contributions": [
74
+ "code",
75
+ "bug",
76
+ "test"
77
+ ]
67
78
  }
68
79
  ],
69
80
  "contributorsPerLine": 7,
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.5.1"
3
+ }
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.1](https://github.com/Soju06/codex-lb/compare/v0.5.0...v0.5.1) (2026-02-03)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **core:** support gzip/deflate request decompression ([#49](https://github.com/Soju06/codex-lb/issues/49)) ([1db79aa](https://github.com/Soju06/codex-lb/commit/1db79aaef8d65af4b9246fad2b0687be17daba6b))
9
+
10
+
11
+ ### Documentation
12
+
13
+ * add choi138 as a contributor for code, bug, and test ([#50](https://github.com/Soju06/codex-lb/issues/50)) ([80d5aae](https://github.com/Soju06/codex-lb/commit/80d5aaefd5c61ea420fda90744e8ffda69eaecf6))
14
+
3
15
  ## [0.5.0](https://github.com/Soju06/codex-lb/compare/v0.4.0...v0.5.0) (2026-01-29)
4
16
 
5
17
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-lb
3
- Version: 0.5.0
3
+ Version: 0.5.1
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>
@@ -161,6 +161,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
161
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>
162
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>
163
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>
164
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/choi138"><img src="https://avatars.githubusercontent.com/u/84369321?v=4?s=100" width="100px;" alt="Choi138"/><br /><sub><b>Choi138</b></sub></a><br /><a href="https://github.com/Soju06/codex-lb/commits?author=choi138" title="Code">💻</a> <a href="https://github.com/Soju06/codex-lb/issues?q=author%3Achoi138" title="Bug reports">🐛</a> <a href="https://github.com/Soju06/codex-lb/commits?author=choi138" title="Tests">⚠️</a></td>
164
165
  </tr>
165
166
  </tbody>
166
167
  </table>
@@ -107,6 +107,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
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
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
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/choi138"><img src="https://avatars.githubusercontent.com/u/84369321?v=4?s=100" width="100px;" alt="Choi138"/><br /><sub><b>Choi138</b></sub></a><br /><a href="https://github.com/Soju06/codex-lb/commits?author=choi138" title="Code">💻</a> <a href="https://github.com/Soju06/codex-lb/issues?q=author%3Achoi138" title="Bug reports">🐛</a> <a href="https://github.com/Soju06/codex-lb/commits?author=choi138" title="Tests">⚠️</a></td>
110
111
  </tr>
111
112
  </tbody>
112
113
  </table>
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ import gzip
4
+ import io
5
+ import zlib
6
+ from collections.abc import Awaitable, Callable
7
+ from typing import Protocol
8
+
9
+ import zstandard as zstd
10
+ from fastapi import FastAPI, Request
11
+ from fastapi.responses import JSONResponse, Response
12
+
13
+ from app.core.config.settings import get_settings
14
+ from app.core.errors import dashboard_error
15
+
16
+
17
+ class _DecompressedBodyTooLarge(Exception):
18
+ def __init__(self, max_size: int) -> None:
19
+ super().__init__(f"Decompressed body exceeded {max_size} bytes")
20
+ self.max_size = max_size
21
+
22
+
23
+ class _Readable(Protocol):
24
+ def read(self, size: int = ...) -> bytes: ...
25
+
26
+
27
+ def _read_limited(reader: _Readable, max_size: int) -> bytes:
28
+ buffer = bytearray()
29
+ total = 0
30
+ chunk_size = 64 * 1024
31
+ while True:
32
+ chunk = reader.read(chunk_size)
33
+ if not chunk:
34
+ break
35
+ total += len(chunk)
36
+ if total > max_size:
37
+ raise _DecompressedBodyTooLarge(max_size)
38
+ buffer.extend(chunk)
39
+ return bytes(buffer)
40
+
41
+
42
+ def _decompress_gzip(data: bytes, max_size: int) -> bytes:
43
+ with gzip.GzipFile(fileobj=io.BytesIO(data)) as reader:
44
+ return _read_limited(reader, max_size)
45
+
46
+
47
+ def _decompress_deflate(data: bytes, max_size: int) -> bytes:
48
+ decompressor = zlib.decompressobj()
49
+ buffer = bytearray()
50
+ chunk_size = 64 * 1024
51
+ for start in range(0, len(data), chunk_size):
52
+ chunk = data[start : start + chunk_size]
53
+ # Bound output growth to avoid oversized allocations.
54
+ while chunk:
55
+ remaining = max_size - len(buffer)
56
+ if remaining == 0:
57
+ raise _DecompressedBodyTooLarge(max_size)
58
+ buffer.extend(decompressor.decompress(chunk, max_length=remaining))
59
+ chunk = decompressor.unconsumed_tail
60
+ while True:
61
+ remaining = max_size - len(buffer)
62
+ if remaining == 0:
63
+ raise _DecompressedBodyTooLarge(max_size)
64
+ drained = decompressor.decompress(b"", max_length=remaining)
65
+ if not drained:
66
+ break
67
+ buffer.extend(drained)
68
+ if not decompressor.eof:
69
+ raise zlib.error("Incomplete deflate stream")
70
+ return bytes(buffer)
71
+
72
+
73
+ def _decompress_zstd(data: bytes, max_size: int) -> bytes:
74
+ try:
75
+ decompressed = zstd.ZstdDecompressor().decompress(data, max_output_size=max_size)
76
+ if len(decompressed) > max_size:
77
+ raise _DecompressedBodyTooLarge(max_size)
78
+ return decompressed
79
+ except _DecompressedBodyTooLarge:
80
+ raise
81
+ except Exception:
82
+ with zstd.ZstdDecompressor().stream_reader(io.BytesIO(data)) as reader:
83
+ return _read_limited(reader, max_size)
84
+
85
+
86
+ def _decompress_body(data: bytes, encodings: list[str], max_size: int) -> bytes:
87
+ supported = {"zstd", "gzip", "deflate", "identity"}
88
+ if any(encoding not in supported for encoding in encodings):
89
+ raise ValueError("Unsupported content-encoding")
90
+ result = data
91
+ for encoding in reversed(encodings):
92
+ if encoding == "zstd":
93
+ result = _decompress_zstd(result, max_size)
94
+ elif encoding == "gzip":
95
+ result = _decompress_gzip(result, max_size)
96
+ elif encoding == "deflate":
97
+ result = _decompress_deflate(result, max_size)
98
+ elif encoding == "identity":
99
+ continue
100
+ return result
101
+
102
+
103
+ def _replace_request_body(request: Request, body: bytes) -> None:
104
+ request._body = body
105
+ headers: list[tuple[bytes, bytes]] = []
106
+ for key, value in request.scope.get("headers", []):
107
+ if key.lower() in (b"content-encoding", b"content-length"):
108
+ continue
109
+ headers.append((key, value))
110
+ headers.append((b"content-length", str(len(body)).encode("ascii")))
111
+ request.scope["headers"] = headers
112
+ # Ensure subsequent request.headers reflects the updated scope headers.
113
+ request._headers = None
114
+
115
+
116
+ def add_request_decompression_middleware(app: FastAPI) -> None:
117
+ @app.middleware("http")
118
+ async def request_decompression_middleware(
119
+ request: Request,
120
+ call_next: Callable[[Request], Awaitable[Response]],
121
+ ) -> Response:
122
+ content_encoding = request.headers.get("content-encoding")
123
+ if not content_encoding:
124
+ return await call_next(request)
125
+ encodings = [enc.strip().lower() for enc in content_encoding.split(",") if enc.strip()]
126
+ if not encodings:
127
+ return await call_next(request)
128
+ body = await request.body()
129
+ settings = get_settings()
130
+ max_size = settings.max_decompressed_body_bytes
131
+ try:
132
+ decompressed = _decompress_body(body, encodings, max_size)
133
+ except _DecompressedBodyTooLarge:
134
+ return JSONResponse(
135
+ status_code=413,
136
+ content=dashboard_error(
137
+ "payload_too_large",
138
+ "Request body exceeds the maximum allowed size",
139
+ ),
140
+ )
141
+ except ValueError:
142
+ return JSONResponse(
143
+ status_code=400,
144
+ content=dashboard_error(
145
+ "invalid_request",
146
+ "Unsupported Content-Encoding",
147
+ ),
148
+ )
149
+ except Exception:
150
+ return JSONResponse(
151
+ status_code=400,
152
+ content=dashboard_error(
153
+ "invalid_request",
154
+ "Request body is compressed but could not be decompressed",
155
+ ),
156
+ )
157
+ _replace_request_body(request, decompressed)
158
+ return await call_next(request)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codex-lb"
3
- version = "0.5.0"
3
+ version = "0.5.1"
4
4
  description = "Codex load balancer and proxy for ChatGPT accounts with usage dashboard"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ import gzip
4
+ import json
5
+ import zlib
6
+
7
+ import pytest
8
+ import zstandard as zstd
9
+ from fastapi import FastAPI, Request
10
+ from httpx import ASGITransport, AsyncClient
11
+
12
+ from app.core.middleware.request_decompression import add_request_decompression_middleware
13
+
14
+ pytestmark = pytest.mark.unit
15
+
16
+
17
+ def _build_echo_app(*, touch_headers: bool = False) -> FastAPI:
18
+ app = FastAPI()
19
+ add_request_decompression_middleware(app)
20
+
21
+ if touch_headers:
22
+
23
+ @app.middleware("http")
24
+ async def touch_headers_middleware(request: Request, call_next):
25
+ _ = request.headers.get("content-encoding")
26
+ return await call_next(request)
27
+
28
+ @app.post("/echo")
29
+ async def echo(request: Request):
30
+ data = await request.json()
31
+ return {"content_encoding": request.headers.get("content-encoding"), "data": data}
32
+
33
+ return app
34
+
35
+
36
+ @pytest.mark.asyncio
37
+ async def test_request_decompression_clears_cached_headers():
38
+ app = _build_echo_app(touch_headers=True)
39
+
40
+ payload = {"hello": "world"}
41
+ body = json.dumps(payload).encode("utf-8")
42
+ compressed = zstd.ZstdCompressor().compress(body)
43
+
44
+ transport = ASGITransport(app=app)
45
+ async with AsyncClient(transport=transport, base_url="http://testserver") as client:
46
+ resp = await client.post(
47
+ "/echo",
48
+ content=compressed,
49
+ headers={"Content-Encoding": "zstd", "Content-Type": "application/json"},
50
+ )
51
+
52
+ assert resp.status_code == 200
53
+ response_data = resp.json()
54
+ assert response_data["content_encoding"] is None
55
+ assert response_data["data"] == payload
56
+
57
+
58
+ @pytest.mark.asyncio
59
+ async def test_request_decompression_supports_gzip():
60
+ app = _build_echo_app()
61
+
62
+ payload = {"hello": "gzip"}
63
+ body = json.dumps(payload).encode("utf-8")
64
+ compressed = gzip.compress(body)
65
+
66
+ transport = ASGITransport(app=app)
67
+ async with AsyncClient(transport=transport, base_url="http://testserver") as client:
68
+ resp = await client.post(
69
+ "/echo",
70
+ content=compressed,
71
+ headers={"Content-Encoding": "gzip", "Content-Type": "application/json"},
72
+ )
73
+
74
+ assert resp.status_code == 200
75
+ response_data = resp.json()
76
+ assert response_data["content_encoding"] is None
77
+ assert response_data["data"] == payload
78
+
79
+
80
+ @pytest.mark.asyncio
81
+ async def test_request_decompression_supports_deflate():
82
+ app = _build_echo_app()
83
+
84
+ payload = {"hello": "deflate"}
85
+ body = json.dumps(payload).encode("utf-8")
86
+ compressed = zlib.compress(body)
87
+
88
+ transport = ASGITransport(app=app)
89
+ async with AsyncClient(transport=transport, base_url="http://testserver") as client:
90
+ resp = await client.post(
91
+ "/echo",
92
+ content=compressed,
93
+ headers={"Content-Encoding": "deflate", "Content-Type": "application/json"},
94
+ )
95
+
96
+ assert resp.status_code == 200
97
+ response_data = resp.json()
98
+ assert response_data["content_encoding"] is None
99
+ assert response_data["data"] == payload
100
+
101
+
102
+ @pytest.mark.asyncio
103
+ async def test_request_decompression_allows_identity():
104
+ app = _build_echo_app()
105
+
106
+ payload = {"hello": "identity"}
107
+ body = json.dumps(payload).encode("utf-8")
108
+
109
+ transport = ASGITransport(app=app)
110
+ async with AsyncClient(transport=transport, base_url="http://testserver") as client:
111
+ resp = await client.post(
112
+ "/echo",
113
+ content=body,
114
+ headers={"Content-Encoding": "identity", "Content-Type": "application/json"},
115
+ )
116
+
117
+ assert resp.status_code == 200
118
+ response_data = resp.json()
119
+ assert response_data["content_encoding"] is None
120
+ assert response_data["data"] == payload
121
+
122
+
123
+ @pytest.mark.asyncio
124
+ async def test_request_decompression_supports_multiple_encodings():
125
+ app = _build_echo_app()
126
+
127
+ payload = {"hello": "multi"}
128
+ body = json.dumps(payload).encode("utf-8")
129
+ gzip_body = gzip.compress(body)
130
+ compressed = zstd.ZstdCompressor().compress(gzip_body)
131
+
132
+ transport = ASGITransport(app=app)
133
+ async with AsyncClient(transport=transport, base_url="http://testserver") as client:
134
+ resp = await client.post(
135
+ "/echo",
136
+ content=compressed,
137
+ headers={"Content-Encoding": "gzip, zstd", "Content-Type": "application/json"},
138
+ )
139
+
140
+ assert resp.status_code == 200
141
+ response_data = resp.json()
142
+ assert response_data["content_encoding"] is None
143
+ assert response_data["data"] == payload
144
+
145
+
146
+ @pytest.mark.asyncio
147
+ async def test_request_decompression_rejects_unsupported_encoding():
148
+ app = _build_echo_app()
149
+
150
+ payload = {"hello": "br"}
151
+ body = json.dumps(payload).encode("utf-8")
152
+ compressed = gzip.compress(body)
153
+
154
+ transport = ASGITransport(app=app)
155
+ async with AsyncClient(transport=transport, base_url="http://testserver") as client:
156
+ resp = await client.post(
157
+ "/echo",
158
+ content=compressed,
159
+ headers={"Content-Encoding": "br", "Content-Type": "application/json"},
160
+ )
161
+
162
+ assert resp.status_code == 400
163
+ response_data = resp.json()
164
+ assert response_data["error"]["code"] == "invalid_request"
165
+ assert response_data["error"]["message"] == "Unsupported Content-Encoding"
@@ -1,3 +0,0 @@
1
- {
2
- ".": "0.5.0"
3
- }
@@ -1,101 +0,0 @@
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)
@@ -1,45 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
-
5
- import pytest
6
- import zstandard as zstd
7
- from fastapi import FastAPI, Request
8
- from httpx import ASGITransport, AsyncClient
9
-
10
- from app.core.middleware.request_decompression import add_request_decompression_middleware
11
-
12
- pytestmark = pytest.mark.unit
13
-
14
-
15
- @pytest.mark.asyncio
16
- async def test_request_decompression_clears_cached_headers():
17
- app = FastAPI()
18
- add_request_decompression_middleware(app)
19
-
20
- @app.middleware("http")
21
- async def touch_headers(request: Request, call_next):
22
- _ = request.headers.get("content-encoding")
23
- return await call_next(request)
24
-
25
- @app.post("/echo")
26
- async def echo(request: Request):
27
- data = await request.json()
28
- return {"content_encoding": request.headers.get("content-encoding"), "data": data}
29
-
30
- payload = {"hello": "world"}
31
- body = json.dumps(payload).encode("utf-8")
32
- compressed = zstd.ZstdCompressor().compress(body)
33
-
34
- transport = ASGITransport(app=app)
35
- async with AsyncClient(transport=transport, base_url="http://testserver") as client:
36
- resp = await client.post(
37
- "/echo",
38
- content=compressed,
39
- headers={"Content-Encoding": "zstd", "Content-Type": "application/json"},
40
- )
41
-
42
- assert resp.status_code == 200
43
- response_data = resp.json()
44
- assert response_data["content_encoding"] is None
45
- assert response_data["data"] == payload
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes