bardd commited on
Commit
260d3dd
·
verified ·
1 Parent(s): fa659b5

Upload 144 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +5 -0
  2. Dockerfile +49 -0
  3. logs/failures.log +10 -0
  4. logs/proxy.log +315 -0
  5. logs/proxy_debug.log +679 -0
  6. requirements.txt +27 -0
  7. src/batch_auth.py +40 -0
  8. src/proxy_app/LICENSE +21 -0
  9. src/proxy_app/__init__.py +3 -0
  10. src/proxy_app/batch_manager.py +84 -0
  11. src/proxy_app/build.py +95 -0
  12. src/proxy_app/detailed_logger.py +187 -0
  13. src/proxy_app/launcher_tui.py +1084 -0
  14. src/proxy_app/main.py +1731 -0
  15. src/proxy_app/model_filter_gui.py +0 -0
  16. src/proxy_app/provider_urls.py +76 -0
  17. src/proxy_app/quota_viewer.py +1596 -0
  18. src/proxy_app/quota_viewer_config.py +300 -0
  19. src/proxy_app/request_logger.py +34 -0
  20. src/proxy_app/settings_tool.py +0 -0
  21. src/rotator_library/COPYING +674 -0
  22. src/rotator_library/COPYING.LESSER +165 -0
  23. src/rotator_library/README.md +345 -0
  24. src/rotator_library/__init__.py +48 -0
  25. src/rotator_library/__pycache__/__init__.cpython-311.pyc +0 -0
  26. src/rotator_library/__pycache__/__init__.cpython-314.pyc +0 -0
  27. src/rotator_library/__pycache__/background_refresher.cpython-311.pyc +0 -0
  28. src/rotator_library/__pycache__/client.cpython-311.pyc +3 -0
  29. src/rotator_library/__pycache__/client.cpython-314.pyc +3 -0
  30. src/rotator_library/__pycache__/cooldown_manager.cpython-311.pyc +0 -0
  31. src/rotator_library/__pycache__/credential_manager.cpython-311.pyc +0 -0
  32. src/rotator_library/__pycache__/credential_tool.cpython-311.pyc +3 -0
  33. src/rotator_library/__pycache__/error_handler.cpython-311.pyc +0 -0
  34. src/rotator_library/__pycache__/failure_logger.cpython-311.pyc +0 -0
  35. src/rotator_library/__pycache__/litellm_providers.cpython-311.pyc +0 -0
  36. src/rotator_library/__pycache__/model_definitions.cpython-311.pyc +0 -0
  37. src/rotator_library/__pycache__/provider_config.cpython-311.pyc +0 -0
  38. src/rotator_library/__pycache__/provider_factory.cpython-311.pyc +0 -0
  39. src/rotator_library/__pycache__/request_sanitizer.cpython-311.pyc +0 -0
  40. src/rotator_library/__pycache__/timeout_config.cpython-311.pyc +0 -0
  41. src/rotator_library/__pycache__/transaction_logger.cpython-311.pyc +0 -0
  42. src/rotator_library/__pycache__/usage_manager.cpython-311.pyc +3 -0
  43. src/rotator_library/anthropic_compat/__init__.py +70 -0
  44. src/rotator_library/anthropic_compat/models.py +147 -0
  45. src/rotator_library/anthropic_compat/streaming.py +433 -0
  46. src/rotator_library/anthropic_compat/translator.py +629 -0
  47. src/rotator_library/background_refresher.py +289 -0
  48. src/rotator_library/client.py +0 -0
  49. src/rotator_library/config/__init__.py +60 -0
  50. src/rotator_library/config/__pycache__/__init__.cpython-311.pyc +0 -0
.gitattributes CHANGED
@@ -33,3 +33,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ src/rotator_library/__pycache__/client.cpython-311.pyc filter=lfs diff=lfs merge=lfs -text
37
+ src/rotator_library/__pycache__/client.cpython-314.pyc filter=lfs diff=lfs merge=lfs -text
38
+ src/rotator_library/__pycache__/credential_tool.cpython-311.pyc filter=lfs diff=lfs merge=lfs -text
39
+ src/rotator_library/__pycache__/usage_manager.cpython-311.pyc filter=lfs diff=lfs merge=lfs -text
40
+ src/rotator_library/providers/__pycache__/antigravity_provider.cpython-311.pyc filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build stage
2
+ FROM python:3.11-slim AS builder
3
+
4
+ WORKDIR /app
5
+
6
+ # Install build dependencies
7
+ RUN apt-get update && apt-get install -y --no-install-recommends \
8
+ gcc \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ # Set PATH for user-installed packages in builder stage
12
+ ENV PATH=/root/.local/bin:$PATH
13
+
14
+ # Copy requirements first for better caching
15
+ COPY requirements.txt .
16
+
17
+ # Copy the local rotator_library for editable install
18
+ COPY src/rotator_library ./src/rotator_library
19
+
20
+ # Install dependencies
21
+ RUN pip install --no-cache-dir --user -r requirements.txt
22
+
23
+ # Production stage
24
+ FROM python:3.11-slim
25
+
26
+ WORKDIR /app
27
+
28
+ # Copy installed packages from builder
29
+ COPY --from=builder /root/.local /root/.local
30
+
31
+ # Make sure scripts in .local are usable
32
+ ENV PATH=/root/.local/bin:$PATH
33
+
34
+ # Copy application code
35
+ COPY src/ ./src/
36
+
37
+ # Create directories for logs and oauth credentials
38
+ RUN mkdir -p logs oauth_creds
39
+
40
+ # Expose the default Hugging Face port
41
+ EXPOSE 7860
42
+
43
+ # Set environment variables
44
+ ENV PYTHONUNBUFFERED=1
45
+ ENV PYTHONDONTWRITEBYTECODE=1
46
+ ENV PYTHONPATH=/app/src
47
+
48
+ # Default command - runs proxy on HF's expected port
49
+ CMD ["python", "src/proxy_app/main.py", "--host", "0.0.0.0", "--port", "7860"]
logs/failures.log ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {"timestamp": "2026-01-23T15:44:48.444258", "api_key_ending": "antigravity_oauth_1.json", "model": "antigravity/gemini-2.0-flash-exp", "attempt_number": 1, "error_type": "TransientQuotaError", "error_message": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again.", "raw_response": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again.", "request_headers": {"host": "localhost:8000", "user-agent": "curl/8.5.0", "accept": "*/*", "content-type": "application/json", "authorization": "Bearer sk-antigravity-proxy-123", "content-length": "114"}, "error_chain": [{"type": "TransientQuotaError", "message": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again."}, {"type": "HTTPStatusError", "message": "Client error '429 Too Many Requests' for url 'https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse'\nFor more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429"}]}
2
+ {"timestamp": "2026-01-23T15:45:05.739946", "api_key_ending": "antigravity_oauth_2.json", "model": "antigravity/gemini-2.0-flash-exp", "attempt_number": 1, "error_type": "TransientQuotaError", "error_message": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again.", "raw_response": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again.", "request_headers": {"host": "localhost:8000", "user-agent": "curl/8.5.0", "accept": "*/*", "content-type": "application/json", "authorization": "Bearer sk-antigravity-proxy-123", "content-length": "114"}, "error_chain": [{"type": "TransientQuotaError", "message": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again."}, {"type": "HTTPStatusError", "message": "Client error '429 Too Many Requests' for url 'https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse'\nFor more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429"}]}
3
+ {"timestamp": "2026-01-23T15:45:37.861148", "api_key_ending": "antigravity_oauth_1.json", "model": "antigravity/gemini-1.5-pro", "attempt_number": 1, "error_type": "TransientQuotaError", "error_message": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again.", "raw_response": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again.", "request_headers": {"host": "localhost:8000", "user-agent": "curl/8.5.0", "accept": "*/*", "content-type": "application/json", "authorization": "Bearer sk-antigravity-proxy-123", "content-length": "104"}, "error_chain": [{"type": "TransientQuotaError", "message": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again."}, {"type": "HTTPStatusError", "message": "Client error '429 Too Many Requests' for url 'https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse'\nFor more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429"}]}
4
+ {"timestamp": "2026-01-23T15:45:54.037894", "api_key_ending": "antigravity_oauth_2.json", "model": "antigravity/gemini-1.5-pro", "attempt_number": 1, "error_type": "TransientQuotaError", "error_message": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again.", "raw_response": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again.", "request_headers": {"host": "localhost:8000", "user-agent": "curl/8.5.0", "accept": "*/*", "content-type": "application/json", "authorization": "Bearer sk-antigravity-proxy-123", "content-length": "104"}, "error_chain": [{"type": "TransientQuotaError", "message": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again."}, {"type": "HTTPStatusError", "message": "Client error '429 Too Many Requests' for url 'https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse'\nFor more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429"}]}
5
+ {"timestamp": "2026-01-23T15:56:41.160374", "api_key_ending": "antigravity_oauth_1.json", "model": "antigravity/gemini-2.0-flash-exp", "attempt_number": 1, "error_type": "TransientQuotaError", "error_message": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again.", "raw_response": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again.", "request_headers": {"host": "localhost:8000", "user-agent": "curl/8.5.0", "accept": "*/*", "content-type": "application/json", "authorization": "Bearer sk-antigravity-proxy-123", "content-length": "195"}, "error_chain": [{"type": "TransientQuotaError", "message": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again."}, {"type": "HTTPStatusError", "message": "Client error '429 Too Many Requests' for url 'https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse'\nFor more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429"}]}
6
+ {"timestamp": "2026-01-23T15:56:58.233365", "api_key_ending": "antigravity_oauth_2.json", "model": "antigravity/gemini-2.0-flash-exp", "attempt_number": 1, "error_type": "TransientQuotaError", "error_message": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again.", "raw_response": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again.", "request_headers": {"host": "localhost:8000", "user-agent": "curl/8.5.0", "accept": "*/*", "content-type": "application/json", "authorization": "Bearer sk-antigravity-proxy-123", "content-length": "195"}, "error_chain": [{"type": "TransientQuotaError", "message": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again."}, {"type": "HTTPStatusError", "message": "Client error '429 Too Many Requests' for url 'https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse'\nFor more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429"}]}
7
+ {"timestamp": "2026-01-23T15:59:24.448647", "api_key_ending": "antigravity_oauth_2.json", "model": "antigravity/gemini-3-flash", "attempt_number": 1, "error_type": "TransientQuotaError", "error_message": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again.", "raw_response": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again.", "request_headers": {"host": "localhost:8000", "user-agent": "curl/8.18.0", "accept": "*/*", "content-type": "application/json", "authorization": "Bearer sk-antigravity-proxy-123", "content-length": "165"}, "error_chain": [{"type": "TransientQuotaError", "message": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again."}, {"type": "HTTPStatusError", "message": "Client error '429 Too Many Requests' for url 'https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse'\nFor more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429"}]}
8
+ {"timestamp": "2026-01-23T15:59:40.446459", "api_key_ending": "antigravity_oauth_1.json", "model": "antigravity/gemini-3-flash", "attempt_number": 1, "error_type": "TransientQuotaError", "error_message": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again.", "raw_response": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again.", "request_headers": {"host": "localhost:8000", "user-agent": "curl/8.18.0", "accept": "*/*", "content-type": "application/json", "authorization": "Bearer sk-antigravity-proxy-123", "content-length": "165"}, "error_chain": [{"type": "TransientQuotaError", "message": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again."}, {"type": "HTTPStatusError", "message": "Client error '429 Too Many Requests' for url 'https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse'\nFor more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429"}]}
9
+ {"timestamp": "2026-01-23T16:01:26.088546", "api_key_ending": "antigravity_oauth_2.json", "model": "antigravity/gemini-2.5-flash", "attempt_number": 1, "error_type": "TransientQuotaError", "error_message": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again.", "raw_response": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again.", "request_headers": {"host": "localhost:8000", "user-agent": "curl/8.18.0", "accept": "*/*", "content-type": "application/json", "authorization": "Bearer sk-antigravity-proxy-123", "content-length": "173"}, "error_chain": [{"type": "TransientQuotaError", "message": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again."}, {"type": "HTTPStatusError", "message": "Client error '429 Too Many Requests' for url 'https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse'\nFor more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429"}]}
10
+ {"timestamp": "2026-01-23T16:01:43.155177", "api_key_ending": "antigravity_oauth_1.json", "model": "antigravity/gemini-2.5-flash", "attempt_number": 1, "error_type": "TransientQuotaError", "error_message": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again.", "raw_response": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again.", "request_headers": {"host": "localhost:8000", "user-agent": "curl/8.18.0", "accept": "*/*", "content-type": "application/json", "authorization": "Bearer sk-antigravity-proxy-123", "content-length": "173"}, "error_chain": [{"type": "TransientQuotaError", "message": "The model returned transient 429 errors after multiple attempts. This may indicate a temporary service issue. Please try again."}, {"type": "HTTPStatusError", "message": "Client error '429 Too Many Requests' for url 'https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse'\nFor more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429"}]}
logs/proxy.log ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 2026-01-23 15:44:10,808 - rotator_library - INFO - Provider 'antigravity' using rotation mode: sequential
2
+ 2026-01-23 15:44:10,809 - rotator_library - INFO - Provider 'antigravity' priority multipliers: {1: 5, 2: 3}
3
+ 2026-01-23 15:44:10,809 - rotator_library - INFO - Provider 'antigravity' sequential fallback multiplier: 2x
4
+ 2026-01-23 15:44:10,819 - rotator_library - INFO - Background token refresher started. Check interval: 600 seconds.
5
+ 2026-01-23 15:44:10,819 - root - INFO - RotatingClient initialized (EmbeddingBatcher disabled).
6
+ 2026-01-23 15:44:10,820 - rotator_library.model_info_service - INFO - ModelRegistry started (refresh every 21600s)
7
+ 2026-01-23 15:44:10,820 - root - INFO - Model info service started (fetching pricing data in background).
8
+ 2026-01-23 15:44:10,822 - rotator_library - INFO - Providers initialized: 1 providers, 2 credentials
9
+ 2026-01-23 15:44:10,823 - rotator_library - INFO - OAuth: antigravity:2 (standard-tier:2)
10
+ 2026-01-23 15:44:10,823 - rotator_library - INFO - Started antigravity antigravity_quota_refresh (interval: 300s)
11
+ 2026-01-23 15:44:10,825 - rotator_library - INFO - antigravity: Fetching initial quota baselines for 2 credentials...
12
+ 2026-01-23 15:44:11,651 - rotator_library.model_info_service - INFO - OpenRouter: 345 models loaded
13
+ 2026-01-23 15:44:11,651 - rotator_library.model_info_service - INFO - Models.dev: 2255 models loaded
14
+ 2026-01-23 15:44:28,311 - root - INFO - 15:44 - 172.17.0.1:42688 - provider: antigravity, model: gemini-2.0-flash-exp - N/A
15
+ 2026-01-23 15:44:28,312 - rotator_library - INFO - Acquiring key for model antigravity/gemini-2.0-flash-exp. Tried keys: 0/2(2)
16
+ 2026-01-23 15:44:28,317 - rotator_library - INFO - Acquired key antigravity_oauth_1.json for model antigravity/gemini-2.0-flash-exp (tier: standard-tier, priority: 2, selection: sequential, quota: 0)
17
+ 2026-01-23 15:44:28,318 - rotator_library - INFO - Attempting call with credential antigravity_oauth_1.json (Attempt 1/2)
18
+ 2026-01-23 15:44:29,652 - rotator_library - INFO - Switching to fallback URL: https://daily-cloudcode-pa.googleapis.com/v1internal
19
+ 2026-01-23 15:44:29,652 - rotator_library - WARNING - Retrying with fallback URL: Client error '404 Not Found' for url 'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:streamGenerateContent?alt=sse'
20
+ For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404
21
+ 2026-01-23 15:44:30,953 - rotator_library - INFO - Switching to fallback URL: https://cloudcode-pa.googleapis.com/v1internal
22
+ 2026-01-23 15:44:30,954 - rotator_library - WARNING - Retrying with fallback URL: Client error '404 Not Found' for url 'https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse'
23
+ For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404
24
+ 2026-01-23 15:44:31,958 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 1/6. Retrying...
25
+ 2026-01-23 15:44:35,483 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 2/6. Retrying...
26
+ 2026-01-23 15:44:39,025 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 3/6. Retrying...
27
+ 2026-01-23 15:44:42,173 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 4/6. Retrying...
28
+ 2026-01-23 15:44:45,306 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 5/6. Retrying...
29
+ 2026-01-23 15:44:48,445 - rotator_library - ERROR - API call failed for model antigravity/gemini-2.0-flash-exp with key antigravity_oauth_1.json. Error: TransientQuotaError. See failures.log for details.
30
+ 2026-01-23 15:44:48,445 - rotator_library - WARNING - Cred antigravity_oauth_1.json server_error (HTTP 503).
31
+ 2026-01-23 15:44:48,446 - rotator_library - INFO - Provider-level error (server_error) for key antigravity_oauth_1.json with model antigravity/gemini-2.0-flash-exp. NOT incrementing failures. Cooldown: 30s
32
+ 2026-01-23 15:44:48,447 - rotator_library - INFO - Released credential antigravity_oauth_1.json from model antigravity/gemini-2.0-flash-exp (remaining concurrent: 0)
33
+ 2026-01-23 15:44:48,447 - rotator_library - INFO - Acquiring key for model antigravity/gemini-2.0-flash-exp. Tried keys: 1/1(2)
34
+ 2026-01-23 15:44:48,448 - rotator_library - INFO - Acquired key antigravity_oauth_2.json for model antigravity/gemini-2.0-flash-exp (tier: standard-tier, priority: 2, selection: sequential, quota: 0)
35
+ 2026-01-23 15:44:48,448 - rotator_library - INFO - Attempting call with credential antigravity_oauth_2.json (Attempt 1/2)
36
+ 2026-01-23 15:44:49,270 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 1/6. Retrying...
37
+ 2026-01-23 15:44:52,782 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 2/6. Retrying...
38
+ 2026-01-23 15:44:56,295 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 3/6. Retrying...
39
+ 2026-01-23 15:44:59,436 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 4/6. Retrying...
40
+ 2026-01-23 15:45:02,593 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 5/6. Retrying...
41
+ 2026-01-23 15:45:05,740 - rotator_library - ERROR - API call failed for model antigravity/gemini-2.0-flash-exp with key antigravity_oauth_2.json. Error: TransientQuotaError. See failures.log for details.
42
+ 2026-01-23 15:45:05,740 - rotator_library - WARNING - Cred antigravity_oauth_2.json server_error (HTTP 503).
43
+ 2026-01-23 15:45:05,740 - rotator_library - INFO - Provider-level error (server_error) for key antigravity_oauth_2.json with model antigravity/gemini-2.0-flash-exp. NOT incrementing failures. Cooldown: 30s
44
+ 2026-01-23 15:45:05,741 - rotator_library - INFO - Released credential antigravity_oauth_2.json from model antigravity/gemini-2.0-flash-exp (remaining concurrent: 0)
45
+ 2026-01-23 15:45:05,742 - rotator_library - ERROR - TIMEOUT: 2 creds tried for antigravity/gemini-2.0-flash-exp | Normal: 2 server_error
46
+ 2026-01-23 15:45:21,583 - root - INFO - 15:45 - 172.17.0.1:53018 - provider: antigravity, model: gemini-1.5-pro - N/A
47
+ 2026-01-23 15:45:21,584 - rotator_library - INFO - Acquiring key for model antigravity/gemini-1.5-pro. Tried keys: 0/2(2)
48
+ 2026-01-23 15:45:21,586 - rotator_library - INFO - Acquired key antigravity_oauth_1.json for model antigravity/gemini-1.5-pro (tier: standard-tier, priority: 2, selection: sequential, quota: 0)
49
+ 2026-01-23 15:45:21,586 - rotator_library - INFO - Attempting call with credential antigravity_oauth_1.json (Attempt 1/2)
50
+ 2026-01-23 15:45:22,152 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-1.5-pro, attempt 1/6. Retrying...
51
+ 2026-01-23 15:45:25,281 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-1.5-pro, attempt 2/6. Retrying...
52
+ 2026-01-23 15:45:28,411 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-1.5-pro, attempt 3/6. Retrying...
53
+ 2026-01-23 15:45:31,562 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-1.5-pro, attempt 4/6. Retrying...
54
+ 2026-01-23 15:45:34,708 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-1.5-pro, attempt 5/6. Retrying...
55
+ 2026-01-23 15:45:37,861 - rotator_library - ERROR - API call failed for model antigravity/gemini-1.5-pro with key antigravity_oauth_1.json. Error: TransientQuotaError. See failures.log for details.
56
+ 2026-01-23 15:45:37,862 - rotator_library - WARNING - Cred antigravity_oauth_1.json server_error (HTTP 503).
57
+ 2026-01-23 15:45:37,862 - rotator_library - INFO - Provider-level error (server_error) for key antigravity_oauth_1.json with model antigravity/gemini-1.5-pro. NOT incrementing failures. Cooldown: 30s
58
+ 2026-01-23 15:45:37,863 - rotator_library - INFO - Released credential antigravity_oauth_1.json from model antigravity/gemini-1.5-pro (remaining concurrent: 0)
59
+ 2026-01-23 15:45:37,864 - rotator_library - INFO - Acquiring key for model antigravity/gemini-1.5-pro. Tried keys: 1/1(2)
60
+ 2026-01-23 15:45:37,865 - rotator_library - INFO - Acquired key antigravity_oauth_2.json for model antigravity/gemini-1.5-pro (tier: standard-tier, priority: 2, selection: sequential, quota: 0)
61
+ 2026-01-23 15:45:37,866 - rotator_library - INFO - Attempting call with credential antigravity_oauth_2.json (Attempt 1/2)
62
+ 2026-01-23 15:45:38,007 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-1.5-pro, attempt 1/6. Retrying...
63
+ 2026-01-23 15:45:41,153 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-1.5-pro, attempt 2/6. Retrying...
64
+ 2026-01-23 15:45:44,308 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-1.5-pro, attempt 3/6. Retrying...
65
+ 2026-01-23 15:45:47,477 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-1.5-pro, attempt 4/6. Retrying...
66
+ 2026-01-23 15:45:50,630 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-1.5-pro, attempt 5/6. Retrying...
67
+ 2026-01-23 15:45:54,038 - rotator_library - ERROR - API call failed for model antigravity/gemini-1.5-pro with key antigravity_oauth_2.json. Error: TransientQuotaError. See failures.log for details.
68
+ 2026-01-23 15:45:54,038 - rotator_library - WARNING - Cred antigravity_oauth_2.json server_error (HTTP 503).
69
+ 2026-01-23 15:45:54,039 - rotator_library - INFO - Provider-level error (server_error) for key antigravity_oauth_2.json with model antigravity/gemini-1.5-pro. NOT incrementing failures. Cooldown: 30s
70
+ 2026-01-23 15:45:54,041 - rotator_library - INFO - Released credential antigravity_oauth_2.json from model antigravity/gemini-1.5-pro (remaining concurrent: 0)
71
+ 2026-01-23 15:45:54,041 - rotator_library - ERROR - TIMEOUT: 2 creds tried for antigravity/gemini-1.5-pro | Normal: 2 server_error
72
+ 2026-01-23 15:56:23,674 - root - INFO - 15:56 - 172.17.0.1:41590 - provider: antigravity, model: gemini-2.0-flash-exp - N/A
73
+ 2026-01-23 15:56:23,675 - rotator_library - INFO - Acquiring key for model antigravity/gemini-2.0-flash-exp. Tried keys: 0/2(2)
74
+ 2026-01-23 15:56:23,677 - rotator_library - INFO - Acquired key antigravity_oauth_1.json for model antigravity/gemini-2.0-flash-exp (tier: standard-tier, priority: 2, selection: sequential, quota: 1)
75
+ 2026-01-23 15:56:23,677 - rotator_library - INFO - Attempting call with credential antigravity_oauth_1.json (Attempt 1/2)
76
+ 2026-01-23 15:56:24,692 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 1/6. Retrying...
77
+ 2026-01-23 15:56:28,214 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 2/6. Retrying...
78
+ 2026-01-23 15:56:31,724 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 3/6. Retrying...
79
+ 2026-01-23 15:56:34,859 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 4/6. Retrying...
80
+ 2026-01-23 15:56:38,005 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 5/6. Retrying...
81
+ 2026-01-23 15:56:41,161 - rotator_library - ERROR - API call failed for model antigravity/gemini-2.0-flash-exp with key antigravity_oauth_1.json. Error: TransientQuotaError. See failures.log for details.
82
+ 2026-01-23 15:56:41,161 - rotator_library - WARNING - Cred antigravity_oauth_1.json server_error (HTTP 503).
83
+ 2026-01-23 15:56:41,162 - rotator_library - INFO - Provider-level error (server_error) for key antigravity_oauth_1.json with model antigravity/gemini-2.0-flash-exp. NOT incrementing failures. Cooldown: 30s
84
+ 2026-01-23 15:56:41,163 - rotator_library - INFO - Released credential antigravity_oauth_1.json from model antigravity/gemini-2.0-flash-exp (remaining concurrent: 0)
85
+ 2026-01-23 15:56:41,163 - rotator_library - INFO - Acquiring key for model antigravity/gemini-2.0-flash-exp. Tried keys: 1/1(2)
86
+ 2026-01-23 15:56:41,164 - rotator_library - INFO - Acquired key antigravity_oauth_2.json for model antigravity/gemini-2.0-flash-exp (tier: standard-tier, priority: 2, selection: sequential, quota: 1)
87
+ 2026-01-23 15:56:41,164 - rotator_library - INFO - Attempting call with credential antigravity_oauth_2.json (Attempt 1/2)
88
+ 2026-01-23 15:56:41,758 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 1/6. Retrying...
89
+ 2026-01-23 15:56:45,275 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 2/6. Retrying...
90
+ 2026-01-23 15:56:48,810 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 3/6. Retrying...
91
+ 2026-01-23 15:56:51,963 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 4/6. Retrying...
92
+ 2026-01-23 15:56:55,088 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 5/6. Retrying...
93
+ 2026-01-23 15:56:58,233 - rotator_library - ERROR - API call failed for model antigravity/gemini-2.0-flash-exp with key antigravity_oauth_2.json. Error: TransientQuotaError. See failures.log for details.
94
+ 2026-01-23 15:56:58,234 - rotator_library - WARNING - Cred antigravity_oauth_2.json server_error (HTTP 503).
95
+ 2026-01-23 15:56:58,234 - rotator_library - INFO - Provider-level error (server_error) for key antigravity_oauth_2.json with model antigravity/gemini-2.0-flash-exp. NOT incrementing failures. Cooldown: 30s
96
+ 2026-01-23 15:56:58,236 - rotator_library - INFO - Released credential antigravity_oauth_2.json from model antigravity/gemini-2.0-flash-exp (remaining concurrent: 0)
97
+ 2026-01-23 15:56:58,236 - rotator_library - ERROR - TIMEOUT: 2 creds tried for antigravity/gemini-2.0-flash-exp | Normal: 2 server_error
98
+ 2026-01-23 15:58:31,358 - rotator_library - INFO - Getting all available models...
99
+ 2026-01-23 15:58:31,358 - rotator_library - INFO - Getting available models for provider: antigravity
100
+ 2026-01-23 15:58:31,359 - rotator_library - INFO - Got 6 models for provider: antigravity
101
+ 2026-01-23 15:58:31,360 - rotator_library - INFO - Finished getting all available models.
102
+ 2026-01-23 15:59:07,976 - root - INFO - 15:59 - 172.17.0.1:41760 - provider: antigravity, model: gemini-3-flash - N/A
103
+ 2026-01-23 15:59:07,976 - rotator_library - INFO - Acquiring key for model antigravity/gemini-3-flash. Tried keys: 0/2(2)
104
+ 2026-01-23 15:59:07,978 - rotator_library - INFO - Acquired key antigravity_oauth_2.json for model antigravity/gemini-3-flash (tier: standard-tier, priority: 2, selection: sequential, quota: 79/400 [80%])
105
+ 2026-01-23 15:59:07,978 - rotator_library - INFO - Attempting call with credential antigravity_oauth_2.json (Attempt 1/2)
106
+ 2026-01-23 15:59:08,623 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-3-flash, attempt 1/6. Retrying...
107
+ 2026-01-23 15:59:11,803 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-3-flash, attempt 2/6. Retrying...
108
+ 2026-01-23 15:59:15,012 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-3-flash, attempt 3/6. Retrying...
109
+ 2026-01-23 15:59:18,150 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-3-flash, attempt 4/6. Retrying...
110
+ 2026-01-23 15:59:21,293 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-3-flash, attempt 5/6. Retrying...
111
+ 2026-01-23 15:59:24,449 - rotator_library - ERROR - API call failed for model antigravity/gemini-3-flash with key antigravity_oauth_2.json. Error: TransientQuotaError. See failures.log for details.
112
+ 2026-01-23 15:59:24,449 - rotator_library - WARNING - Cred antigravity_oauth_2.json server_error (HTTP 503).
113
+ 2026-01-23 15:59:24,450 - rotator_library - INFO - Provider-level error (server_error) for key antigravity_oauth_2.json with model antigravity/gemini-3-flash. NOT incrementing failures. Cooldown: 30s
114
+ 2026-01-23 15:59:24,451 - rotator_library - INFO - Released credential antigravity_oauth_2.json from model antigravity/gemini-3-flash (remaining concurrent: 0)
115
+ 2026-01-23 15:59:24,452 - rotator_library - INFO - Acquiring key for model antigravity/gemini-3-flash. Tried keys: 1/1(2)
116
+ 2026-01-23 15:59:24,453 - rotator_library - INFO - Acquired key antigravity_oauth_1.json for model antigravity/gemini-3-flash (tier: standard-tier, priority: 2, selection: sequential, quota: 0/400 [100%])
117
+ 2026-01-23 15:59:24,453 - rotator_library - INFO - Attempting call with credential antigravity_oauth_1.json (Attempt 1/2)
118
+ 2026-01-23 15:59:24,658 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-3-flash, attempt 1/6. Retrying...
119
+ 2026-01-23 15:59:27,819 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-3-flash, attempt 2/6. Retrying...
120
+ 2026-01-23 15:59:31,009 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-3-flash, attempt 3/6. Retrying...
121
+ 2026-01-23 15:59:34,150 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-3-flash, attempt 4/6. Retrying...
122
+ 2026-01-23 15:59:37,303 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-3-flash, attempt 5/6. Retrying...
123
+ 2026-01-23 15:59:40,447 - rotator_library - ERROR - API call failed for model antigravity/gemini-3-flash with key antigravity_oauth_1.json. Error: TransientQuotaError. See failures.log for details.
124
+ 2026-01-23 15:59:40,447 - rotator_library - WARNING - Cred antigravity_oauth_1.json server_error (HTTP 503).
125
+ 2026-01-23 15:59:40,448 - rotator_library - INFO - Provider-level error (server_error) for key antigravity_oauth_1.json with model antigravity/gemini-3-flash. NOT incrementing failures. Cooldown: 30s
126
+ 2026-01-23 15:59:40,450 - rotator_library - INFO - Released credential antigravity_oauth_1.json from model antigravity/gemini-3-flash (remaining concurrent: 0)
127
+ 2026-01-23 15:59:40,450 - rotator_library - ERROR - TIMEOUT: 2 creds tried for antigravity/gemini-3-flash | Normal: 2 server_error
128
+ 2026-01-23 16:00:10,954 - rotator_library - INFO - Getting all available models...
129
+ 2026-01-23 16:00:10,954 - rotator_library - INFO - Getting available models for provider: antigravity
130
+ 2026-01-23 16:00:10,955 - rotator_library - INFO - Finished getting all available models.
131
+ 2026-01-23 16:00:21,200 - rotator_library - INFO - Getting all available models...
132
+ 2026-01-23 16:00:21,201 - rotator_library - INFO - Getting available models for provider: antigravity
133
+ 2026-01-23 16:00:21,201 - rotator_library - INFO - Finished getting all available models.
134
+ 2026-01-23 16:01:08,173 - root - INFO - 16:01 - 172.17.0.1:39582 - provider: antigravity, model: gemini-2.5-flash - N/A
135
+ 2026-01-23 16:01:08,174 - rotator_library - INFO - Acquiring key for model antigravity/gemini-2.5-flash. Tried keys: 0/2(2)
136
+ 2026-01-23 16:01:08,175 - rotator_library - INFO - Acquired key antigravity_oauth_2.json for model antigravity/gemini-2.5-flash (tier: standard-tier, priority: 2, selection: sequential, quota: 599/3000 [80%])
137
+ 2026-01-23 16:01:08,176 - rotator_library - INFO - Attempting call with credential antigravity_oauth_2.json (Attempt 1/2)
138
+ 2026-01-23 16:01:08,892 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.5-flash, attempt 1/6. Retrying...
139
+ 2026-01-23 16:01:12,675 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.5-flash, attempt 2/6. Retrying...
140
+ 2026-01-23 16:01:15,849 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.5-flash, attempt 3/6. Retrying...
141
+ 2026-01-23 16:01:19,004 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.5-flash, attempt 4/6. Retrying...
142
+ 2026-01-23 16:01:22,584 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.5-flash, attempt 5/6. Retrying...
143
+ 2026-01-23 16:01:26,089 - rotator_library - ERROR - API call failed for model antigravity/gemini-2.5-flash with key antigravity_oauth_2.json. Error: TransientQuotaError. See failures.log for details.
144
+ 2026-01-23 16:01:26,089 - rotator_library - WARNING - Cred antigravity_oauth_2.json server_error (HTTP 503).
145
+ 2026-01-23 16:01:26,089 - rotator_library - INFO - Provider-level error (server_error) for key antigravity_oauth_2.json with model antigravity/gemini-2.5-flash. NOT incrementing failures. Cooldown: 30s
146
+ 2026-01-23 16:01:26,090 - rotator_library - INFO - Released credential antigravity_oauth_2.json from model antigravity/gemini-2.5-flash (remaining concurrent: 0)
147
+ 2026-01-23 16:01:26,091 - rotator_library - INFO - Acquiring key for model antigravity/gemini-2.5-flash. Tried keys: 1/1(2)
148
+ 2026-01-23 16:01:26,091 - rotator_library - INFO - Acquired key antigravity_oauth_1.json for model antigravity/gemini-2.5-flash (tier: standard-tier, priority: 2, selection: sequential, quota: 0/3000 [100%])
149
+ 2026-01-23 16:01:26,092 - rotator_library - INFO - Attempting call with credential antigravity_oauth_1.json (Attempt 1/2)
150
+ 2026-01-23 16:01:26,606 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.5-flash, attempt 1/6. Retrying...
151
+ 2026-01-23 16:01:30,115 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.5-flash, attempt 2/6. Retrying...
152
+ 2026-01-23 16:01:33,311 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.5-flash, attempt 3/6. Retrying...
153
+ 2026-01-23 16:01:36,840 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.5-flash, attempt 4/6. Retrying...
154
+ 2026-01-23 16:01:39,989 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.5-flash, attempt 5/6. Retrying...
155
+ 2026-01-23 16:01:43,155 - rotator_library - ERROR - API call failed for model antigravity/gemini-2.5-flash with key antigravity_oauth_1.json. Error: TransientQuotaError. See failures.log for details.
156
+ 2026-01-23 16:01:43,156 - rotator_library - WARNING - Cred antigravity_oauth_1.json server_error (HTTP 503).
157
+ 2026-01-23 16:01:43,156 - rotator_library - INFO - Provider-level error (server_error) for key antigravity_oauth_1.json with model antigravity/gemini-2.5-flash. NOT incrementing failures. Cooldown: 30s
158
+ 2026-01-23 16:01:43,157 - rotator_library - INFO - Released credential antigravity_oauth_1.json from model antigravity/gemini-2.5-flash (remaining concurrent: 0)
159
+ 2026-01-23 16:01:43,157 - rotator_library - ERROR - TIMEOUT: 2 creds tried for antigravity/gemini-2.5-flash | Normal: 2 server_error
160
+ 2026-01-23 16:02:46,116 - rotator_library - INFO - Background token refresher stopped.
161
+ 2026-01-23 16:02:46,116 - rotator_library.model_info_service - INFO - ModelRegistry stopped
162
+ 2026-01-23 16:02:46,116 - root - INFO - RotatingClient closed.
163
+ 2026-01-23 16:03:05,733 - rotator_library - INFO - Provider 'antigravity' using rotation mode: sequential
164
+ 2026-01-23 16:03:05,734 - rotator_library - INFO - Provider 'antigravity' priority multipliers: {1: 5, 2: 3}
165
+ 2026-01-23 16:03:05,734 - rotator_library - INFO - Provider 'antigravity' sequential fallback multiplier: 2x
166
+ 2026-01-23 16:03:05,745 - rotator_library - INFO - Background token refresher started. Check interval: 600 seconds.
167
+ 2026-01-23 16:03:05,745 - root - INFO - RotatingClient initialized (EmbeddingBatcher disabled).
168
+ 2026-01-23 16:03:05,745 - rotator_library.model_info_service - INFO - ModelRegistry started (refresh every 21600s)
169
+ 2026-01-23 16:03:05,746 - root - INFO - Model info service started (fetching pricing data in background).
170
+ 2026-01-23 16:03:05,748 - rotator_library - INFO - Providers initialized: 1 providers, 2 credentials
171
+ 2026-01-23 16:03:05,748 - rotator_library - INFO - OAuth: antigravity:2 (standard-tier:2)
172
+ 2026-01-23 16:03:05,748 - rotator_library - INFO - Started antigravity antigravity_quota_refresh (interval: 300s)
173
+ 2026-01-23 16:03:05,751 - rotator_library - INFO - antigravity: Fetching initial quota baselines for 2 credentials...
174
+ 2026-01-23 16:03:06,489 - rotator_library.model_info_service - INFO - OpenRouter: 345 models loaded
175
+ 2026-01-23 16:03:06,489 - rotator_library.model_info_service - INFO - Models.dev: 2255 models loaded
176
+ 2026-01-23 16:04:06,556 - rotator_library - INFO - Getting all available models...
177
+ 2026-01-23 16:04:06,560 - rotator_library - INFO - Getting available models for provider: antigravity
178
+ 2026-01-23 16:04:06,564 - rotator_library - INFO - Got 6 models for provider: antigravity
179
+ 2026-01-23 16:04:06,565 - rotator_library - INFO - Finished getting all available models.
180
+ 2026-01-23 16:04:22,953 - root - INFO - 16:04 - 172.17.0.1:57468 - provider: antigravity, model: gemini-3-flash - N/A
181
+ 2026-01-23 16:04:22,955 - rotator_library - INFO - Acquiring key for model antigravity/gemini-3-flash. Tried keys: 0/2(2)
182
+ 2026-01-23 16:04:22,958 - rotator_library - INFO - Acquired key antigravity_oauth_2.json for model antigravity/gemini-3-flash (tier: standard-tier, priority: 2, selection: sequential, quota: 80/400 [80%])
183
+ 2026-01-23 16:04:22,958 - rotator_library - INFO - Attempting call with credential antigravity_oauth_2.json (Attempt 1/2)
184
+ 2026-01-23 16:04:25,936 - rotator_library - INFO - Started 5.0h window for model antigravity/gemini-3-flash on antigravity_oauth_2.json
185
+ 2026-01-23 16:04:25,936 - rotator_library - INFO - Recorded usage from response object for key antigravity_oauth_2.json
186
+ 2026-01-23 16:04:25,938 - rotator_library - INFO - Released credential antigravity_oauth_2.json from model antigravity/gemini-3-flash (remaining concurrent: 0)
187
+ 2026-01-23 16:12:00,819 - rotator_library - INFO - Background token refresher stopped.
188
+ 2026-01-23 16:12:00,820 - rotator_library.model_info_service - INFO - ModelRegistry stopped
189
+ 2026-01-23 16:12:00,820 - root - INFO - RotatingClient closed.
190
+ 2026-01-23 16:12:06,391 - rotator_library - INFO - Provider 'antigravity' using rotation mode: sequential
191
+ 2026-01-23 16:12:06,392 - rotator_library - INFO - Provider 'antigravity' priority multipliers: {1: 5, 2: 3}
192
+ 2026-01-23 16:12:06,392 - rotator_library - INFO - Provider 'antigravity' sequential fallback multiplier: 2x
193
+ 2026-01-23 16:12:06,402 - rotator_library - INFO - Background token refresher started. Check interval: 600 seconds.
194
+ 2026-01-23 16:12:06,402 - root - INFO - RotatingClient initialized (EmbeddingBatcher disabled).
195
+ 2026-01-23 16:12:06,403 - rotator_library.model_info_service - INFO - ModelRegistry started (refresh every 21600s)
196
+ 2026-01-23 16:12:06,403 - root - INFO - Model info service started (fetching pricing data in background).
197
+ 2026-01-23 16:12:06,404 - rotator_library - INFO - Providers initialized: 1 providers, 1 credentials
198
+ 2026-01-23 16:12:06,405 - rotator_library - INFO - OAuth: antigravity:1 (standard-tier:1)
199
+ 2026-01-23 16:12:06,405 - rotator_library - INFO - Started antigravity antigravity_quota_refresh (interval: 300s)
200
+ 2026-01-23 16:12:06,407 - rotator_library - INFO - antigravity: Fetching initial quota baselines for 1 credentials...
201
+ 2026-01-23 16:12:07,541 - rotator_library.model_info_service - INFO - OpenRouter: 345 models loaded
202
+ 2026-01-23 16:12:07,541 - rotator_library.model_info_service - INFO - Models.dev: 2255 models loaded
203
+ 2026-01-23 16:38:58,754 - root - INFO - 16:38 - 172.17.0.1:50620 - provider: antigravity, model: gemini-3-flash - N/A
204
+ 2026-01-23 16:38:58,755 - rotator_library - INFO - Acquiring key for model antigravity/gemini-3-flash. Tried keys: 0/1(1)
205
+ 2026-01-23 16:38:58,757 - rotator_library - INFO - Acquired key antigravity_oauth_1.json for model antigravity/gemini-3-flash (tier: standard-tier, priority: 2, selection: sequential, quota: 1/400 [99%])
206
+ 2026-01-23 16:38:58,758 - rotator_library - INFO - Attempting call with credential antigravity_oauth_1.json (Attempt 1/2)
207
+ 2026-01-23 16:39:02,593 - rotator_library - INFO - Started 5.0h window for model antigravity/gemini-3-flash on antigravity_oauth_1.json
208
+ 2026-01-23 16:39:02,593 - rotator_library - INFO - Recorded usage from response object for key antigravity_oauth_1.json
209
+ 2026-01-23 16:39:02,595 - rotator_library - INFO - Released credential antigravity_oauth_1.json from model antigravity/gemini-3-flash (remaining concurrent: 0)
210
+ 2026-01-24 11:18:44,039 - rotator_library - INFO - Getting all available models...
211
+ 2026-01-24 11:18:44,058 - rotator_library - INFO - Getting available models for provider: antigravity
212
+ 2026-01-24 11:18:44,059 - rotator_library - INFO - Got 6 models for provider: antigravity
213
+ 2026-01-24 11:18:44,060 - rotator_library - INFO - Finished getting all available models.
214
+ 2026-01-24 11:23:14,658 - rotator_library - INFO - Getting all available models...
215
+ 2026-01-24 11:23:14,659 - rotator_library - INFO - Getting available models for provider: antigravity
216
+ 2026-01-24 11:23:14,659 - rotator_library - INFO - Finished getting all available models.
217
+ 2026-01-24 11:32:50,410 - root - INFO - 11:32 - 172.17.0.1:45252 - provider: antigravity, model: gemini-3-flash - N/A
218
+ 2026-01-24 11:32:50,411 - rotator_library - INFO - Acquiring credential for model antigravity/gemini-3-flash. Tried credentials: 0/1(1)
219
+ 2026-01-24 11:32:50,413 - rotator_library - INFO - Reset model group 'g3-flash' (1 models) for antigravity_oauth_1.json
220
+ 2026-01-24 11:32:50,524 - rotator_library - INFO - Reset model group 'g3-flash' (1 models) for antigravity_oauth_2.json
221
+ 2026-01-24 11:32:50,817 - rotator_library - INFO - Acquired key antigravity_oauth_1.json for model antigravity/gemini-3-flash (tier: standard-tier, priority: 2, selection: sequential, quota: 0/400 [100%])
222
+ 2026-01-24 11:32:50,881 - rotator_library - INFO - Attempting stream with credential antigravity_oauth_1.json (Attempt 1/2)
223
+ 2026-01-24 11:32:51,096 - rotator_library - INFO - Stream connection established for credential antigravity_oauth_1.json. Processing response.
224
+ 2026-01-24 11:32:51,590 - root - INFO - 11:32 - 172.17.0.1:45266 - provider: antigravity, model: gemini-3-flash - N/A
225
+ 2026-01-24 11:32:51,597 - rotator_library - INFO - Acquiring credential for model antigravity/gemini-3-flash. Tried credentials: 0/1(1)
226
+ 2026-01-24 11:32:51,598 - rotator_library - INFO - Acquired key antigravity_oauth_1.json for model antigravity/gemini-3-flash (tier: standard-tier, priority: 2, selection: sequential, concurrent: 2/3, quota: 0/400 [100%])
227
+ 2026-01-24 11:32:51,599 - rotator_library - INFO - Attempting stream with credential antigravity_oauth_1.json (Attempt 1/2)
228
+ 2026-01-24 11:32:51,599 - rotator_library - INFO - Stream connection established for credential antigravity_oauth_1.json. Processing response.
229
+ 2026-01-24 11:32:51,607 - root - INFO - 11:32 - 172.17.0.1:45284 - provider: antigravity, model: claude-opus-4.5 - N/A
230
+ 2026-01-24 11:32:51,608 - rotator_library - INFO - Acquiring credential for model antigravity/claude-opus-4.5. Tried credentials: 0/1(1)
231
+ 2026-01-24 11:32:51,609 - rotator_library - INFO - Acquired key antigravity_oauth_1.json for model antigravity/claude-opus-4.5 (tier: standard-tier, priority: 2, selection: sequential, concurrent: 1/3, quota: 0/150 [100%])
232
+ 2026-01-24 11:32:51,610 - rotator_library - INFO - Attempting stream with credential antigravity_oauth_1.json (Attempt 1/2)
233
+ 2026-01-24 11:32:51,614 - rotator_library - INFO - Stream connection established for credential antigravity_oauth_1.json. Processing response.
234
+ 2026-01-24 11:32:54,551 - rotator_library - INFO - Started 5.0h window for model antigravity/gemini-3-flash on antigravity_oauth_1.json
235
+ 2026-01-24 11:32:54,552 - rotator_library - INFO - Recorded usage from response object for key antigravity_oauth_1.json
236
+ 2026-01-24 11:32:54,554 - rotator_library - INFO - Released credential antigravity_oauth_1.json from model antigravity/gemini-3-flash (remaining concurrent: 1)
237
+ 2026-01-24 11:32:54,554 - rotator_library - INFO - STREAM FINISHED and lock released for credential antigravity_oauth_1.json.
238
+ 2026-01-24 11:32:56,228 - rotator_library - INFO - Recorded usage from response object for key antigravity_oauth_1.json
239
+ 2026-01-24 11:32:56,229 - rotator_library - INFO - Released credential antigravity_oauth_1.json from model antigravity/gemini-3-flash (remaining concurrent: 0)
240
+ 2026-01-24 11:32:56,230 - rotator_library - INFO - STREAM FINISHED and lock released for credential antigravity_oauth_1.json.
241
+ 2026-01-24 11:32:57,435 - rotator_library - INFO - Started 5.0h window for model antigravity/claude-opus-4.5 on antigravity_oauth_1.json
242
+ 2026-01-24 11:32:57,435 - rotator_library - INFO - Recorded usage from response object for key antigravity_oauth_1.json
243
+ 2026-01-24 11:32:57,437 - rotator_library - INFO - Released credential antigravity_oauth_1.json from model antigravity/claude-opus-4.5 (remaining concurrent: 0)
244
+ 2026-01-24 11:32:57,437 - rotator_library - INFO - STREAM FINISHED and lock released for credential antigravity_oauth_1.json.
245
+ 2026-01-24 11:34:30,522 - root - INFO - 11:34 - 172.17.0.1:40506 - provider: antigravity, model: claude-opus-4.5 - N/A
246
+ 2026-01-24 11:34:30,524 - rotator_library - INFO - Acquiring credential for model antigravity/claude-opus-4.5. Tried credentials: 0/1(1)
247
+ 2026-01-24 11:34:30,526 - rotator_library - INFO - Acquired key antigravity_oauth_1.json for model antigravity/claude-opus-4.5 (tier: standard-tier, priority: 2, selection: sequential, quota: 1/150 [99%])
248
+ 2026-01-24 11:34:30,526 - rotator_library - INFO - Attempting stream with credential antigravity_oauth_1.json (Attempt 1/2)
249
+ 2026-01-24 11:34:30,645 - rotator_library - INFO - Stream connection established for credential antigravity_oauth_1.json. Processing response.
250
+ 2026-01-24 11:34:30,651 - root - INFO - 11:34 - 172.17.0.1:40538 - provider: antigravity, model: gemini-3-flash - N/A
251
+ 2026-01-24 11:34:30,652 - rotator_library - INFO - Acquiring credential for model antigravity/gemini-3-flash. Tried credentials: 0/1(1)
252
+ 2026-01-24 11:34:30,653 - rotator_library - INFO - Acquired key antigravity_oauth_1.json for model antigravity/gemini-3-flash (tier: standard-tier, priority: 2, selection: sequential, concurrent: 1/3, quota: 2/400 [99%])
253
+ 2026-01-24 11:34:30,654 - rotator_library - INFO - Attempting stream with credential antigravity_oauth_1.json (Attempt 1/2)
254
+ 2026-01-24 11:34:30,655 - rotator_library - INFO - Stream connection established for credential antigravity_oauth_1.json. Processing response.
255
+ 2026-01-24 11:34:33,407 - rotator_library - INFO - Recorded usage from response object for key antigravity_oauth_1.json
256
+ 2026-01-24 11:34:33,409 - rotator_library - INFO - Released credential antigravity_oauth_1.json from model antigravity/gemini-3-flash (remaining concurrent: 0)
257
+ 2026-01-24 11:34:33,410 - rotator_library - INFO - STREAM FINISHED and lock released for credential antigravity_oauth_1.json.
258
+ 2026-01-24 11:34:36,075 - rotator_library - INFO - Recorded usage from response object for key antigravity_oauth_1.json
259
+ 2026-01-24 11:34:36,077 - rotator_library - INFO - Released credential antigravity_oauth_1.json from model antigravity/claude-opus-4.5 (remaining concurrent: 0)
260
+ 2026-01-24 11:34:36,077 - rotator_library - INFO - STREAM FINISHED and lock released for credential antigravity_oauth_1.json.
261
+ 2026-01-24 11:34:38,581 - root - INFO - 11:34 - 172.17.0.1:40506 - provider: antigravity, model: claude-opus-4.5 - N/A
262
+ 2026-01-24 11:34:38,582 - rotator_library - INFO - Acquiring credential for model antigravity/claude-opus-4.5. Tried credentials: 0/1(1)
263
+ 2026-01-24 11:34:38,583 - rotator_library - INFO - Acquired key antigravity_oauth_1.json for model antigravity/claude-opus-4.5 (tier: standard-tier, priority: 2, selection: sequential, quota: 2/150 [98%])
264
+ 2026-01-24 11:34:38,584 - rotator_library - INFO - Attempting stream with credential antigravity_oauth_1.json (Attempt 1/2)
265
+ 2026-01-24 11:34:38,585 - rotator_library - INFO - [Thinking Sanitization] Closing tool loop - turn has no thinking at start
266
+ 2026-01-24 11:34:38,585 - rotator_library - INFO - [Thinking Sanitization] Closed tool loop with synthetic messages. Model: '[Tool execution completed.]', User: '[Continue]'. Claude will now start a fresh turn with thinking enabled.
267
+ 2026-01-24 11:34:38,589 - rotator_library - INFO - Stream connection established for credential antigravity_oauth_1.json. Processing response.
268
+ 2026-01-24 11:34:44,008 - rotator_library - INFO - Recorded usage from response object for key antigravity_oauth_1.json
269
+ 2026-01-24 11:34:44,010 - rotator_library - INFO - Released credential antigravity_oauth_1.json from model antigravity/claude-opus-4.5 (remaining concurrent: 0)
270
+ 2026-01-24 11:34:44,010 - rotator_library - INFO - STREAM FINISHED and lock released for credential antigravity_oauth_1.json.
271
+ 2026-01-24 11:38:54,076 - root - INFO - 11:38 - 172.17.0.1:37082 - provider: antigravity, model: claude-opus-4.5 - N/A
272
+ 2026-01-24 11:38:54,076 - rotator_library - INFO - Acquiring credential for model antigravity/claude-opus-4.5. Tried credentials: 0/1(1)
273
+ 2026-01-24 11:38:54,077 - rotator_library - INFO - Acquired key antigravity_oauth_1.json for model antigravity/claude-opus-4.5 (tier: standard-tier, priority: 2, selection: sequential, quota: 3/150 [98%])
274
+ 2026-01-24 11:38:54,081 - rotator_library - INFO - Attempting stream with credential antigravity_oauth_1.json (Attempt 1/2)
275
+ 2026-01-24 11:38:54,091 - rotator_library - INFO - Stream connection established for credential antigravity_oauth_1.json. Processing response.
276
+ 2026-01-24 11:38:54,149 - root - INFO - 11:38 - 172.17.0.1:37094 - provider: antigravity, model: gemini-3-flash - N/A
277
+ 2026-01-24 11:38:54,150 - rotator_library - INFO - Acquiring credential for model antigravity/gemini-3-flash. Tried credentials: 0/1(1)
278
+ 2026-01-24 11:38:54,152 - rotator_library - INFO - Acquired key antigravity_oauth_1.json for model antigravity/gemini-3-flash (tier: standard-tier, priority: 2, selection: sequential, concurrent: 1/3, quota: 3/400 [99%])
279
+ 2026-01-24 11:38:54,152 - rotator_library - INFO - Attempting stream with credential antigravity_oauth_1.json (Attempt 1/2)
280
+ 2026-01-24 11:38:54,153 - rotator_library - INFO - Stream connection established for credential antigravity_oauth_1.json. Processing response.
281
+ 2026-01-24 11:38:56,999 - rotator_library - INFO - Recorded usage from response object for key antigravity_oauth_1.json
282
+ 2026-01-24 11:38:57,001 - rotator_library - INFO - Released credential antigravity_oauth_1.json from model antigravity/gemini-3-flash (remaining concurrent: 0)
283
+ 2026-01-24 11:38:57,002 - rotator_library - INFO - STREAM FINISHED and lock released for credential antigravity_oauth_1.json.
284
+ 2026-01-24 11:38:58,817 - rotator_library - INFO - Recorded usage from response object for key antigravity_oauth_1.json
285
+ 2026-01-24 11:38:58,819 - rotator_library - INFO - Released credential antigravity_oauth_1.json from model antigravity/claude-opus-4.5 (remaining concurrent: 0)
286
+ 2026-01-24 11:38:58,820 - rotator_library - INFO - STREAM FINISHED and lock released for credential antigravity_oauth_1.json.
287
+ 2026-01-24 11:39:23,428 - root - INFO - 11:39 - 172.17.0.1:50878 - provider: antigravity, model: gemini-3-flash - N/A
288
+ 2026-01-24 11:39:23,428 - rotator_library - INFO - Acquiring credential for model antigravity/gemini-3-flash. Tried credentials: 0/1(1)
289
+ 2026-01-24 11:39:23,430 - rotator_library - INFO - Acquired key antigravity_oauth_1.json for model antigravity/gemini-3-flash (tier: standard-tier, priority: 2, selection: sequential, quota: 4/400 [99%])
290
+ 2026-01-24 11:39:23,430 - rotator_library - INFO - Attempting stream with credential antigravity_oauth_1.json (Attempt 1/2)
291
+ 2026-01-24 11:39:23,443 - rotator_library - INFO - Stream connection established for credential antigravity_oauth_1.json. Processing response.
292
+ 2026-01-24 11:39:23,450 - root - INFO - 11:39 - 172.17.0.1:50886 - provider: antigravity, model: gemini-3-flash - N/A
293
+ 2026-01-24 11:39:23,451 - rotator_library - INFO - Acquiring credential for model antigravity/gemini-3-flash. Tried credentials: 0/1(1)
294
+ 2026-01-24 11:39:23,453 - rotator_library - INFO - Acquired key antigravity_oauth_1.json for model antigravity/gemini-3-flash (tier: standard-tier, priority: 2, selection: sequential, concurrent: 2/3, quota: 4/400 [99%])
295
+ 2026-01-24 11:39:23,453 - rotator_library - INFO - Attempting stream with credential antigravity_oauth_1.json (Attempt 1/2)
296
+ 2026-01-24 11:39:23,454 - rotator_library - INFO - Stream connection established for credential antigravity_oauth_1.json. Processing response.
297
+ 2026-01-24 11:39:25,862 - rotator_library - INFO - Recorded usage from response object for key antigravity_oauth_1.json
298
+ 2026-01-24 11:39:25,865 - rotator_library - INFO - Released credential antigravity_oauth_1.json from model antigravity/gemini-3-flash (remaining concurrent: 1)
299
+ 2026-01-24 11:39:25,865 - rotator_library - INFO - STREAM FINISHED and lock released for credential antigravity_oauth_1.json.
300
+ 2026-01-24 11:39:27,283 - rotator_library - INFO - Recorded usage from response object for key antigravity_oauth_1.json
301
+ 2026-01-24 11:39:27,284 - rotator_library - INFO - Released credential antigravity_oauth_1.json from model antigravity/gemini-3-flash (remaining concurrent: 0)
302
+ 2026-01-24 11:39:27,285 - rotator_library - INFO - STREAM FINISHED and lock released for credential antigravity_oauth_1.json.
303
+ 2026-01-24 11:45:15,687 - rotator_library.model_info_service - INFO - Scheduled registry refresh...
304
+ 2026-01-24 11:45:17,015 - rotator_library.model_info_service - INFO - OpenRouter: 345 models loaded
305
+ 2026-01-24 11:45:17,018 - rotator_library.model_info_service - INFO - Models.dev: 2253 models loaded
306
+ 2026-01-24 11:45:17,035 - rotator_library.model_info_service - INFO - Registry refresh complete
307
+ 2026-01-24 17:05:30,072 - rotator_library - WARNING - Network error during refresh: [Errno -3] Temporary failure in name resolution, retry 1/3 in 1s
308
+ 2026-01-24 17:05:34,907 - rotator_library - WARNING - Refresh timeout (15s) for 'antigravity_oauth_1.json'
309
+ 2026-01-24 17:05:34,908 - rotator_library - WARNING - Refresh failed for 'antigravity_oauth_1.json' (timeout). Retry 1/3, back of queue.
310
+ 2026-01-24 17:06:14,940 - rotator_library - WARNING - Network error during refresh: [Errno -3] Temporary failure in name resolution, retry 1/3 in 1s
311
+ 2026-01-24 17:06:19,910 - rotator_library - WARNING - Refresh timeout (15s) for 'antigravity_oauth_1.json'
312
+ 2026-01-24 17:06:19,910 - rotator_library - WARNING - Refresh failed for 'antigravity_oauth_1.json' (timeout). Retry 2/3, back of queue.
313
+ 2026-01-24 17:06:59,936 - rotator_library - WARNING - Network error during refresh: [Errno -3] Temporary failure in name resolution, retry 1/3 in 1s
314
+ 2026-01-24 17:07:04,912 - rotator_library - WARNING - Refresh timeout (15s) for 'antigravity_oauth_1.json'
315
+ 2026-01-24 17:07:04,912 - rotator_library - ERROR - Max retries (3) reached for 'antigravity_oauth_1.json' (last error: timeout). Will retry next refresh cycle.
logs/proxy_debug.log ADDED
@@ -0,0 +1,679 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 2026-01-23 15:44:10,821 - rotator_library - DEBUG - ProviderCache[gemini3_signatures]: Disk enabled (memory_ttl=3600s, disk_ttl=86400s)
2
+ 2026-01-23 15:44:10,821 - rotator_library - DEBUG - ProviderCache[claude_thinking]: Disk enabled (memory_ttl=3600s, disk_ttl=86400s)
3
+ 2026-01-23 15:44:10,821 - rotator_library - DEBUG - Antigravity config: signatures_in_client=True, cache=True, dynamic_models=False, gemini3_fix=True, gemini3_strict_schema=True, claude_fix=False, thinking_sanitization=True, parallel_tool_claude=True, parallel_tool_gemini3=True
4
+ 2026-01-23 15:44:10,822 - rotator_library - DEBUG - Loaded persisted tier 'standard-tier' for credential: antigravity_oauth_1.json
5
+ 2026-01-23 15:44:10,822 - rotator_library - DEBUG - Loaded persisted tier 'standard-tier' for credential: antigravity_oauth_2.json
6
+ 2026-01-23 15:44:10,822 - rotator_library - DEBUG - antigravity: Loaded 2 credential tiers from disk: standard-tier=2
7
+ 2026-01-23 15:44:10,823 - rotator_library - DEBUG - Loading ANTIGRAVITY credentials from file: /app/oauth_creds/antigravity_oauth_1.json
8
+ 2026-01-23 15:44:10,823 - rotator_library - DEBUG - Loading ANTIGRAVITY credentials from file: /app/oauth_creds/antigravity_oauth_2.json
9
+ 2026-01-23 15:44:10,825 - rotator_library - DEBUG - ProviderCache[gemini3_signatures]: Started background tasks
10
+ 2026-01-23 15:44:10,825 - rotator_library - DEBUG - ProviderCache[claude_thinking]: Started background tasks
11
+ 2026-01-23 15:44:10,826 - rotator_library - DEBUG - Fetching quota baselines for 2 credentials...
12
+ 2026-01-23 15:44:13,440 - rotator_library - DEBUG - Baseline fetch complete: 2/2 successful
13
+ 2026-01-23 15:44:13,441 - rotator_library - DEBUG - ProviderCache[gemini3_signatures]: Disk enabled (memory_ttl=3600s, disk_ttl=86400s)
14
+ 2026-01-23 15:44:13,441 - rotator_library - DEBUG - ProviderCache[claude_thinking]: Disk enabled (memory_ttl=3600s, disk_ttl=86400s)
15
+ 2026-01-23 15:44:13,442 - rotator_library - DEBUG - Antigravity config: signatures_in_client=True, cache=True, dynamic_models=False, gemini3_fix=True, gemini3_strict_schema=True, claude_fix=False, thinking_sanitization=True, parallel_tool_claude=True, parallel_tool_gemini3=True
16
+ 2026-01-23 15:44:13,442 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/gemini-2.5-flash: remaining=100.00%, synced_request_count=0
17
+ 2026-01-23 15:44:13,443 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/claude-opus-4.5: remaining=100.00%, synced_request_count=0
18
+ 2026-01-23 15:44:13,444 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/gemini-2.5-flash-lite: remaining=100.00%, synced_request_count=0
19
+ 2026-01-23 15:44:13,491 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/gemini-3-pro-preview: remaining=100.00%, synced_request_count=0
20
+ 2026-01-23 15:44:13,492 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/claude-sonnet-4.5: remaining=100.00%, synced_request_count=0
21
+ 2026-01-23 15:44:13,681 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/gemini-3-flash: remaining=100.00%, synced_request_count=0
22
+ 2026-01-23 15:44:13,682 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_2.json model=antigravity/gemini-2.5-flash-lite: remaining=100.00%, synced_request_count=0
23
+ 2026-01-23 15:44:13,861 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_2.json model=antigravity/claude-sonnet-4.5: remaining=100.00%, synced_request_count=0
24
+ 2026-01-23 15:44:13,862 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_2.json model=antigravity/gemini-2.5-flash: remaining=80.00%, synced_request_count=599
25
+ 2026-01-23 15:44:14,020 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_2.json model=antigravity/gemini-3-pro-preview: remaining=100.00%, synced_request_count=0
26
+ 2026-01-23 15:44:14,021 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_2.json model=antigravity/gemini-3-flash: remaining=80.00%, synced_request_count=79
27
+ 2026-01-23 15:44:14,168 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_2.json model=antigravity/claude-opus-4.5: remaining=100.00%, synced_request_count=0
28
+ 2026-01-23 15:44:14,170 - rotator_library - DEBUG - Antigravity quota baseline refresh: no cooldowns needed
29
+ 2026-01-23 15:44:14,170 - rotator_library - DEBUG - antigravity quota refresh: updated 12 model baselines
30
+ 2026-01-23 15:44:14,170 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: initial run complete
31
+ 2026-01-23 15:44:14,170 - rotator_library - DEBUG - ProviderCache[gemini3_signatures]: Started background tasks
32
+ 2026-01-23 15:44:14,170 - rotator_library - DEBUG - ProviderCache[claude_thinking]: Started background tasks
33
+ 2026-01-23 15:44:28,311 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/gemini-2.0-flash-exp, reasoning_effort=None
34
+ 2026-01-23 15:44:28,312 - rotator_library - DEBUG - Credential priorities for antigravity: P2=2
35
+ 2026-01-23 15:44:28,312 - rotator_library - DEBUG - Lazy-loaded tier 'standard-tier' for credential: antigravity_oauth_1.json
36
+ 2026-01-23 15:44:28,313 - rotator_library - DEBUG - Lazy-loaded tier 'standard-tier' for credential: antigravity_oauth_2.json
37
+ 2026-01-23 15:44:28,316 - rotator_library - DEBUG - Sequential ordering: antigravity_oauth_1.json(p=2, u=0) → antigravity_oauth_2.json(p=2, u=0)
38
+ 2026-01-23 15:44:28,317 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call.
39
+ 2026-01-23 15:44:28,318 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json
40
+ 2026-01-23 15:44:28,318 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46
41
+ 2026-01-23 15:44:48,448 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call.
42
+ 2026-01-23 15:44:48,449 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_2.json
43
+ 2026-01-23 15:44:48,449 - rotator_library - DEBUG - Using cached project ID: bold-throne-g2hmx
44
+ 2026-01-23 15:45:21,582 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/gemini-1.5-pro, reasoning_effort=None
45
+ 2026-01-23 15:45:21,583 - rotator_library - DEBUG - Credential priorities for antigravity: P2=2
46
+ 2026-01-23 15:45:21,586 - rotator_library - DEBUG - Sequential ordering: antigravity_oauth_1.json(p=2, u=0) → antigravity_oauth_2.json(p=2, u=0)
47
+ 2026-01-23 15:45:21,586 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call.
48
+ 2026-01-23 15:45:21,587 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json
49
+ 2026-01-23 15:45:21,587 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46
50
+ 2026-01-23 15:45:37,865 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call.
51
+ 2026-01-23 15:45:37,866 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_2.json
52
+ 2026-01-23 15:45:37,866 - rotator_library - DEBUG - Using cached project ID: bold-throne-g2hmx
53
+ 2026-01-23 15:49:14,171 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
54
+ 2026-01-23 15:49:14,171 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
55
+ 2026-01-23 15:54:14,172 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
56
+ 2026-01-23 15:54:14,172 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
57
+ 2026-01-23 15:56:23,673 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/gemini-2.0-flash-exp, reasoning_effort=None
58
+ 2026-01-23 15:56:23,674 - rotator_library - DEBUG - Credential priorities for antigravity: P2=2
59
+ 2026-01-23 15:56:23,676 - rotator_library - DEBUG - Sequential ordering: antigravity_oauth_1.json(p=2, u=1) → antigravity_oauth_2.json(p=2, u=1)
60
+ 2026-01-23 15:56:23,677 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call.
61
+ 2026-01-23 15:56:23,678 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json
62
+ 2026-01-23 15:56:23,678 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46
63
+ 2026-01-23 15:56:41,164 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call.
64
+ 2026-01-23 15:56:41,164 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_2.json
65
+ 2026-01-23 15:56:41,164 - rotator_library - DEBUG - Using cached project ID: bold-throne-g2hmx
66
+ 2026-01-23 15:58:31,359 - rotator_library - DEBUG - Attempting to get models for antigravity with credential antigravity_oauth_2.json
67
+ 2026-01-23 15:58:31,359 - rotator_library - DEBUG - Using hardcoded model list
68
+ 2026-01-23 15:59:07,975 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/gemini-3-flash, reasoning_effort=None
69
+ 2026-01-23 15:59:07,976 - rotator_library - DEBUG - Credential priorities for antigravity: P2=2
70
+ 2026-01-23 15:59:07,978 - rotator_library - DEBUG - Sequential ordering: antigravity_oauth_2.json(p=2, u=79) → antigravity_oauth_1.json(p=2, u=0)
71
+ 2026-01-23 15:59:07,978 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call.
72
+ 2026-01-23 15:59:07,979 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_2.json
73
+ 2026-01-23 15:59:07,979 - rotator_library - DEBUG - Using cached project ID: bold-throne-g2hmx
74
+ 2026-01-23 15:59:14,173 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
75
+ 2026-01-23 15:59:14,173 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
76
+ 2026-01-23 15:59:24,453 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call.
77
+ 2026-01-23 15:59:24,453 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json
78
+ 2026-01-23 15:59:24,454 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46
79
+ 2026-01-23 16:00:10,954 - rotator_library - DEBUG - Returning cached models for provider: antigravity
80
+ 2026-01-23 16:00:21,201 - rotator_library - DEBUG - Returning cached models for provider: antigravity
81
+ 2026-01-23 16:01:08,173 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/gemini-2.5-flash, reasoning_effort=None
82
+ 2026-01-23 16:01:08,173 - rotator_library - DEBUG - Credential priorities for antigravity: P2=2
83
+ 2026-01-23 16:01:08,175 - rotator_library - DEBUG - Sequential ordering: antigravity_oauth_2.json(p=2, u=599) → antigravity_oauth_1.json(p=2, u=0)
84
+ 2026-01-23 16:01:08,176 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call.
85
+ 2026-01-23 16:01:08,176 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_2.json
86
+ 2026-01-23 16:01:08,176 - rotator_library - DEBUG - Using cached project ID: bold-throne-g2hmx
87
+ 2026-01-23 16:01:26,091 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call.
88
+ 2026-01-23 16:01:26,092 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json
89
+ 2026-01-23 16:01:26,092 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46
90
+ 2026-01-23 16:02:46,115 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: cancelled
91
+ 2026-01-23 16:02:46,115 - rotator_library - DEBUG - Stopped background job for 'antigravity'
92
+ 2026-01-23 16:03:05,746 - rotator_library - DEBUG - ProviderCache[gemini3_signatures]: Disk enabled (memory_ttl=3600s, disk_ttl=86400s)
93
+ 2026-01-23 16:03:05,747 - rotator_library - DEBUG - ProviderCache[claude_thinking]: Disk enabled (memory_ttl=3600s, disk_ttl=86400s)
94
+ 2026-01-23 16:03:05,747 - rotator_library - DEBUG - Antigravity config: signatures_in_client=True, cache=True, dynamic_models=False, gemini3_fix=True, gemini3_strict_schema=True, claude_fix=False, thinking_sanitization=True, parallel_tool_claude=True, parallel_tool_gemini3=True
95
+ 2026-01-23 16:03:05,747 - rotator_library - DEBUG - Loaded persisted tier 'standard-tier' for credential: antigravity_oauth_1.json
96
+ 2026-01-23 16:03:05,748 - rotator_library - DEBUG - Loaded persisted tier 'standard-tier' for credential: antigravity_oauth_2.json
97
+ 2026-01-23 16:03:05,748 - rotator_library - DEBUG - antigravity: Loaded 2 credential tiers from disk: standard-tier=2
98
+ 2026-01-23 16:03:05,749 - rotator_library - DEBUG - Loading ANTIGRAVITY credentials from file: /app/oauth_creds/antigravity_oauth_1.json
99
+ 2026-01-23 16:03:05,749 - rotator_library - DEBUG - Loading ANTIGRAVITY credentials from file: /app/oauth_creds/antigravity_oauth_2.json
100
+ 2026-01-23 16:03:05,751 - rotator_library - DEBUG - ProviderCache[gemini3_signatures]: Started background tasks
101
+ 2026-01-23 16:03:05,751 - rotator_library - DEBUG - ProviderCache[claude_thinking]: Started background tasks
102
+ 2026-01-23 16:03:05,751 - rotator_library - DEBUG - Fetching quota baselines for 2 credentials...
103
+ 2026-01-23 16:03:08,351 - rotator_library - DEBUG - Baseline fetch complete: 2/2 successful
104
+ 2026-01-23 16:03:08,353 - rotator_library - DEBUG - ProviderCache[gemini3_signatures]: Disk enabled (memory_ttl=3600s, disk_ttl=86400s)
105
+ 2026-01-23 16:03:08,354 - rotator_library - DEBUG - ProviderCache[claude_thinking]: Disk enabled (memory_ttl=3600s, disk_ttl=86400s)
106
+ 2026-01-23 16:03:08,354 - rotator_library - DEBUG - Antigravity config: signatures_in_client=True, cache=True, dynamic_models=False, gemini3_fix=True, gemini3_strict_schema=True, claude_fix=False, thinking_sanitization=True, parallel_tool_claude=True, parallel_tool_gemini3=True
107
+ 2026-01-23 16:03:08,355 - rotator_library - DEBUG - Lazy-loaded tier 'standard-tier' for credential: antigravity_oauth_1.json
108
+ 2026-01-23 16:03:08,356 - rotator_library - DEBUG - Lazy-loaded tier 'standard-tier' for credential: antigravity_oauth_2.json
109
+ 2026-01-23 16:03:08,356 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/claude-sonnet-4.5: remaining=100.00%, synced_request_count=0
110
+ 2026-01-23 16:03:08,358 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/gemini-2.5-flash-lite: remaining=100.00%, synced_request_count=0
111
+ 2026-01-23 16:03:08,360 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/gemini-3-flash: remaining=100.00%, synced_request_count=1
112
+ 2026-01-23 16:03:08,360 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/gemini-3-pro-preview: remaining=100.00%, synced_request_count=0
113
+ 2026-01-23 16:03:08,361 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/gemini-2.5-flash: remaining=100.00%, synced_request_count=1
114
+ 2026-01-23 16:03:08,363 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/claude-opus-4.5: remaining=100.00%, synced_request_count=0
115
+ 2026-01-23 16:03:08,364 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_2.json model=antigravity/claude-sonnet-4.5: remaining=100.00%, synced_request_count=0
116
+ 2026-01-23 16:03:08,365 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_2.json model=antigravity/gemini-2.5-flash: remaining=80.00%, synced_request_count=600
117
+ 2026-01-23 16:03:08,366 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_2.json model=antigravity/gemini-2.5-flash-lite: remaining=100.00%, synced_request_count=0
118
+ 2026-01-23 16:03:08,367 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_2.json model=antigravity/gemini-3-pro-preview: remaining=100.00%, synced_request_count=0
119
+ 2026-01-23 16:03:08,368 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_2.json model=antigravity/gemini-3-flash: remaining=80.00%, synced_request_count=80
120
+ 2026-01-23 16:03:08,369 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_2.json model=antigravity/claude-opus-4.5: remaining=100.00%, synced_request_count=0
121
+ 2026-01-23 16:03:08,370 - rotator_library - DEBUG - Antigravity quota baseline refresh: no cooldowns needed
122
+ 2026-01-23 16:03:08,370 - rotator_library - DEBUG - antigravity quota refresh: updated 12 model baselines
123
+ 2026-01-23 16:03:08,370 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: initial run complete
124
+ 2026-01-23 16:03:08,370 - rotator_library - DEBUG - ProviderCache[gemini3_signatures]: Started background tasks
125
+ 2026-01-23 16:03:08,370 - rotator_library - DEBUG - ProviderCache[claude_thinking]: Started background tasks
126
+ 2026-01-23 16:04:06,561 - rotator_library - DEBUG - Attempting to get models for antigravity with credential antigravity_oauth_1.json
127
+ 2026-01-23 16:04:06,564 - rotator_library - DEBUG - Using hardcoded model list
128
+ 2026-01-23 16:04:22,953 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/gemini-3-flash, reasoning_effort=None
129
+ 2026-01-23 16:04:22,954 - rotator_library - DEBUG - Credential priorities for antigravity: P2=2
130
+ 2026-01-23 16:04:22,957 - rotator_library - DEBUG - Sequential ordering: antigravity_oauth_2.json(p=2, u=80) → antigravity_oauth_1.json(p=2, u=1)
131
+ 2026-01-23 16:04:22,958 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call.
132
+ 2026-01-23 16:04:22,958 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_2.json
133
+ 2026-01-23 16:04:22,959 - rotator_library - DEBUG - Using cached project ID: bold-throne-g2hmx
134
+ 2026-01-23 16:04:25,936 - rotator_library - DEBUG - Skipping cost calculation for provider 'antigravity' (custom provider).
135
+ 2026-01-23 16:08:08,371 - rotator_library - DEBUG - Refreshing quota baselines for 1 recently active credentials
136
+ 2026-01-23 16:08:10,408 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_2.json model=antigravity/gemini-3-flash: remaining=80.00%, synced_request_count=81
137
+ 2026-01-23 16:08:10,409 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_2.json model=antigravity/gemini-2.5-flash: remaining=80.00%, synced_request_count=600
138
+ 2026-01-23 16:08:10,410 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_2.json model=antigravity/claude-opus-4.5: remaining=100.00%, synced_request_count=0
139
+ 2026-01-23 16:08:10,412 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_2.json model=antigravity/gemini-3-pro-preview: remaining=100.00%, synced_request_count=0
140
+ 2026-01-23 16:08:10,416 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_2.json model=antigravity/claude-sonnet-4.5: remaining=100.00%, synced_request_count=0
141
+ 2026-01-23 16:08:10,417 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_2.json model=antigravity/gemini-2.5-flash-lite: remaining=100.00%, synced_request_count=0
142
+ 2026-01-23 16:08:10,418 - rotator_library - DEBUG - Antigravity quota baseline refresh: no cooldowns needed
143
+ 2026-01-23 16:08:10,418 - rotator_library - DEBUG - antigravity quota refresh: updated 6 model baselines
144
+ 2026-01-23 16:08:10,418 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
145
+ 2026-01-23 16:12:00,819 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: cancelled
146
+ 2026-01-23 16:12:00,819 - rotator_library - DEBUG - Stopped background job for 'antigravity'
147
+ 2026-01-23 16:12:06,403 - rotator_library - DEBUG - ProviderCache[gemini3_signatures]: Disk enabled (memory_ttl=3600s, disk_ttl=86400s)
148
+ 2026-01-23 16:12:06,404 - rotator_library - DEBUG - ProviderCache[claude_thinking]: Disk enabled (memory_ttl=3600s, disk_ttl=86400s)
149
+ 2026-01-23 16:12:06,404 - rotator_library - DEBUG - Antigravity config: signatures_in_client=True, cache=True, dynamic_models=False, gemini3_fix=True, gemini3_strict_schema=True, claude_fix=False, thinking_sanitization=True, parallel_tool_claude=True, parallel_tool_gemini3=True
150
+ 2026-01-23 16:12:06,404 - rotator_library - DEBUG - Loaded persisted tier 'standard-tier' for credential: antigravity_oauth_1.json
151
+ 2026-01-23 16:12:06,404 - rotator_library - DEBUG - antigravity: Loaded 1 credential tiers from disk: standard-tier=1
152
+ 2026-01-23 16:12:06,405 - rotator_library - DEBUG - Loading ANTIGRAVITY credentials from file: /app/oauth_creds/antigravity_oauth_1.json
153
+ 2026-01-23 16:12:06,407 - rotator_library - DEBUG - ProviderCache[gemini3_signatures]: Started background tasks
154
+ 2026-01-23 16:12:06,407 - rotator_library - DEBUG - ProviderCache[claude_thinking]: Started background tasks
155
+ 2026-01-23 16:12:06,407 - rotator_library - DEBUG - Fetching quota baselines for 1 credentials...
156
+ 2026-01-23 16:12:09,041 - rotator_library - DEBUG - Baseline fetch complete: 1/1 successful
157
+ 2026-01-23 16:12:09,043 - rotator_library - DEBUG - ProviderCache[gemini3_signatures]: Disk enabled (memory_ttl=3600s, disk_ttl=86400s)
158
+ 2026-01-23 16:12:09,043 - rotator_library - DEBUG - ProviderCache[claude_thinking]: Disk enabled (memory_ttl=3600s, disk_ttl=86400s)
159
+ 2026-01-23 16:12:09,043 - rotator_library - DEBUG - Antigravity config: signatures_in_client=True, cache=True, dynamic_models=False, gemini3_fix=True, gemini3_strict_schema=True, claude_fix=False, thinking_sanitization=True, parallel_tool_claude=True, parallel_tool_gemini3=True
160
+ 2026-01-23 16:12:09,044 - rotator_library - DEBUG - Lazy-loaded tier 'standard-tier' for credential: antigravity_oauth_1.json
161
+ 2026-01-23 16:12:09,045 - rotator_library - DEBUG - Could not lazy-load tier from /app/oauth_creds/antigravity_oauth_2.json: [Errno 2] No such file or directory: '/app/oauth_creds/antigravity_oauth_2.json'
162
+ 2026-01-23 16:12:09,045 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/gemini-2.5-flash: remaining=100.00%, synced_request_count=1
163
+ 2026-01-23 16:12:09,051 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/gemini-3-flash: remaining=100.00%, synced_request_count=1
164
+ 2026-01-23 16:12:09,678 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/claude-sonnet-4.5: remaining=100.00%, synced_request_count=0
165
+ 2026-01-23 16:12:09,679 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/gemini-3-pro-preview: remaining=100.00%, synced_request_count=0
166
+ 2026-01-23 16:12:09,837 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/claude-opus-4.5: remaining=100.00%, synced_request_count=0
167
+ 2026-01-23 16:12:09,839 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/gemini-2.5-flash-lite: remaining=100.00%, synced_request_count=0
168
+ 2026-01-23 16:12:09,973 - rotator_library - DEBUG - Antigravity quota baseline refresh: no cooldowns needed
169
+ 2026-01-23 16:12:09,973 - rotator_library - DEBUG - antigravity quota refresh: updated 6 model baselines
170
+ 2026-01-23 16:12:09,973 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: initial run complete
171
+ 2026-01-23 16:12:09,974 - rotator_library - DEBUG - ProviderCache[gemini3_signatures]: Started background tasks
172
+ 2026-01-23 16:12:09,974 - rotator_library - DEBUG - ProviderCache[claude_thinking]: Started background tasks
173
+ 2026-01-23 16:17:09,974 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
174
+ 2026-01-23 16:17:09,974 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
175
+ 2026-01-23 16:22:06,407 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)...
176
+ 2026-01-23 16:22:07,224 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json'
177
+ 2026-01-23 16:22:07,226 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'.
178
+ 2026-01-23 16:22:07,226 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'.
179
+ 2026-01-23 16:22:09,975 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
180
+ 2026-01-23 16:22:09,976 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
181
+ 2026-01-23 16:27:09,977 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
182
+ 2026-01-23 16:27:09,977 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
183
+ 2026-01-23 16:32:09,978 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
184
+ 2026-01-23 16:32:09,979 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
185
+ 2026-01-23 16:37:09,980 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
186
+ 2026-01-23 16:37:09,980 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
187
+ 2026-01-23 16:38:58,754 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/gemini-3-flash, reasoning_effort=None
188
+ 2026-01-23 16:38:58,755 - rotator_library - DEBUG - Credential priorities for antigravity: P2=1
189
+ 2026-01-23 16:38:58,756 - rotator_library - DEBUG - Could not lazy-load tier from /app/oauth_creds/antigravity_oauth_2.json: [Errno 2] No such file or directory: '/app/oauth_creds/antigravity_oauth_2.json'
190
+ 2026-01-23 16:38:58,757 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call.
191
+ 2026-01-23 16:38:58,758 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json
192
+ 2026-01-23 16:38:58,758 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46
193
+ 2026-01-23 16:39:02,594 - rotator_library - DEBUG - Skipping cost calculation for provider 'antigravity' (custom provider).
194
+ 2026-01-23 16:42:09,981 - rotator_library - DEBUG - Refreshing quota baselines for 1 recently active credentials
195
+ 2026-01-23 16:42:12,436 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/claude-sonnet-4.5: remaining=100.00%, synced_request_count=0
196
+ 2026-01-23 16:42:12,438 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/gemini-3-flash: remaining=100.00%, synced_request_count=2
197
+ 2026-01-23 16:42:12,440 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/gemini-2.5-flash-lite: remaining=100.00%, synced_request_count=0
198
+ 2026-01-23 16:42:12,444 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/gemini-2.5-flash: remaining=100.00%, synced_request_count=1
199
+ 2026-01-23 16:42:12,446 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/claude-opus-4.5: remaining=100.00%, synced_request_count=0
200
+ 2026-01-23 16:42:12,448 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/gemini-3-pro-preview: remaining=100.00%, synced_request_count=0
201
+ 2026-01-23 16:42:12,450 - rotator_library - DEBUG - Antigravity quota baseline refresh: no cooldowns needed
202
+ 2026-01-23 16:42:12,450 - rotator_library - DEBUG - antigravity quota refresh: updated 6 model baselines
203
+ 2026-01-23 16:42:12,451 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
204
+ 2026-01-23 16:47:12,452 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
205
+ 2026-01-23 16:47:12,452 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
206
+ 2026-01-23 16:52:06,409 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)...
207
+ 2026-01-23 16:52:07,231 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json'
208
+ 2026-01-23 16:52:07,233 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'.
209
+ 2026-01-23 16:52:07,233 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'.
210
+ 2026-01-23 16:52:12,453 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
211
+ 2026-01-23 16:52:12,454 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
212
+ 2026-01-24 03:36:39,099 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
213
+ 2026-01-24 03:36:39,099 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
214
+ 2026-01-24 03:41:33,054 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)...
215
+ 2026-01-24 03:41:33,814 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json'
216
+ 2026-01-24 03:41:33,816 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'.
217
+ 2026-01-24 03:41:33,816 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'.
218
+ 2026-01-24 03:41:39,102 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
219
+ 2026-01-24 03:41:39,102 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
220
+ 2026-01-24 03:46:39,103 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
221
+ 2026-01-24 03:46:39,103 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
222
+ 2026-01-24 03:51:39,104 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
223
+ 2026-01-24 03:51:39,104 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
224
+ 2026-01-24 03:56:39,105 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
225
+ 2026-01-24 03:56:39,105 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
226
+ 2026-01-24 04:01:39,107 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
227
+ 2026-01-24 04:01:39,107 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
228
+ 2026-01-24 04:06:39,108 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
229
+ 2026-01-24 04:06:39,108 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
230
+ 2026-01-24 07:04:03,801 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)...
231
+ 2026-01-24 07:04:04,545 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json'
232
+ 2026-01-24 07:04:04,547 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'.
233
+ 2026-01-24 07:04:04,547 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'.
234
+ 2026-01-24 07:04:09,855 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
235
+ 2026-01-24 07:04:09,855 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
236
+ 2026-01-24 07:09:09,856 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
237
+ 2026-01-24 07:09:09,856 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
238
+ 2026-01-24 07:14:09,857 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
239
+ 2026-01-24 07:14:09,857 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
240
+ 2026-01-24 07:19:15,130 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
241
+ 2026-01-24 07:19:15,130 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
242
+ 2026-01-24 07:24:15,131 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
243
+ 2026-01-24 07:24:15,131 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
244
+ 2026-01-24 07:29:15,132 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
245
+ 2026-01-24 07:29:15,132 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
246
+ 2026-01-24 07:35:14,550 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)...
247
+ 2026-01-24 07:35:15,430 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json'
248
+ 2026-01-24 07:35:15,432 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'.
249
+ 2026-01-24 07:35:15,432 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'.
250
+ 2026-01-24 07:35:20,607 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
251
+ 2026-01-24 07:35:20,608 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
252
+ 2026-01-24 07:40:20,610 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
253
+ 2026-01-24 07:40:20,610 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
254
+ 2026-01-24 07:45:20,612 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
255
+ 2026-01-24 07:45:20,612 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
256
+ 2026-01-24 07:50:20,613 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
257
+ 2026-01-24 07:50:20,613 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
258
+ 2026-01-24 07:55:20,615 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
259
+ 2026-01-24 07:55:20,615 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
260
+ 2026-01-24 08:00:20,616 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
261
+ 2026-01-24 08:00:20,616 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
262
+ 2026-01-24 08:05:14,554 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)...
263
+ 2026-01-24 08:05:15,298 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json'
264
+ 2026-01-24 08:05:15,300 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'.
265
+ 2026-01-24 08:05:15,300 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'.
266
+ 2026-01-24 08:05:20,617 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
267
+ 2026-01-24 08:05:20,618 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
268
+ 2026-01-24 08:10:20,619 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
269
+ 2026-01-24 08:10:20,619 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
270
+ 2026-01-24 08:15:20,620 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
271
+ 2026-01-24 08:15:20,621 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
272
+ 2026-01-24 08:20:20,621 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
273
+ 2026-01-24 08:20:20,622 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
274
+ 2026-01-24 08:25:20,622 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
275
+ 2026-01-24 08:25:20,623 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
276
+ 2026-01-24 08:30:20,623 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
277
+ 2026-01-24 08:30:20,624 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
278
+ 2026-01-24 08:35:14,557 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)...
279
+ 2026-01-24 08:35:15,319 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json'
280
+ 2026-01-24 08:35:15,541 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'.
281
+ 2026-01-24 08:35:15,541 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'.
282
+ 2026-01-24 08:35:20,624 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
283
+ 2026-01-24 08:35:20,624 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
284
+ 2026-01-24 08:40:20,625 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
285
+ 2026-01-24 08:40:20,625 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
286
+ 2026-01-24 08:45:20,626 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
287
+ 2026-01-24 08:45:20,627 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
288
+ 2026-01-24 08:50:20,627 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
289
+ 2026-01-24 08:50:21,005 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
290
+ 2026-01-24 08:55:21,005 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
291
+ 2026-01-24 08:55:21,006 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
292
+ 2026-01-24 09:00:21,006 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
293
+ 2026-01-24 09:00:21,008 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
294
+ 2026-01-24 09:05:14,560 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)...
295
+ 2026-01-24 09:05:15,874 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json'
296
+ 2026-01-24 09:05:16,123 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'.
297
+ 2026-01-24 09:05:16,123 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'.
298
+ 2026-01-24 09:05:21,009 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
299
+ 2026-01-24 09:05:21,009 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
300
+ 2026-01-24 09:10:21,010 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
301
+ 2026-01-24 09:10:21,010 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
302
+ 2026-01-24 09:15:21,011 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
303
+ 2026-01-24 09:15:21,011 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
304
+ 2026-01-24 09:20:21,012 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
305
+ 2026-01-24 09:20:21,012 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
306
+ 2026-01-24 09:25:21,013 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
307
+ 2026-01-24 09:25:21,014 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
308
+ 2026-01-24 09:30:21,015 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
309
+ 2026-01-24 09:30:21,015 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
310
+ 2026-01-24 09:35:14,562 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)...
311
+ 2026-01-24 09:35:15,530 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json'
312
+ 2026-01-24 09:35:15,619 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'.
313
+ 2026-01-24 09:35:15,619 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'.
314
+ 2026-01-24 09:35:21,016 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
315
+ 2026-01-24 09:35:21,016 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
316
+ 2026-01-24 09:40:21,087 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
317
+ 2026-01-24 09:40:21,088 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
318
+ 2026-01-24 09:45:21,088 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
319
+ 2026-01-24 09:45:21,091 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
320
+ 2026-01-24 09:50:21,091 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
321
+ 2026-01-24 09:50:21,091 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
322
+ 2026-01-24 09:55:21,093 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
323
+ 2026-01-24 09:55:21,093 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
324
+ 2026-01-24 10:00:21,094 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
325
+ 2026-01-24 10:00:21,094 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
326
+ 2026-01-24 10:05:14,568 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)...
327
+ 2026-01-24 10:05:15,410 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json'
328
+ 2026-01-24 10:05:15,460 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'.
329
+ 2026-01-24 10:05:15,460 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'.
330
+ 2026-01-24 10:05:21,095 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
331
+ 2026-01-24 10:05:21,096 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
332
+ 2026-01-24 10:10:21,097 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
333
+ 2026-01-24 10:10:21,097 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
334
+ 2026-01-24 10:15:21,101 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
335
+ 2026-01-24 10:15:21,101 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
336
+ 2026-01-24 10:20:21,102 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
337
+ 2026-01-24 10:20:21,102 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
338
+ 2026-01-24 10:25:21,103 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
339
+ 2026-01-24 10:25:21,103 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
340
+ 2026-01-24 10:30:21,104 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
341
+ 2026-01-24 10:30:21,105 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
342
+ 2026-01-24 10:35:14,571 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)...
343
+ 2026-01-24 10:35:15,333 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json'
344
+ 2026-01-24 10:35:15,531 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'.
345
+ 2026-01-24 10:35:15,531 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'.
346
+ 2026-01-24 10:35:21,106 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
347
+ 2026-01-24 10:35:21,107 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
348
+ 2026-01-24 10:40:21,108 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
349
+ 2026-01-24 10:40:21,108 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
350
+ 2026-01-24 10:45:21,109 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
351
+ 2026-01-24 10:45:21,109 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
352
+ 2026-01-24 10:50:21,110 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
353
+ 2026-01-24 10:50:21,110 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
354
+ 2026-01-24 10:55:21,110 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
355
+ 2026-01-24 10:55:21,111 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
356
+ 2026-01-24 11:00:21,113 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
357
+ 2026-01-24 11:00:21,115 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
358
+ 2026-01-24 11:05:14,575 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)...
359
+ 2026-01-24 11:05:15,395 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json'
360
+ 2026-01-24 11:05:15,397 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'.
361
+ 2026-01-24 11:05:15,397 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'.
362
+ 2026-01-24 11:05:21,116 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
363
+ 2026-01-24 11:05:21,116 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
364
+ 2026-01-24 11:10:21,117 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
365
+ 2026-01-24 11:10:21,118 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
366
+ 2026-01-24 11:15:21,119 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
367
+ 2026-01-24 11:15:21,120 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
368
+ 2026-01-24 11:18:44,059 - rotator_library - DEBUG - Attempting to get models for antigravity with credential antigravity_oauth_1.json
369
+ 2026-01-24 11:18:44,059 - rotator_library - DEBUG - Using hardcoded model list
370
+ 2026-01-24 11:20:21,121 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
371
+ 2026-01-24 11:20:21,121 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
372
+ 2026-01-24 11:23:14,659 - rotator_library - DEBUG - Returning cached models for provider: antigravity
373
+ 2026-01-24 11:25:21,121 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
374
+ 2026-01-24 11:25:21,122 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
375
+ 2026-01-24 11:30:21,122 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
376
+ 2026-01-24 11:30:21,123 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
377
+ 2026-01-24 11:32:50,245 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/gemini-3-flash, reasoning_effort=None
378
+ 2026-01-24 11:32:50,411 - rotator_library - DEBUG - Credential priorities for antigravity: P2=1
379
+ 2026-01-24 11:32:50,524 - rotator_library - DEBUG - Could not lazy-load tier from /app/oauth_creds/antigravity_oauth_2.json: [Errno 2] No such file or directory: '/app/oauth_creds/antigravity_oauth_2.json'
380
+ 2026-01-24 11:32:50,881 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call.
381
+ 2026-01-24 11:32:50,882 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json
382
+ 2026-01-24 11:32:50,883 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46
383
+ 2026-01-24 11:32:51,589 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/gemini-3-flash, reasoning_effort=None
384
+ 2026-01-24 11:32:51,591 - rotator_library - DEBUG - Credential priorities for antigravity: P2=1
385
+ 2026-01-24 11:32:51,598 - rotator_library - DEBUG - Could not lazy-load tier from /app/oauth_creds/antigravity_oauth_2.json: [Errno 2] No such file or directory: '/app/oauth_creds/antigravity_oauth_2.json'
386
+ 2026-01-24 11:32:51,599 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call.
387
+ 2026-01-24 11:32:51,599 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json
388
+ 2026-01-24 11:32:51,599 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46
389
+ 2026-01-24 11:32:51,607 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/claude-opus-4.5, reasoning_effort=None
390
+ 2026-01-24 11:32:51,608 - rotator_library - DEBUG - Credential priorities for antigravity: P2=1
391
+ 2026-01-24 11:32:51,609 - rotator_library - DEBUG - Could not lazy-load tier from /app/oauth_creds/antigravity_oauth_2.json: [Errno 2] No such file or directory: '/app/oauth_creds/antigravity_oauth_2.json'
392
+ 2026-01-24 11:32:51,610 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call.
393
+ 2026-01-24 11:32:51,610 - rotator_library - DEBUG - [Thinking Sanitization] thinking_enabled=True, in_tool_loop=False, turn_has_thinking=False, turn_start_idx=-1
394
+ 2026-01-24 11:32:51,610 - rotator_library - DEBUG - [Interleaved Thinking] Injected reminder to user message at index 0
395
+ 2026-01-24 11:32:51,611 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name)
396
+ 2026-01-24 11:32:51,611 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name)
397
+ 2026-01-24 11:32:51,611 - rotator_library - DEBUG - [Schema] Preserving property 'format' (matches validation keyword name)
398
+ 2026-01-24 11:32:51,612 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name)
399
+ 2026-01-24 11:32:51,612 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name)
400
+ 2026-01-24 11:32:51,612 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json
401
+ 2026-01-24 11:32:51,612 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46
402
+ 2026-01-24 11:32:54,552 - rotator_library - DEBUG - Skipping cost calculation for provider 'antigravity' (custom provider).
403
+ 2026-01-24 11:32:56,228 - rotator_library - DEBUG - Skipping cost calculation for provider 'antigravity' (custom provider).
404
+ 2026-01-24 11:32:57,435 - rotator_library - DEBUG - Skipping cost calculation for provider 'antigravity' (custom provider).
405
+ 2026-01-24 11:34:30,522 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/claude-opus-4.5, reasoning_effort=None
406
+ 2026-01-24 11:34:30,523 - rotator_library - DEBUG - Credential priorities for antigravity: P2=1
407
+ 2026-01-24 11:34:30,525 - rotator_library - DEBUG - Could not lazy-load tier from /app/oauth_creds/antigravity_oauth_2.json: [Errno 2] No such file or directory: '/app/oauth_creds/antigravity_oauth_2.json'
408
+ 2026-01-24 11:34:30,526 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call.
409
+ 2026-01-24 11:34:30,641 - rotator_library - DEBUG - [Thinking Sanitization] thinking_enabled=True, in_tool_loop=False, turn_has_thinking=False, turn_start_idx=-1
410
+ 2026-01-24 11:34:30,641 - rotator_library - DEBUG - [Interleaved Thinking] Injected reminder to user message at index 2
411
+ 2026-01-24 11:34:30,642 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name)
412
+ 2026-01-24 11:34:30,642 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name)
413
+ 2026-01-24 11:34:30,642 - rotator_library - DEBUG - [Schema] Preserving property 'format' (matches validation keyword name)
414
+ 2026-01-24 11:34:30,642 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name)
415
+ 2026-01-24 11:34:30,643 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name)
416
+ 2026-01-24 11:34:30,643 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json
417
+ 2026-01-24 11:34:30,644 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46
418
+ 2026-01-24 11:34:30,650 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/gemini-3-flash, reasoning_effort=None
419
+ 2026-01-24 11:34:30,652 - rotator_library - DEBUG - Credential priorities for antigravity: P2=1
420
+ 2026-01-24 11:34:30,653 - rotator_library - DEBUG - Could not lazy-load tier from /app/oauth_creds/antigravity_oauth_2.json: [Errno 2] No such file or directory: '/app/oauth_creds/antigravity_oauth_2.json'
421
+ 2026-01-24 11:34:30,654 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call.
422
+ 2026-01-24 11:34:30,654 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json
423
+ 2026-01-24 11:34:30,654 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46
424
+ 2026-01-24 11:34:33,408 - rotator_library - DEBUG - Skipping cost calculation for provider 'antigravity' (custom provider).
425
+ 2026-01-24 11:34:36,075 - rotator_library - DEBUG - Skipping cost calculation for provider 'antigravity' (custom provider).
426
+ 2026-01-24 11:34:38,581 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/claude-opus-4.5, reasoning_effort=None
427
+ 2026-01-24 11:34:38,582 - rotator_library - DEBUG - Credential priorities for antigravity: P2=1
428
+ 2026-01-24 11:34:38,583 - rotator_library - DEBUG - Could not lazy-load tier from /app/oauth_creds/antigravity_oauth_2.json: [Errno 2] No such file or directory: '/app/oauth_creds/antigravity_oauth_2.json'
429
+ 2026-01-24 11:34:38,584 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call.
430
+ 2026-01-24 11:34:38,585 - rotator_library - DEBUG - [Thinking Sanitization] thinking_enabled=True, in_tool_loop=True, turn_has_thinking=False, turn_start_idx=3
431
+ 2026-01-24 11:34:38,585 - rotator_library - DEBUG - [Interleaved Thinking] Injected reminder to user message at index 6
432
+ 2026-01-24 11:34:38,586 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name)
433
+ 2026-01-24 11:34:38,586 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name)
434
+ 2026-01-24 11:34:38,586 - rotator_library - DEBUG - [Schema] Preserving property 'format' (matches validation keyword name)
435
+ 2026-01-24 11:34:38,586 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name)
436
+ 2026-01-24 11:34:38,587 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name)
437
+ 2026-01-24 11:34:38,587 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json
438
+ 2026-01-24 11:34:38,588 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46
439
+ 2026-01-24 11:34:44,008 - rotator_library - DEBUG - Skipping cost calculation for provider 'antigravity' (custom provider).
440
+ 2026-01-24 11:35:14,577 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)...
441
+ 2026-01-24 11:35:15,337 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json'
442
+ 2026-01-24 11:35:15,448 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'.
443
+ 2026-01-24 11:35:15,449 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'.
444
+ 2026-01-24 11:35:21,124 - rotator_library - DEBUG - Refreshing quota baselines for 1 recently active credentials
445
+ 2026-01-24 11:35:21,806 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/gemini-2.5-flash: remaining=100.00%, synced_request_count=1
446
+ 2026-01-24 11:35:21,807 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/gemini-2.5-flash-lite: remaining=100.00%, synced_request_count=0
447
+ 2026-01-24 11:35:21,809 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/claude-sonnet-4.5: remaining=100.00%, synced_request_count=3
448
+ 2026-01-24 11:35:21,810 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/gemini-3-flash: remaining=100.00%, synced_request_count=3
449
+ 2026-01-24 11:35:21,812 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/gemini-3-pro-preview: remaining=100.00%, synced_request_count=0
450
+ 2026-01-24 11:35:21,813 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/claude-opus-4.5: remaining=100.00%, synced_request_count=3
451
+ 2026-01-24 11:35:21,814 - rotator_library - DEBUG - Antigravity quota baseline refresh: no cooldowns needed
452
+ 2026-01-24 11:35:21,815 - rotator_library - DEBUG - antigravity quota refresh: updated 6 model baselines
453
+ 2026-01-24 11:35:21,815 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
454
+ 2026-01-24 11:38:54,075 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/claude-opus-4.5, reasoning_effort=None
455
+ 2026-01-24 11:38:54,076 - rotator_library - DEBUG - Credential priorities for antigravity: P2=1
456
+ 2026-01-24 11:38:54,077 - rotator_library - DEBUG - Could not lazy-load tier from /app/oauth_creds/antigravity_oauth_2.json: [Errno 2] No such file or directory: '/app/oauth_creds/antigravity_oauth_2.json'
457
+ 2026-01-24 11:38:54,080 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call.
458
+ 2026-01-24 11:38:54,081 - rotator_library - DEBUG - [Thinking Sanitization] thinking_enabled=True, in_tool_loop=False, turn_has_thinking=False, turn_start_idx=-1
459
+ 2026-01-24 11:38:54,081 - rotator_library - DEBUG - [Interleaved Thinking] Injected reminder to user message at index 6
460
+ 2026-01-24 11:38:54,082 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name)
461
+ 2026-01-24 11:38:54,087 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name)
462
+ 2026-01-24 11:38:54,087 - rotator_library - DEBUG - [Schema] Preserving property 'format' (matches validation keyword name)
463
+ 2026-01-24 11:38:54,088 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name)
464
+ 2026-01-24 11:38:54,088 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name)
465
+ 2026-01-24 11:38:54,088 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json
466
+ 2026-01-24 11:38:54,089 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46
467
+ 2026-01-24 11:38:54,148 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/gemini-3-flash, reasoning_effort=None
468
+ 2026-01-24 11:38:54,150 - rotator_library - DEBUG - Credential priorities for antigravity: P2=1
469
+ 2026-01-24 11:38:54,151 - rotator_library - DEBUG - Could not lazy-load tier from /app/oauth_creds/antigravity_oauth_2.json: [Errno 2] No such file or directory: '/app/oauth_creds/antigravity_oauth_2.json'
470
+ 2026-01-24 11:38:54,152 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call.
471
+ 2026-01-24 11:38:54,153 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json
472
+ 2026-01-24 11:38:54,153 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46
473
+ 2026-01-24 11:38:57,000 - rotator_library - DEBUG - Skipping cost calculation for provider 'antigravity' (custom provider).
474
+ 2026-01-24 11:38:58,818 - rotator_library - DEBUG - Skipping cost calculation for provider 'antigravity' (custom provider).
475
+ 2026-01-24 11:39:23,427 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/gemini-3-flash, reasoning_effort=None
476
+ 2026-01-24 11:39:23,428 - rotator_library - DEBUG - Credential priorities for antigravity: P2=1
477
+ 2026-01-24 11:39:23,429 - rotator_library - DEBUG - Could not lazy-load tier from /app/oauth_creds/antigravity_oauth_2.json: [Errno 2] No such file or directory: '/app/oauth_creds/antigravity_oauth_2.json'
478
+ 2026-01-24 11:39:23,430 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call.
479
+ 2026-01-24 11:39:23,431 - rotator_library - DEBUG - Missing thoughtSignature for first func call toolu_vrtx_01AxDghXwH4PMXNES8HtfNv9, using bypass
480
+ 2026-01-24 11:39:23,431 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name)
481
+ 2026-01-24 11:39:23,431 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name)
482
+ 2026-01-24 11:39:23,431 - rotator_library - DEBUG - [Schema] Preserving property 'format' (matches validation keyword name)
483
+ 2026-01-24 11:39:23,432 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name)
484
+ 2026-01-24 11:39:23,432 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name)
485
+ 2026-01-24 11:39:23,440 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json
486
+ 2026-01-24 11:39:23,441 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46
487
+ 2026-01-24 11:39:23,450 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/gemini-3-flash, reasoning_effort=None
488
+ 2026-01-24 11:39:23,451 - rotator_library - DEBUG - Credential priorities for antigravity: P2=1
489
+ 2026-01-24 11:39:23,452 - rotator_library - DEBUG - Could not lazy-load tier from /app/oauth_creds/antigravity_oauth_2.json: [Errno 2] No such file or directory: '/app/oauth_creds/antigravity_oauth_2.json'
490
+ 2026-01-24 11:39:23,453 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call.
491
+ 2026-01-24 11:39:23,454 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json
492
+ 2026-01-24 11:39:23,454 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46
493
+ 2026-01-24 11:39:25,863 - rotator_library - DEBUG - Skipping cost calculation for provider 'antigravity' (custom provider).
494
+ 2026-01-24 11:39:27,283 - rotator_library - DEBUG - Skipping cost calculation for provider 'antigravity' (custom provider).
495
+ 2026-01-24 11:40:21,816 - rotator_library - DEBUG - Refreshing quota baselines for 1 recently active credentials
496
+ 2026-01-24 11:40:22,455 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/gemini-3-pro-preview: remaining=100.00%, synced_request_count=0
497
+ 2026-01-24 11:40:22,456 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/gemini-2.5-flash-lite: remaining=100.00%, synced_request_count=0
498
+ 2026-01-24 11:40:22,475 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/gemini-2.5-flash: remaining=100.00%, synced_request_count=1
499
+ 2026-01-24 11:40:22,477 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/gemini-3-flash: remaining=100.00%, synced_request_count=6
500
+ 2026-01-24 11:40:22,479 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/claude-sonnet-4.5: remaining=100.00%, synced_request_count=4
501
+ 2026-01-24 11:40:22,480 - rotator_library - DEBUG - Updated quota baseline for antigravity_oauth_1.json model=antigravity/claude-opus-4.5: remaining=100.00%, synced_request_count=4
502
+ 2026-01-24 11:40:22,612 - rotator_library - DEBUG - Antigravity quota baseline refresh: no cooldowns needed
503
+ 2026-01-24 11:40:22,613 - rotator_library - DEBUG - antigravity quota refresh: updated 6 model baselines
504
+ 2026-01-24 11:40:22,613 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
505
+ 2026-01-24 11:45:22,613 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
506
+ 2026-01-24 11:45:22,613 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
507
+ 2026-01-24 11:50:22,617 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
508
+ 2026-01-24 11:50:22,617 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
509
+ 2026-01-24 11:55:22,618 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
510
+ 2026-01-24 11:55:22,620 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
511
+ 2026-01-24 12:00:22,621 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
512
+ 2026-01-24 12:00:22,621 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
513
+ 2026-01-24 12:05:14,583 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)...
514
+ 2026-01-24 12:05:15,498 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json'
515
+ 2026-01-24 12:05:15,500 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'.
516
+ 2026-01-24 12:05:15,500 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'.
517
+ 2026-01-24 12:05:22,621 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
518
+ 2026-01-24 12:05:22,622 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
519
+ 2026-01-24 12:10:22,622 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
520
+ 2026-01-24 12:10:22,623 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
521
+ 2026-01-24 12:15:22,624 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
522
+ 2026-01-24 12:15:22,624 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
523
+ 2026-01-24 12:20:22,625 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
524
+ 2026-01-24 12:20:22,625 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
525
+ 2026-01-24 12:25:22,626 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
526
+ 2026-01-24 12:25:22,627 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
527
+ 2026-01-24 12:30:22,627 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
528
+ 2026-01-24 12:30:22,627 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
529
+ 2026-01-24 12:35:14,587 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)...
530
+ 2026-01-24 12:35:15,520 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json'
531
+ 2026-01-24 12:35:15,521 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'.
532
+ 2026-01-24 12:35:15,522 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'.
533
+ 2026-01-24 12:35:22,628 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
534
+ 2026-01-24 12:35:22,628 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
535
+ 2026-01-24 12:40:22,630 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
536
+ 2026-01-24 12:40:22,630 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
537
+ 2026-01-24 12:45:22,631 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
538
+ 2026-01-24 12:45:22,632 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
539
+ 2026-01-24 12:50:22,632 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
540
+ 2026-01-24 12:50:22,632 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
541
+ 2026-01-24 12:55:22,634 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
542
+ 2026-01-24 12:55:22,636 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
543
+ 2026-01-24 13:00:22,637 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
544
+ 2026-01-24 13:00:22,638 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
545
+ 2026-01-24 13:05:14,590 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)...
546
+ 2026-01-24 13:05:15,435 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json'
547
+ 2026-01-24 13:05:15,437 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'.
548
+ 2026-01-24 13:05:15,437 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'.
549
+ 2026-01-24 13:05:22,638 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
550
+ 2026-01-24 13:05:22,639 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
551
+ 2026-01-24 13:10:22,640 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
552
+ 2026-01-24 13:10:22,640 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
553
+ 2026-01-24 13:15:22,641 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
554
+ 2026-01-24 13:15:22,642 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
555
+ 2026-01-24 13:20:27,937 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
556
+ 2026-01-24 13:20:27,937 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
557
+ 2026-01-24 13:25:27,941 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
558
+ 2026-01-24 13:25:27,941 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
559
+ 2026-01-24 13:30:27,941 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
560
+ 2026-01-24 13:30:27,941 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
561
+ 2026-01-24 13:35:19,887 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)...
562
+ 2026-01-24 13:35:20,806 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json'
563
+ 2026-01-24 13:35:20,807 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'.
564
+ 2026-01-24 13:35:20,807 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'.
565
+ 2026-01-24 13:35:27,942 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
566
+ 2026-01-24 13:35:27,942 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
567
+ 2026-01-24 13:40:27,943 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
568
+ 2026-01-24 13:40:27,944 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
569
+ 2026-01-24 13:45:27,945 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
570
+ 2026-01-24 13:45:27,946 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
571
+ 2026-01-24 13:50:27,946 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
572
+ 2026-01-24 13:50:27,951 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
573
+ 2026-01-24 13:55:27,951 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
574
+ 2026-01-24 13:55:27,951 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
575
+ 2026-01-24 14:00:27,952 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
576
+ 2026-01-24 14:00:27,952 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
577
+ 2026-01-24 14:05:19,891 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)...
578
+ 2026-01-24 14:05:20,652 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json'
579
+ 2026-01-24 14:05:20,655 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'.
580
+ 2026-01-24 14:05:20,655 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'.
581
+ 2026-01-24 14:05:27,952 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
582
+ 2026-01-24 14:05:27,952 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
583
+ 2026-01-24 14:10:27,953 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
584
+ 2026-01-24 14:10:27,953 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
585
+ 2026-01-24 14:15:27,954 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
586
+ 2026-01-24 14:15:27,957 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
587
+ 2026-01-24 14:20:27,958 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
588
+ 2026-01-24 14:20:27,959 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
589
+ 2026-01-24 14:25:27,960 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
590
+ 2026-01-24 14:25:27,960 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
591
+ 2026-01-24 14:30:27,961 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
592
+ 2026-01-24 14:30:27,961 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
593
+ 2026-01-24 14:35:19,895 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)...
594
+ 2026-01-24 14:35:20,722 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json'
595
+ 2026-01-24 14:35:20,723 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'.
596
+ 2026-01-24 14:35:20,723 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'.
597
+ 2026-01-24 14:35:27,962 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
598
+ 2026-01-24 14:35:27,964 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
599
+ 2026-01-24 14:40:27,964 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
600
+ 2026-01-24 14:40:27,965 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
601
+ 2026-01-24 14:45:27,966 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
602
+ 2026-01-24 14:45:27,966 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
603
+ 2026-01-24 14:50:27,967 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
604
+ 2026-01-24 14:50:27,970 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
605
+ 2026-01-24 14:55:27,971 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
606
+ 2026-01-24 14:55:27,971 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
607
+ 2026-01-24 15:00:27,972 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
608
+ 2026-01-24 15:00:27,972 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
609
+ 2026-01-24 15:05:19,897 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)...
610
+ 2026-01-24 15:05:20,696 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json'
611
+ 2026-01-24 15:05:20,697 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'.
612
+ 2026-01-24 15:05:20,697 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'.
613
+ 2026-01-24 15:05:27,973 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
614
+ 2026-01-24 15:05:27,973 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
615
+ 2026-01-24 15:10:27,974 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
616
+ 2026-01-24 15:10:27,974 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
617
+ 2026-01-24 15:15:27,975 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
618
+ 2026-01-24 15:15:27,977 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
619
+ 2026-01-24 15:20:27,977 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
620
+ 2026-01-24 15:20:27,977 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
621
+ 2026-01-24 15:25:27,978 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
622
+ 2026-01-24 15:25:27,978 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
623
+ 2026-01-24 15:30:27,979 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
624
+ 2026-01-24 15:30:27,980 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
625
+ 2026-01-24 15:35:19,899 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)...
626
+ 2026-01-24 15:35:20,685 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json'
627
+ 2026-01-24 15:35:20,686 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'.
628
+ 2026-01-24 15:35:20,686 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'.
629
+ 2026-01-24 15:35:27,982 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
630
+ 2026-01-24 15:35:27,982 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
631
+ 2026-01-24 15:40:27,983 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
632
+ 2026-01-24 15:40:27,983 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
633
+ 2026-01-24 15:45:27,984 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
634
+ 2026-01-24 15:45:27,984 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
635
+ 2026-01-24 15:50:27,985 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
636
+ 2026-01-24 15:50:27,987 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
637
+ 2026-01-24 15:55:27,988 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
638
+ 2026-01-24 15:55:27,988 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
639
+ 2026-01-24 16:00:27,989 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
640
+ 2026-01-24 16:00:27,989 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
641
+ 2026-01-24 16:05:19,902 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)...
642
+ 2026-01-24 16:05:20,610 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json'
643
+ 2026-01-24 16:05:20,613 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'.
644
+ 2026-01-24 16:05:20,613 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'.
645
+ 2026-01-24 16:05:27,990 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
646
+ 2026-01-24 16:05:27,990 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
647
+ 2026-01-24 16:10:27,991 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
648
+ 2026-01-24 16:10:27,992 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
649
+ 2026-01-24 16:15:27,993 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
650
+ 2026-01-24 16:15:27,997 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
651
+ 2026-01-24 16:20:27,999 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
652
+ 2026-01-24 16:20:27,999 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
653
+ 2026-01-24 16:25:28,000 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
654
+ 2026-01-24 16:25:28,000 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
655
+ 2026-01-24 16:30:28,001 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
656
+ 2026-01-24 16:30:28,001 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
657
+ 2026-01-24 16:35:19,905 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)...
658
+ 2026-01-24 16:35:20,764 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json'
659
+ 2026-01-24 16:35:20,765 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'.
660
+ 2026-01-24 16:35:20,766 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'.
661
+ 2026-01-24 16:35:28,003 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
662
+ 2026-01-24 16:35:28,004 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
663
+ 2026-01-24 16:40:28,005 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
664
+ 2026-01-24 16:40:28,005 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
665
+ 2026-01-24 16:45:28,006 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
666
+ 2026-01-24 16:45:28,006 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
667
+ 2026-01-24 16:50:28,007 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
668
+ 2026-01-24 16:50:28,008 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
669
+ 2026-01-24 16:55:28,008 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
670
+ 2026-01-24 16:55:28,008 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
671
+ 2026-01-24 17:00:28,009 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
672
+ 2026-01-24 17:00:28,009 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
673
+ 2026-01-24 17:05:19,906 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)...
674
+ 2026-01-24 17:05:28,010 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
675
+ 2026-01-24 17:05:28,014 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
676
+ 2026-01-24 17:06:04,909 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)...
677
+ 2026-01-24 17:06:49,911 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)...
678
+ 2026-01-24 17:10:28,015 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines
679
+ 2026-01-24 17:10:28,016 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete
requirements.txt ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FastAPI framework for building the proxy server
2
+ fastapi
3
+ # ASGI server for running the FastAPI application
4
+ uvicorn
5
+ # For loading environment variables from a .env file
6
+ python-dotenv
7
+
8
+ # Installs the local rotator_library in editable mode
9
+ -e src/rotator_library
10
+
11
+ # A library for calling LLM APIs with a consistent format
12
+ litellm
13
+
14
+ filelock
15
+ httpx
16
+ aiofiles
17
+ aiohttp
18
+
19
+ colorlog
20
+
21
+ rich
22
+
23
+ # GUI for model filter configuration
24
+ customtkinter
25
+
26
+ # For building the executable
27
+ pyinstaller
src/batch_auth.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: LGPL-3.0-only
2
+ # Copyright (c) 2026 Mirrowel
3
+
4
+ import asyncio
5
+ import sys
6
+ import argparse
7
+ from pathlib import Path
8
+
9
+ # Add the 'src' directory to the Python path
10
+ sys.path.append(str(Path(__file__).resolve().parent))
11
+
12
+ from rotator_library import provider_factory
13
+
14
+ async def main():
15
+ parser = argparse.ArgumentParser(description="Batch authorize multiple Google OAuth accounts.")
16
+ parser.add_argument("emails", nargs="+", help="List of Gmail addresses to authorize.")
17
+ parser.add_argument("--provider", default="antigravity", help="Provider to authorize (default: antigravity).")
18
+ args = parser.parse_args()
19
+
20
+ auth_class = provider_factory.get_provider_auth_class(args.provider)
21
+ auth_instance = auth_class()
22
+
23
+ print(f"🚀 Starting batch authorization for {len(args.emails)} accounts on {args.provider}...")
24
+
25
+ for email in args.emails:
26
+ print(f"\n🔑 Setting up: {email}")
27
+ result = await auth_instance.setup_credential(login_hint=email)
28
+
29
+ if result.success:
30
+ print(f"✅ Success! Saved to: {Path(result.file_path).name}")
31
+ if result.is_update:
32
+ print(f"ℹ️ Updated existing credential for {result.email}")
33
+ else:
34
+ print(f"❌ Failed: {result.error}")
35
+
36
+ print("\n✨ Batch authorization complete!")
37
+ print("👉 Now run 'python -m rotator_library.credential_tool' and choose 'Export to .env' to get your tokens.")
38
+
39
+ if __name__ == "__main__":
40
+ asyncio.run(main())
src/proxy_app/LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Mirrowel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
src/proxy_app/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Mirrowel
3
+
src/proxy_app/batch_manager.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Mirrowel
3
+
4
+ import asyncio
5
+ from typing import List, Dict, Any, Tuple
6
+ import time
7
+ from rotator_library import RotatingClient
8
+
9
+ class EmbeddingBatcher:
10
+ def __init__(self, client: RotatingClient, batch_size: int = 64, timeout: float = 0.1):
11
+ self.client = client
12
+ self.batch_size = batch_size
13
+ self.timeout = timeout
14
+ self.queue = asyncio.Queue()
15
+ self.worker_task = asyncio.create_task(self._batch_worker())
16
+
17
+ async def add_request(self, request_data: Dict[str, Any]) -> Any:
18
+ future = asyncio.Future()
19
+ await self.queue.put((request_data, future))
20
+ return await future
21
+
22
+ async def _batch_worker(self):
23
+ while True:
24
+ batch, futures = await self._gather_batch()
25
+ if not batch:
26
+ continue
27
+
28
+ try:
29
+ # Assume all requests in a batch use the same model and other settings
30
+ model = batch[0]["model"]
31
+ inputs = [item["input"][0] for item in batch] # Extract single string input
32
+
33
+ batched_request = {
34
+ "model": model,
35
+ "input": inputs
36
+ }
37
+
38
+ # Pass through any other relevant parameters from the first request
39
+ for key in ["input_type", "dimensions", "user"]:
40
+ if key in batch[0]:
41
+ batched_request[key] = batch[0][key]
42
+
43
+ response = await self.client.aembedding(**batched_request)
44
+
45
+ # Distribute results back to the original requesters
46
+ for i, future in enumerate(futures):
47
+ # Create a new response object for each item in the batch
48
+ single_response_data = {
49
+ "object": response.object,
50
+ "model": response.model,
51
+ "data": [response.data[i]],
52
+ "usage": response.usage # Usage is for the whole batch
53
+ }
54
+ future.set_result(single_response_data)
55
+
56
+ except Exception as e:
57
+ for future in futures:
58
+ future.set_exception(e)
59
+
60
+ async def _gather_batch(self) -> Tuple[List[Dict[str, Any]], List[asyncio.Future]]:
61
+ batch = []
62
+ futures = []
63
+ start_time = time.time()
64
+
65
+ while len(batch) < self.batch_size and (time.time() - start_time) < self.timeout:
66
+ try:
67
+ # Wait for an item with a timeout
68
+ timeout = self.timeout - (time.time() - start_time)
69
+ if timeout <= 0:
70
+ break
71
+ request, future = await asyncio.wait_for(self.queue.get(), timeout=timeout)
72
+ batch.append(request)
73
+ futures.append(future)
74
+ except asyncio.TimeoutError:
75
+ break
76
+
77
+ return batch, futures
78
+
79
+ async def stop(self):
80
+ self.worker_task.cancel()
81
+ try:
82
+ await self.worker_task
83
+ except asyncio.CancelledError:
84
+ pass
src/proxy_app/build.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Mirrowel
3
+
4
+ import os
5
+ import sys
6
+ import platform
7
+ import subprocess
8
+
9
+
10
+ def get_providers():
11
+ """
12
+ Scans the 'src/rotator_library/providers' directory to find all provider modules.
13
+ Returns a list of hidden import arguments for PyInstaller.
14
+ """
15
+ hidden_imports = []
16
+ # Get the absolute path to the directory containing this script
17
+ script_dir = os.path.dirname(os.path.abspath(__file__))
18
+ # Construct the path to the providers directory relative to this script's location
19
+ providers_path = os.path.join(script_dir, "..", "rotator_library", "providers")
20
+
21
+ if not os.path.isdir(providers_path):
22
+ print(f"Error: Directory not found at '{os.path.abspath(providers_path)}'")
23
+ return []
24
+
25
+ for filename in os.listdir(providers_path):
26
+ if filename.endswith("_provider.py") and filename != "__init__.py":
27
+ module_name = f"rotator_library.providers.{filename[:-3]}"
28
+ hidden_imports.append(f"--hidden-import={module_name}")
29
+ return hidden_imports
30
+
31
+
32
+ def main():
33
+ """
34
+ Constructs and runs the PyInstaller command to build the executable.
35
+ """
36
+ # Base PyInstaller command with optimizations
37
+ command = [
38
+ sys.executable,
39
+ "-m",
40
+ "PyInstaller",
41
+ "--onefile",
42
+ "--name",
43
+ "proxy_app",
44
+ "--paths",
45
+ "../",
46
+ "--paths",
47
+ ".",
48
+ # Core imports
49
+ "--hidden-import=rotator_library",
50
+ "--hidden-import=tiktoken_ext.openai_public",
51
+ "--hidden-import=tiktoken_ext",
52
+ "--collect-data",
53
+ "litellm",
54
+ # Optimization: Exclude unused heavy modules
55
+ "--exclude-module=matplotlib",
56
+ "--exclude-module=IPython",
57
+ "--exclude-module=jupyter",
58
+ "--exclude-module=notebook",
59
+ "--exclude-module=PIL.ImageTk",
60
+ # Optimization: Enable UPX compression (if available)
61
+ "--upx-dir=upx"
62
+ if platform.system() != "Darwin"
63
+ else "--noupx", # macOS has issues with UPX
64
+ # Optimization: Strip debug symbols (smaller binary)
65
+ "--strip"
66
+ if platform.system() != "Windows"
67
+ else "--console", # Windows gets clean console
68
+ ]
69
+
70
+ # Add hidden imports for providers
71
+ provider_imports = get_providers()
72
+ if not provider_imports:
73
+ print(
74
+ "Warning: No providers found. The build might not include any LLM providers."
75
+ )
76
+ command.extend(provider_imports)
77
+
78
+ # Add the main script
79
+ command.append("main.py")
80
+
81
+ # Execute the command
82
+ print(f"Running command: {' '.join(command)}")
83
+ try:
84
+ # Run PyInstaller from the script's directory to ensure relative paths are correct
85
+ script_dir = os.path.dirname(os.path.abspath(__file__))
86
+ subprocess.run(command, check=True, cwd=script_dir)
87
+ print("Build successful!")
88
+ except subprocess.CalledProcessError as e:
89
+ print(f"Build failed with error: {e}")
90
+ except FileNotFoundError:
91
+ print("Error: PyInstaller is not installed or not in the system's PATH.")
92
+
93
+
94
+ if __name__ == "__main__":
95
+ main()
src/proxy_app/detailed_logger.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Mirrowel
3
+
4
+ # src/proxy_app/detailed_logger.py
5
+ """
6
+ Raw I/O Logger for the Proxy Layer.
7
+
8
+ This logger captures the UNMODIFIED HTTP request and response at the proxy boundary.
9
+ It is disabled by default and should only be enabled for debugging the proxy itself.
10
+
11
+ Use this when you need to:
12
+ - Verify that requests/responses are not being corrupted
13
+ - Debug HTTP-level issues between the client and proxy
14
+ - Capture exact payloads as received/sent by the proxy
15
+
16
+ For normal request/response logging with provider correlation, use the
17
+ TransactionLogger in the rotator_library instead (enabled via --enable-request-logging).
18
+
19
+ Directory structure:
20
+ logs/raw_io/{YYYYMMDD_HHMMSS}_{request_id}/
21
+ request.json # Unmodified incoming HTTP request
22
+ streaming_chunks.jsonl # If streaming mode
23
+ final_response.json # Unmodified outgoing HTTP response
24
+ metadata.json # Summary metadata
25
+ """
26
+
27
+ import json
28
+ import time
29
+ import uuid
30
+ from datetime import datetime
31
+ from pathlib import Path
32
+ from typing import Any, Dict, Optional
33
+ import logging
34
+
35
+ from rotator_library.utils.resilient_io import (
36
+ safe_write_json,
37
+ safe_log_write,
38
+ safe_mkdir,
39
+ )
40
+ from rotator_library.utils.paths import get_logs_dir
41
+
42
+
43
+ def _get_raw_io_logs_dir() -> Path:
44
+ """Get the raw I/O logs directory, creating it if needed."""
45
+ logs_dir = get_logs_dir()
46
+ raw_io_dir = logs_dir / "raw_io"
47
+ raw_io_dir.mkdir(parents=True, exist_ok=True)
48
+ return raw_io_dir
49
+
50
+
51
+ class RawIOLogger:
52
+ """
53
+ Logs raw HTTP request/response at the proxy boundary.
54
+
55
+ This captures the EXACT data as received from and sent to the client,
56
+ without any transformations. Useful for debugging the proxy itself.
57
+
58
+ DISABLED by default. Enable with --enable-raw-logging flag.
59
+
60
+ Uses fire-and-forget logging - if disk writes fail, logs are dropped (not buffered)
61
+ to prevent memory issues, especially with streaming responses.
62
+ """
63
+
64
+ def __init__(self):
65
+ """
66
+ Initializes the logger for a single request, creating a unique directory
67
+ to store all related log files.
68
+ """
69
+ self.start_time = time.time()
70
+ self.request_id = str(uuid.uuid4())
71
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
72
+ self.log_dir = _get_raw_io_logs_dir() / f"{timestamp}_{self.request_id}"
73
+ self.streaming = False
74
+ self._dir_available = safe_mkdir(self.log_dir, logging)
75
+
76
+ def _write_json(self, filename: str, data: Dict[str, Any]):
77
+ """Helper to write data to a JSON file in the log directory."""
78
+ if not self._dir_available:
79
+ # Try to create directory again in case it was recreated
80
+ self._dir_available = safe_mkdir(self.log_dir, logging)
81
+ if not self._dir_available:
82
+ return
83
+
84
+ safe_write_json(
85
+ self.log_dir / filename,
86
+ data,
87
+ logging,
88
+ atomic=False,
89
+ indent=4,
90
+ ensure_ascii=False,
91
+ )
92
+
93
+ def log_request(self, headers: Dict[str, Any], body: Dict[str, Any]):
94
+ """Logs the raw incoming request details."""
95
+ self.streaming = body.get("stream", False)
96
+ request_data = {
97
+ "request_id": self.request_id,
98
+ "timestamp_utc": datetime.utcnow().isoformat(),
99
+ "headers": dict(headers),
100
+ "body": body,
101
+ }
102
+ self._write_json("request.json", request_data)
103
+
104
+ def log_stream_chunk(self, chunk: Dict[str, Any]):
105
+ """Logs an individual chunk from a streaming response to a JSON Lines file."""
106
+ if not self._dir_available:
107
+ return
108
+
109
+ log_entry = {"timestamp_utc": datetime.utcnow().isoformat(), "chunk": chunk}
110
+ content = json.dumps(log_entry, ensure_ascii=False) + "\n"
111
+ safe_log_write(self.log_dir / "streaming_chunks.jsonl", content, logging)
112
+
113
+ def log_final_response(
114
+ self, status_code: int, headers: Optional[Dict[str, Any]], body: Dict[str, Any]
115
+ ):
116
+ """Logs the raw outgoing response."""
117
+ end_time = time.time()
118
+ duration_ms = (end_time - self.start_time) * 1000
119
+
120
+ response_data = {
121
+ "request_id": self.request_id,
122
+ "timestamp_utc": datetime.utcnow().isoformat(),
123
+ "status_code": status_code,
124
+ "duration_ms": round(duration_ms),
125
+ "headers": dict(headers) if headers else None,
126
+ "body": body,
127
+ }
128
+ self._write_json("final_response.json", response_data)
129
+ self._log_metadata(response_data)
130
+
131
+ def _extract_reasoning(self, response_body: Dict[str, Any]) -> Optional[str]:
132
+ """Recursively searches for and extracts 'reasoning' fields from the response body."""
133
+ if not isinstance(response_body, dict):
134
+ return None
135
+
136
+ if "reasoning" in response_body:
137
+ return response_body["reasoning"]
138
+
139
+ if "choices" in response_body and response_body["choices"]:
140
+ message = response_body["choices"][0].get("message", {})
141
+ if "reasoning" in message:
142
+ return message["reasoning"]
143
+ if "reasoning_content" in message:
144
+ return message["reasoning_content"]
145
+
146
+ return None
147
+
148
+ def _log_metadata(self, response_data: Dict[str, Any]):
149
+ """Logs a summary of the transaction for quick analysis."""
150
+ usage = response_data.get("body", {}).get("usage") or {}
151
+ model = response_data.get("body", {}).get("model", "N/A")
152
+ finish_reason = "N/A"
153
+ if (
154
+ "choices" in response_data.get("body", {})
155
+ and response_data["body"]["choices"]
156
+ ):
157
+ finish_reason = response_data["body"]["choices"][0].get(
158
+ "finish_reason", "N/A"
159
+ )
160
+
161
+ metadata = {
162
+ "request_id": self.request_id,
163
+ "timestamp_utc": response_data["timestamp_utc"],
164
+ "duration_ms": response_data["duration_ms"],
165
+ "status_code": response_data["status_code"],
166
+ "model": model,
167
+ "streaming": self.streaming,
168
+ "usage": {
169
+ "prompt_tokens": usage.get("prompt_tokens"),
170
+ "completion_tokens": usage.get("completion_tokens"),
171
+ "total_tokens": usage.get("total_tokens"),
172
+ },
173
+ "finish_reason": finish_reason,
174
+ "reasoning_found": False,
175
+ "reasoning_content": None,
176
+ }
177
+
178
+ reasoning = self._extract_reasoning(response_data.get("body", {}))
179
+ if reasoning:
180
+ metadata["reasoning_found"] = True
181
+ metadata["reasoning_content"] = reasoning
182
+
183
+ self._write_json("metadata.json", metadata)
184
+
185
+
186
+ # Backward compatibility alias
187
+ DetailedLogger = RawIOLogger
src/proxy_app/launcher_tui.py ADDED
@@ -0,0 +1,1084 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Mirrowel
3
+
4
+ """
5
+ Interactive TUI launcher for the LLM API Key Proxy.
6
+ Provides a beautiful Rich-based interface for configuration and execution.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import sys
12
+ from pathlib import Path
13
+ from rich.console import Console
14
+ from rich.prompt import IntPrompt, Prompt
15
+ from rich.panel import Panel
16
+ from rich.text import Text
17
+ from dotenv import load_dotenv, set_key
18
+
19
+ console = Console()
20
+
21
+
22
+ def _get_env_file() -> Path:
23
+ """
24
+ Get .env file path (lightweight - no heavy imports).
25
+
26
+ Returns:
27
+ Path to .env file - EXE directory if frozen, else current working directory
28
+ """
29
+ if getattr(sys, "frozen", False):
30
+ # Running as PyInstaller EXE - use EXE's directory
31
+ return Path(sys.executable).parent / ".env"
32
+ # Running as script - use current working directory
33
+ return Path.cwd() / ".env"
34
+
35
+
36
+ def clear_screen(subtitle: str = ""):
37
+ """
38
+ Cross-platform terminal clear with optional header.
39
+
40
+ Uses native OS commands instead of ANSI escape sequences:
41
+ - Windows (conhost & Windows Terminal): cls
42
+ - Unix-like systems (Linux, Mac): clear
43
+
44
+ Args:
45
+ subtitle: If provided, displays a header panel with this subtitle.
46
+ If empty/None, just clears the screen.
47
+ """
48
+ os.system("cls" if os.name == "nt" else "clear")
49
+ if subtitle:
50
+ console.print(
51
+ Panel(
52
+ f"[bold cyan]{subtitle}[/bold cyan]",
53
+ title="--- API Key Proxy ---",
54
+ )
55
+ )
56
+
57
+
58
+ class LauncherConfig:
59
+ """Manages launcher_config.json (host, port, logging only)"""
60
+
61
+ def __init__(self, config_path: Path = Path("launcher_config.json")):
62
+ self.config_path = config_path
63
+ self.defaults = {
64
+ "host": "127.0.0.1",
65
+ "port": 8000,
66
+ "enable_request_logging": False,
67
+ "enable_raw_logging": False,
68
+ }
69
+ self.config = self.load()
70
+
71
+ def load(self) -> dict:
72
+ """Load config from file or create with defaults."""
73
+ if self.config_path.exists():
74
+ try:
75
+ with open(self.config_path, "r") as f:
76
+ config = json.load(f)
77
+ # Merge with defaults for any missing keys
78
+ for key, value in self.defaults.items():
79
+ if key not in config:
80
+ config[key] = value
81
+ return config
82
+ except (json.JSONDecodeError, IOError):
83
+ return self.defaults.copy()
84
+ return self.defaults.copy()
85
+
86
+ def save(self):
87
+ """Save current config to file."""
88
+ import datetime
89
+
90
+ self.config["last_updated"] = datetime.datetime.now().isoformat()
91
+ try:
92
+ with open(self.config_path, "w") as f:
93
+ json.dump(self.config, f, indent=2)
94
+ except IOError as e:
95
+ console.print(f"[red]Error saving config: {e}[/red]")
96
+
97
+ def update(self, **kwargs):
98
+ """Update config values."""
99
+ self.config.update(kwargs)
100
+ self.save()
101
+
102
+ @staticmethod
103
+ def update_proxy_api_key(new_key: str):
104
+ """Update PROXY_API_KEY in .env only"""
105
+ env_file = _get_env_file()
106
+ set_key(str(env_file), "PROXY_API_KEY", new_key)
107
+ load_dotenv(dotenv_path=env_file, override=True)
108
+
109
+
110
+ class SettingsDetector:
111
+ """Detects settings from .env for display"""
112
+
113
+ @staticmethod
114
+ def _load_local_env() -> dict:
115
+ """Load environment variables from local .env file only"""
116
+ env_file = _get_env_file()
117
+ env_dict = {}
118
+ if not env_file.exists():
119
+ return env_dict
120
+ try:
121
+ with open(env_file, "r", encoding="utf-8") as f:
122
+ for line in f:
123
+ line = line.strip()
124
+ if not line or line.startswith("#"):
125
+ continue
126
+ if "=" in line:
127
+ key, _, value = line.partition("=")
128
+ key, value = key.strip(), value.strip()
129
+ if value and value[0] in ('"', "'") and value[-1] == value[0]:
130
+ value = value[1:-1]
131
+ env_dict[key] = value
132
+ except (IOError, OSError):
133
+ pass
134
+ return env_dict
135
+
136
+ @staticmethod
137
+ def get_all_settings() -> dict:
138
+ """Returns comprehensive settings overview (includes provider_settings which triggers heavy imports)"""
139
+ return {
140
+ "credentials": SettingsDetector.detect_credentials(),
141
+ "custom_bases": SettingsDetector.detect_custom_api_bases(),
142
+ "model_definitions": SettingsDetector.detect_model_definitions(),
143
+ "concurrency_limits": SettingsDetector.detect_concurrency_limits(),
144
+ "model_filters": SettingsDetector.detect_model_filters(),
145
+ "provider_settings": SettingsDetector.detect_provider_settings(),
146
+ }
147
+
148
+ @staticmethod
149
+ def get_basic_settings() -> dict:
150
+ """Returns basic settings overview without provider_settings (avoids heavy imports)"""
151
+ return {
152
+ "credentials": SettingsDetector.detect_credentials(),
153
+ "custom_bases": SettingsDetector.detect_custom_api_bases(),
154
+ "model_definitions": SettingsDetector.detect_model_definitions(),
155
+ "concurrency_limits": SettingsDetector.detect_concurrency_limits(),
156
+ "model_filters": SettingsDetector.detect_model_filters(),
157
+ }
158
+
159
+ @staticmethod
160
+ def detect_credentials() -> dict:
161
+ """Detect API keys and OAuth credentials"""
162
+ import re
163
+ from pathlib import Path
164
+
165
+ providers = {}
166
+
167
+ # Scan for API keys
168
+ env_vars = SettingsDetector._load_local_env()
169
+ for key, value in env_vars.items():
170
+ if "_API_KEY" in key and key != "PROXY_API_KEY":
171
+ provider = key.split("_API_KEY")[0].lower()
172
+ if provider not in providers:
173
+ providers[provider] = {"api_keys": 0, "oauth": 0, "custom": False}
174
+ providers[provider]["api_keys"] += 1
175
+
176
+ # Scan for file-based OAuth credentials
177
+ oauth_dir = Path("oauth_creds")
178
+ if oauth_dir.exists():
179
+ for file in oauth_dir.glob("*_oauth_*.json"):
180
+ provider = file.name.split("_oauth_")[0]
181
+ if provider not in providers:
182
+ providers[provider] = {"api_keys": 0, "oauth": 0, "custom": False}
183
+ providers[provider]["oauth"] += 1
184
+
185
+ # Scan for env-based OAuth credentials
186
+ # Maps provider name to the ENV_PREFIX used by the provider
187
+ # (duplicated from credential_manager to avoid heavy imports)
188
+ env_oauth_providers = {
189
+ "gemini_cli": "GEMINI_CLI",
190
+ "antigravity": "ANTIGRAVITY",
191
+ "qwen_code": "QWEN_CODE",
192
+ "iflow": "IFLOW",
193
+ }
194
+
195
+ for provider, env_prefix in env_oauth_providers.items():
196
+ oauth_count = 0
197
+
198
+ # Check numbered credentials (PROVIDER_N_ACCESS_TOKEN pattern)
199
+ numbered_pattern = re.compile(rf"^{env_prefix}_(\d+)_ACCESS_TOKEN$")
200
+ for key in env_vars.keys():
201
+ match = numbered_pattern.match(key)
202
+ if match:
203
+ index = match.group(1)
204
+ refresh_key = f"{env_prefix}_{index}_REFRESH_TOKEN"
205
+ if refresh_key in env_vars and env_vars[refresh_key]:
206
+ oauth_count += 1
207
+
208
+ # Check legacy single credential (if no numbered found)
209
+ if oauth_count == 0:
210
+ access_key = f"{env_prefix}_ACCESS_TOKEN"
211
+ refresh_key = f"{env_prefix}_REFRESH_TOKEN"
212
+ if env_vars.get(access_key) and env_vars.get(refresh_key):
213
+ oauth_count = 1
214
+
215
+ if oauth_count > 0:
216
+ if provider not in providers:
217
+ providers[provider] = {"api_keys": 0, "oauth": 0, "custom": False}
218
+ providers[provider]["oauth"] += oauth_count
219
+
220
+ # Mark custom providers (have API_BASE set)
221
+ for provider in providers:
222
+ if os.getenv(f"{provider.upper()}_API_BASE"):
223
+ providers[provider]["custom"] = True
224
+
225
+ return providers
226
+
227
+ @staticmethod
228
+ def detect_custom_api_bases() -> dict:
229
+ """Detect custom API base URLs (not in hardcoded map)"""
230
+ from proxy_app.provider_urls import PROVIDER_URL_MAP
231
+
232
+ bases = {}
233
+ env_vars = SettingsDetector._load_local_env()
234
+ for key, value in env_vars.items():
235
+ if key.endswith("_API_BASE"):
236
+ provider = key.replace("_API_BASE", "").lower()
237
+ # Only include if NOT in hardcoded map
238
+ if provider not in PROVIDER_URL_MAP:
239
+ bases[provider] = value
240
+ return bases
241
+
242
+ @staticmethod
243
+ def detect_model_definitions() -> dict:
244
+ """Detect provider model definitions"""
245
+ models = {}
246
+ env_vars = SettingsDetector._load_local_env()
247
+ for key, value in env_vars.items():
248
+ if key.endswith("_MODELS"):
249
+ provider = key.replace("_MODELS", "").lower()
250
+ try:
251
+ parsed = json.loads(value)
252
+ if isinstance(parsed, dict):
253
+ models[provider] = len(parsed)
254
+ elif isinstance(parsed, list):
255
+ models[provider] = len(parsed)
256
+ except (json.JSONDecodeError, ValueError):
257
+ pass
258
+ return models
259
+
260
+ @staticmethod
261
+ def detect_concurrency_limits() -> dict:
262
+ """Detect max concurrent requests per key"""
263
+ limits = {}
264
+ env_vars = SettingsDetector._load_local_env()
265
+ for key, value in env_vars.items():
266
+ if key.startswith("MAX_CONCURRENT_REQUESTS_PER_KEY_"):
267
+ provider = key.replace("MAX_CONCURRENT_REQUESTS_PER_KEY_", "").lower()
268
+ try:
269
+ limits[provider] = int(value)
270
+ except (json.JSONDecodeError, ValueError):
271
+ pass
272
+ return limits
273
+
274
+ @staticmethod
275
+ def detect_model_filters() -> dict:
276
+ """Detect active model filters (basic info only: defined or not)"""
277
+ filters = {}
278
+ env_vars = SettingsDetector._load_local_env()
279
+ for key, value in env_vars.items():
280
+ if key.startswith("IGNORE_MODELS_") or key.startswith("WHITELIST_MODELS_"):
281
+ filter_type = "ignore" if key.startswith("IGNORE") else "whitelist"
282
+ provider = key.replace(f"{filter_type.upper()}_MODELS_", "").lower()
283
+ if provider not in filters:
284
+ filters[provider] = {"has_ignore": False, "has_whitelist": False}
285
+ if filter_type == "ignore":
286
+ filters[provider]["has_ignore"] = True
287
+ else:
288
+ filters[provider]["has_whitelist"] = True
289
+ return filters
290
+
291
+ @staticmethod
292
+ def detect_provider_settings() -> dict:
293
+ """Detect provider-specific settings (Antigravity, Gemini CLI)"""
294
+ try:
295
+ from proxy_app.settings_tool import PROVIDER_SETTINGS_MAP
296
+ except ImportError:
297
+ # Fallback for direct execution or testing
298
+ from .settings_tool import PROVIDER_SETTINGS_MAP
299
+
300
+ provider_settings = {}
301
+ env_vars = SettingsDetector._load_local_env()
302
+
303
+ for provider, definitions in PROVIDER_SETTINGS_MAP.items():
304
+ modified_count = 0
305
+ for key, definition in definitions.items():
306
+ env_value = env_vars.get(key)
307
+ if env_value is not None:
308
+ # Check if value differs from default
309
+ default = definition.get("default")
310
+ setting_type = definition.get("type", "str")
311
+
312
+ try:
313
+ if setting_type == "bool":
314
+ current = env_value.lower() in ("true", "1", "yes")
315
+ elif setting_type == "int":
316
+ current = int(env_value)
317
+ else:
318
+ current = env_value
319
+
320
+ if current != default:
321
+ modified_count += 1
322
+ except (ValueError, AttributeError):
323
+ pass
324
+
325
+ if modified_count > 0:
326
+ provider_settings[provider] = modified_count
327
+
328
+ return provider_settings
329
+
330
+
331
+ class LauncherTUI:
332
+ """Main launcher interface"""
333
+
334
+ def __init__(self):
335
+ self.console = Console()
336
+ self.config = LauncherConfig()
337
+ self.running = True
338
+ self.env_file = _get_env_file()
339
+ # Load .env file to ensure environment variables are available
340
+ load_dotenv(dotenv_path=self.env_file, override=True)
341
+
342
+ def needs_onboarding(self) -> bool:
343
+ """Check if onboarding is needed"""
344
+ return not self.env_file.exists() or not os.getenv("PROXY_API_KEY")
345
+
346
+ def run(self):
347
+ """Main TUI loop"""
348
+ while self.running:
349
+ self.show_main_menu()
350
+
351
+ def show_main_menu(self):
352
+ """Display main menu and handle selection"""
353
+ clear_screen()
354
+
355
+ # Detect basic settings (excludes provider_settings to avoid heavy imports)
356
+ settings = SettingsDetector.get_basic_settings()
357
+ credentials = settings["credentials"]
358
+ custom_bases = settings["custom_bases"]
359
+
360
+ # Check if setup is needed
361
+ show_warning = self.needs_onboarding()
362
+
363
+ # Build title with GitHub link
364
+ self.console.print(
365
+ Panel.fit(
366
+ "[bold cyan]🚀 LLM API Key Proxy - Interactive Launcher[/bold cyan]",
367
+ border_style="cyan",
368
+ )
369
+ )
370
+ self.console.print(
371
+ "[dim]GitHub: [blue underline]https://github.com/Mirrowel/LLM-API-Key-Proxy[/blue underline][/dim]"
372
+ )
373
+
374
+ # Show warning if .env file doesn't exist
375
+ if show_warning:
376
+ self.console.print()
377
+ self.console.print(
378
+ Panel(
379
+ Text.from_markup(
380
+ "⚠️ [bold yellow]INITIAL SETUP REQUIRED[/bold yellow]\n\n"
381
+ "The proxy needs initial configuration:\n"
382
+ " ❌ No .env file found\n\n"
383
+ "Why this matters:\n"
384
+ " • The .env file stores your credentials and settings\n"
385
+ " • PROXY_API_KEY protects your proxy from unauthorized access\n"
386
+ " • Provider API keys enable LLM access\n\n"
387
+ "What to do:\n"
388
+ ' 1. Select option "3. Manage Credentials" to launch the credential tool\n'
389
+ " 2. The tool will create .env and set up PROXY_API_KEY automatically\n"
390
+ " 3. You can add provider credentials (API keys or OAuth)\n\n"
391
+ "⚠️ Note: The credential tool adds PROXY_API_KEY by default.\n"
392
+ " You can remove it later if you want an unsecured proxy."
393
+ ),
394
+ border_style="yellow",
395
+ expand=False,
396
+ )
397
+ )
398
+ # Show security warning if PROXY_API_KEY is missing (but .env exists)
399
+ elif not os.getenv("PROXY_API_KEY"):
400
+ self.console.print()
401
+ self.console.print(
402
+ Panel(
403
+ Text.from_markup(
404
+ "⚠️ [bold red]SECURITY WARNING: PROXY_API_KEY Not Set[/bold red]\n\n"
405
+ "Your proxy is currently UNSECURED!\n"
406
+ "Anyone can access it without authentication.\n\n"
407
+ "This is a serious security risk if your proxy is accessible\n"
408
+ "from the internet or untrusted networks.\n\n"
409
+ "👉 [bold]Recommended:[/bold] Set PROXY_API_KEY in .env file\n"
410
+ ' Use option "2. Configure Proxy Settings" → "3. Set Proxy API Key"\n'
411
+ ' or option "3. Manage Credentials"'
412
+ ),
413
+ border_style="red",
414
+ expand=False,
415
+ )
416
+ )
417
+
418
+ # Show config
419
+ self.console.print()
420
+ self.console.print("[bold]📋 Proxy Configuration[/bold]")
421
+ self.console.print("━" * 70)
422
+ self.console.print(f" Host: {self.config.config['host']}")
423
+ self.console.print(f" Port: {self.config.config['port']}")
424
+ self.console.print(
425
+ f" Transaction Logging: {'✅ Enabled' if self.config.config['enable_request_logging'] else '❌ Disabled'}"
426
+ )
427
+ self.console.print(
428
+ f" Raw I/O Logging: {'✅ Enabled' if self.config.config.get('enable_raw_logging', False) else '❌ Disabled'}"
429
+ )
430
+
431
+ # Show actual API key value
432
+ proxy_key = os.getenv("PROXY_API_KEY")
433
+ if proxy_key:
434
+ self.console.print(f" Proxy API Key: {proxy_key}")
435
+ else:
436
+ self.console.print(" Proxy API Key: [red]Not Set (INSECURE!)[/red]")
437
+
438
+ # Show status summary
439
+ self.console.print()
440
+ self.console.print("[bold]📊 Status Summary[/bold]")
441
+ self.console.print("━" * 70)
442
+ provider_count = len(credentials)
443
+ custom_count = len(custom_bases)
444
+
445
+ self.console.print(f" Providers: {provider_count} configured")
446
+ self.console.print(f" Custom Providers: {custom_count} configured")
447
+ # Note: provider_settings detection is deferred to avoid heavy imports on startup
448
+ has_advanced = bool(
449
+ settings["model_definitions"]
450
+ or settings["concurrency_limits"]
451
+ or settings["model_filters"]
452
+ )
453
+ self.console.print(
454
+ f" Advanced Settings: {'Active (view in menu 4)' if has_advanced else 'None (view menu 4 for details)'}"
455
+ )
456
+
457
+ # Show menu
458
+ self.console.print()
459
+ self.console.print("━" * 70)
460
+ self.console.print()
461
+ self.console.print("[bold]🎯 Main Menu[/bold]")
462
+ self.console.print()
463
+ if show_warning:
464
+ self.console.print(" 1. ▶️ Run Proxy Server")
465
+ self.console.print(" 2. ⚙️ Configure Proxy Settings")
466
+ self.console.print(
467
+ " 3. 🔑 Manage Credentials ⬅️ [bold yellow]Start here![/bold yellow]"
468
+ )
469
+ else:
470
+ self.console.print(" 1. ▶️ Run Proxy Server")
471
+ self.console.print(" 2. ⚙️ Configure Proxy Settings")
472
+ self.console.print(" 3. 🔑 Manage Credentials")
473
+
474
+ self.console.print(" 4. 📊 View Provider & Advanced Settings")
475
+ self.console.print(" 5. 📈 View Quota & Usage Stats (Alpha)")
476
+ self.console.print(" 6. 🔄 Reload Configuration")
477
+ self.console.print(" 7. ℹ️ About")
478
+ self.console.print(" 8. 🚪 Exit")
479
+
480
+ self.console.print()
481
+ self.console.print("━" * 70)
482
+ self.console.print()
483
+
484
+ choice = Prompt.ask(
485
+ "Select option",
486
+ choices=["1", "2", "3", "4", "5", "6", "7", "8"],
487
+ show_choices=False,
488
+ )
489
+
490
+ if choice == "1":
491
+ self.run_proxy()
492
+ elif choice == "2":
493
+ self.show_config_menu()
494
+ elif choice == "3":
495
+ self.launch_credential_tool()
496
+ elif choice == "4":
497
+ self.show_provider_settings_menu()
498
+ elif choice == "5":
499
+ self.launch_quota_viewer()
500
+ elif choice == "6":
501
+ load_dotenv(dotenv_path=_get_env_file(), override=True)
502
+ self.config = LauncherConfig() # Reload config
503
+ self.console.print("\n[green]✅ Configuration reloaded![/green]")
504
+ elif choice == "7":
505
+ self.show_about()
506
+ elif choice == "8":
507
+ self.running = False
508
+ sys.exit(0)
509
+
510
+ def confirm_setting_change(self, setting_name: str, warning_lines: list) -> bool:
511
+ """
512
+ Display a warning and require Y/N (case-sensitive) confirmation.
513
+ Re-prompts until user enters exactly 'Y' or 'N'.
514
+ Returns True only if user enters 'Y'.
515
+ """
516
+ clear_screen()
517
+ self.console.print()
518
+ self.console.print(
519
+ Panel(
520
+ Text.from_markup(
521
+ f"[bold yellow]⚠️ WARNING: You are about to change the {setting_name}[/bold yellow]\n\n"
522
+ + "\n".join(warning_lines)
523
+ + "\n\n[bold]If you are not sure about changing this - don't.[/bold]"
524
+ ),
525
+ border_style="yellow",
526
+ expand=False,
527
+ )
528
+ )
529
+
530
+ while True:
531
+ response = Prompt.ask(
532
+ "Enter [bold]Y[/bold] to confirm, [bold]N[/bold] to cancel (case-sensitive)"
533
+ )
534
+ if response == "Y":
535
+ return True
536
+ elif response == "N":
537
+ self.console.print("\n[dim]Operation cancelled.[/dim]")
538
+ return False
539
+ else:
540
+ self.console.print(
541
+ "[red]Please enter exactly 'Y' or 'N' (case-sensitive)[/red]"
542
+ )
543
+
544
+ def show_config_menu(self):
545
+ """Display configuration sub-menu"""
546
+ while True:
547
+ clear_screen()
548
+
549
+ self.console.print(
550
+ Panel.fit(
551
+ "[bold cyan]⚙️ Proxy Configuration[/bold cyan]", border_style="cyan"
552
+ )
553
+ )
554
+
555
+ self.console.print()
556
+ self.console.print("[bold]📋 Current Settings[/bold]")
557
+ self.console.print("━" * 70)
558
+ self.console.print(f" Host: {self.config.config['host']}")
559
+ self.console.print(f" Port: {self.config.config['port']}")
560
+ self.console.print(
561
+ f" Transaction Logging: {'✅ Enabled' if self.config.config['enable_request_logging'] else '❌ Disabled'}"
562
+ )
563
+ self.console.print(
564
+ f" Raw I/O Logging: {'✅ Enabled' if self.config.config.get('enable_raw_logging', False) else '❌ Disabled'}"
565
+ )
566
+ self.console.print(
567
+ f" Proxy API Key: {'✅ Set' if os.getenv('PROXY_API_KEY') else '❌ Not Set'}"
568
+ )
569
+
570
+ self.console.print()
571
+ self.console.print("━" * 70)
572
+ self.console.print()
573
+ self.console.print("[bold]⚙️ Configuration Options[/bold]")
574
+ self.console.print()
575
+ self.console.print(" 1. 🌐 Set Host IP")
576
+ self.console.print(" 2. 🔌 Set Port")
577
+ self.console.print(" 3. 🔑 Set Proxy API Key")
578
+ self.console.print(" 4. 📝 Toggle Transaction Logging")
579
+ self.console.print(" 5. 📋 Toggle Raw I/O Logging")
580
+ self.console.print(" 6. 🔄 Reset to Default Settings")
581
+ self.console.print(" 7. ↩️ Back to Main Menu")
582
+
583
+ self.console.print()
584
+ self.console.print("━" * 70)
585
+ self.console.print()
586
+
587
+ choice = Prompt.ask(
588
+ "Select option",
589
+ choices=["1", "2", "3", "4", "5", "6", "7"],
590
+ show_choices=False,
591
+ )
592
+
593
+ if choice == "1":
594
+ # Show warning and require confirmation
595
+ confirmed = self.confirm_setting_change(
596
+ "Host IP",
597
+ [
598
+ "Changing the host IP affects which network interfaces the proxy listens on:",
599
+ " • [cyan]127.0.0.1[/cyan] = Local access only (recommended for development)",
600
+ " • [cyan]0.0.0.0[/cyan] = Accessible from all network interfaces",
601
+ "",
602
+ "Applications configured to connect to the old host may fail to connect.",
603
+ ],
604
+ )
605
+ if not confirmed:
606
+ continue
607
+
608
+ new_host = Prompt.ask(
609
+ "Enter new host IP", default=self.config.config["host"]
610
+ )
611
+ self.config.update(host=new_host)
612
+ self.console.print(f"\n[green]✅ Host updated to: {new_host}[/green]")
613
+ elif choice == "2":
614
+ # Show warning and require confirmation
615
+ confirmed = self.confirm_setting_change(
616
+ "Port",
617
+ [
618
+ "Changing the port will affect all applications currently configured",
619
+ "to connect to your proxy on the existing port.",
620
+ "",
621
+ "Applications using the old port will fail to connect.",
622
+ ],
623
+ )
624
+ if not confirmed:
625
+ continue
626
+
627
+ new_port = IntPrompt.ask(
628
+ "Enter new port", default=self.config.config["port"]
629
+ )
630
+ if 1 <= new_port <= 65535:
631
+ self.config.update(port=new_port)
632
+ self.console.print(
633
+ f"\n[green]✅ Port updated to: {new_port}[/green]"
634
+ )
635
+ else:
636
+ self.console.print("\n[red]❌ Port must be between 1-65535[/red]")
637
+ elif choice == "3":
638
+ # Show warning and require confirmation
639
+ confirmed = self.confirm_setting_change(
640
+ "Proxy API Key",
641
+ [
642
+ "This is the authentication key that applications use to access your proxy.",
643
+ "",
644
+ "[bold red]⚠️ Changing this will BREAK all applications currently configured",
645
+ " with the existing API key![/bold red]",
646
+ "",
647
+ "[bold cyan]💡 If you want to add provider API keys (OpenAI, Gemini, etc.),",
648
+ ' go to "3. 🔑 Manage Credentials" in the main menu instead.[/bold cyan]',
649
+ ],
650
+ )
651
+ if not confirmed:
652
+ continue
653
+
654
+ current = os.getenv("PROXY_API_KEY", "")
655
+ new_key = Prompt.ask(
656
+ "Enter new Proxy API Key (leave empty to disable authentication)",
657
+ default=current,
658
+ )
659
+
660
+ if new_key != current:
661
+ # If setting to empty, show additional warning
662
+ if not new_key:
663
+ self.console.print(
664
+ "\n[bold red]⚠️ Authentication will be DISABLED - anyone can access your proxy![/bold red]"
665
+ )
666
+ Prompt.ask("Press Enter to continue", default="")
667
+
668
+ LauncherConfig.update_proxy_api_key(new_key)
669
+
670
+ if new_key:
671
+ self.console.print(
672
+ "\n[green]✅ Proxy API Key updated successfully![/green]"
673
+ )
674
+ self.console.print(" Updated in .env file")
675
+ else:
676
+ self.console.print(
677
+ "\n[yellow]⚠️ Proxy API Key cleared - authentication disabled![/yellow]"
678
+ )
679
+ self.console.print(" Updated in .env file")
680
+ else:
681
+ self.console.print("\n[yellow]No changes made[/yellow]")
682
+ elif choice == "4":
683
+ current = self.config.config["enable_request_logging"]
684
+ self.config.update(enable_request_logging=not current)
685
+ self.console.print(
686
+ f"\n[green]✅ Transaction Logging {'enabled' if not current else 'disabled'}![/green]"
687
+ )
688
+ elif choice == "5":
689
+ current = self.config.config.get("enable_raw_logging", False)
690
+ self.config.update(enable_raw_logging=not current)
691
+ self.console.print(
692
+ f"\n[green]✅ Raw I/O Logging {'enabled' if not current else 'disabled'}![/green]"
693
+ )
694
+ elif choice == "6":
695
+ # Reset to Default Settings
696
+ # Define defaults
697
+ default_host = "127.0.0.1"
698
+ default_port = 8000
699
+ default_logging = False
700
+ default_raw_logging = False
701
+ default_api_key = "VerysecretKey"
702
+
703
+ # Get current values
704
+ current_host = self.config.config["host"]
705
+ current_port = self.config.config["port"]
706
+ current_logging = self.config.config["enable_request_logging"]
707
+ current_raw_logging = self.config.config.get(
708
+ "enable_raw_logging", False
709
+ )
710
+ current_api_key = os.getenv("PROXY_API_KEY", "")
711
+
712
+ # Build comparison table
713
+ warning_lines = [
714
+ "This will reset ALL proxy settings to their defaults:",
715
+ "",
716
+ "[bold] Setting Current Value → Default Value[/bold]",
717
+ " " + "─" * 62,
718
+ f" Host IP {current_host:20} → {default_host}",
719
+ f" Port {str(current_port):20} → {default_port}",
720
+ f" Transaction Logging {'Enabled':20} → Disabled"
721
+ if current_logging
722
+ else f" Transaction Logging {'Disabled':20} → Disabled",
723
+ f" Raw I/O Logging {'Enabled':20} → Disabled"
724
+ if current_raw_logging
725
+ else f" Raw I/O Logging {'Disabled':20} → Disabled",
726
+ f" Proxy API Key {current_api_key[:20]:20} → {default_api_key}",
727
+ "",
728
+ "[bold red]⚠️ This may break applications configured with current settings![/bold red]",
729
+ ]
730
+
731
+ confirmed = self.confirm_setting_change(
732
+ "Settings (Reset to Defaults)", warning_lines
733
+ )
734
+ if not confirmed:
735
+ continue
736
+
737
+ # Apply defaults
738
+ self.config.update(
739
+ host=default_host,
740
+ port=default_port,
741
+ enable_request_logging=default_logging,
742
+ enable_raw_logging=default_raw_logging,
743
+ )
744
+ LauncherConfig.update_proxy_api_key(default_api_key)
745
+
746
+ self.console.print(
747
+ "\n[green]✅ All settings have been reset to defaults![/green]"
748
+ )
749
+ self.console.print(f" Host: {default_host}")
750
+ self.console.print(f" Port: {default_port}")
751
+ self.console.print(f" Transaction Logging: Disabled")
752
+ self.console.print(f" Raw I/O Logging: Disabled")
753
+ self.console.print(f" Proxy API Key: {default_api_key}")
754
+ elif choice == "7":
755
+ break
756
+
757
+ def show_provider_settings_menu(self):
758
+ """Display provider/advanced settings (read-only + launch tool)"""
759
+ clear_screen()
760
+
761
+ # Use basic settings to avoid heavy imports - provider_settings deferred to Settings Tool
762
+ settings = SettingsDetector.get_basic_settings()
763
+
764
+ credentials = settings["credentials"]
765
+ custom_bases = settings["custom_bases"]
766
+ model_defs = settings["model_definitions"]
767
+ concurrency = settings["concurrency_limits"]
768
+ filters = settings["model_filters"]
769
+
770
+ self.console.print(
771
+ Panel.fit(
772
+ "[bold cyan]📊 Provider & Advanced Settings[/bold cyan]",
773
+ border_style="cyan",
774
+ )
775
+ )
776
+
777
+ # Configured Providers
778
+ self.console.print()
779
+ self.console.print("[bold]📊 Configured Providers[/bold]")
780
+ self.console.print("━" * 70)
781
+ if credentials:
782
+ for provider, info in credentials.items():
783
+ provider_name = provider.title()
784
+ parts = []
785
+ if info["api_keys"] > 0:
786
+ parts.append(
787
+ f"{info['api_keys']} API key{'s' if info['api_keys'] > 1 else ''}"
788
+ )
789
+ if info["oauth"] > 0:
790
+ parts.append(
791
+ f"{info['oauth']} OAuth credential{'s' if info['oauth'] > 1 else ''}"
792
+ )
793
+
794
+ display = " + ".join(parts)
795
+ if info["custom"]:
796
+ display += " (Custom)"
797
+
798
+ self.console.print(f" ✅ {provider_name:20} {display}")
799
+ else:
800
+ self.console.print(" [dim]No providers configured[/dim]")
801
+
802
+ # Custom API Bases
803
+ if custom_bases:
804
+ self.console.print()
805
+ self.console.print("[bold]🌐 Custom API Bases[/bold]")
806
+ self.console.print("━" * 70)
807
+ for provider, base in custom_bases.items():
808
+ self.console.print(f" • {provider:15} {base}")
809
+
810
+ # Model Definitions
811
+ if model_defs:
812
+ self.console.print()
813
+ self.console.print("[bold]📦 Provider Model Definitions[/bold]")
814
+ self.console.print("━" * 70)
815
+ for provider, count in model_defs.items():
816
+ self.console.print(
817
+ f" • {provider:15} {count} model{'s' if count > 1 else ''} configured"
818
+ )
819
+
820
+ # Concurrency Limits
821
+ if concurrency:
822
+ self.console.print()
823
+ self.console.print("[bold]⚡ Concurrency Limits[/bold]")
824
+ self.console.print("━" * 70)
825
+ for provider, limit in concurrency.items():
826
+ self.console.print(f" • {provider:15} {limit} requests/key")
827
+ self.console.print(" • Default: 1 request/key (all others)")
828
+
829
+ # Model Filters (basic info only)
830
+ if filters:
831
+ self.console.print()
832
+ self.console.print("[bold]🎯 Model Filters[/bold]")
833
+ self.console.print("━" * 70)
834
+ for provider, filter_info in filters.items():
835
+ status_parts = []
836
+ if filter_info["has_whitelist"]:
837
+ status_parts.append("Whitelist")
838
+ if filter_info["has_ignore"]:
839
+ status_parts.append("Ignore list")
840
+ status = " + ".join(status_parts) if status_parts else "None"
841
+ self.console.print(f" • {provider:15} ✅ {status}")
842
+
843
+ # Provider-Specific Settings (deferred to Settings Tool to avoid heavy imports)
844
+ self.console.print()
845
+ self.console.print("[bold]🔬 Provider-Specific Settings[/bold]")
846
+ self.console.print("━" * 70)
847
+ self.console.print(
848
+ " [dim]Launch Settings Tool to view/configure provider-specific settings[/dim]"
849
+ )
850
+
851
+ # Actions
852
+ self.console.print()
853
+ self.console.print("━" * 70)
854
+ self.console.print()
855
+ self.console.print("[bold]💡 Actions[/bold]")
856
+ self.console.print()
857
+ self.console.print(
858
+ " 1. 🔧 Launch Settings Tool (configure advanced settings)"
859
+ )
860
+ self.console.print(" 2. ↩️ Back to Main Menu")
861
+
862
+ self.console.print()
863
+ self.console.print("━" * 70)
864
+ self.console.print(
865
+ "[dim]ℹ️ Advanced settings are stored in .env file.\n Use the Settings Tool to configure them interactively.[/dim]"
866
+ )
867
+ self.console.print()
868
+ self.console.print(
869
+ "[dim]⚠️ Note: Settings Tool supports only common configuration types.\n For complex settings, edit .env directly.[/dim]"
870
+ )
871
+ self.console.print()
872
+
873
+ choice = Prompt.ask("Select option", choices=["1", "2"], show_choices=False)
874
+
875
+ if choice == "1":
876
+ self.launch_settings_tool()
877
+ # choice == "2" returns to main menu
878
+
879
+ def launch_credential_tool(self):
880
+ """Launch credential management tool"""
881
+ import time
882
+
883
+ # CRITICAL: Show full loading UI to replace the 6-7 second blank wait
884
+ clear_screen()
885
+
886
+ _start_time = time.time()
887
+
888
+ # Show the same header as standalone mode
889
+ self.console.print("━" * 70)
890
+ self.console.print("Interactive Credential Setup Tool")
891
+ self.console.print("GitHub: https://github.com/Mirrowel/LLM-API-Key-Proxy")
892
+ self.console.print("━" * 70)
893
+ self.console.print("Loading credential management components...")
894
+
895
+ # Now import with spinner (this is where the 6-7 second delay happens)
896
+ with self.console.status("Initializing credential tool...", spinner="dots"):
897
+ from rotator_library.credential_tool import (
898
+ run_credential_tool,
899
+ _ensure_providers_loaded,
900
+ )
901
+
902
+ _, PROVIDER_PLUGINS = _ensure_providers_loaded()
903
+ self.console.print("✓ Credential tool initialized")
904
+
905
+ _elapsed = time.time() - _start_time
906
+ self.console.print(
907
+ f"✓ Tool ready in {_elapsed:.2f}s ({len(PROVIDER_PLUGINS)} providers available)"
908
+ )
909
+
910
+ # Small delay to let user see the ready message
911
+ time.sleep(0.5)
912
+
913
+ # Run the tool with from_launcher=True to skip duplicate loading screen
914
+ run_credential_tool(from_launcher=True)
915
+ # Reload environment after credential tool
916
+ load_dotenv(dotenv_path=_get_env_file(), override=True)
917
+
918
+ def launch_settings_tool(self):
919
+ """Launch settings configuration tool"""
920
+ import time
921
+
922
+ clear_screen()
923
+
924
+ self.console.print("━" * 70)
925
+ self.console.print("Advanced Settings Configuration Tool")
926
+ self.console.print("━" * 70)
927
+
928
+ _start_time = time.time()
929
+
930
+ with self.console.status("Initializing settings tool...", spinner="dots"):
931
+ from proxy_app.settings_tool import run_settings_tool
932
+
933
+ _elapsed = time.time() - _start_time
934
+ self.console.print(f"✓ Settings tool ready in {_elapsed:.2f}s")
935
+
936
+ time.sleep(0.3)
937
+
938
+ run_settings_tool()
939
+ # Reload environment after settings tool
940
+ load_dotenv(dotenv_path=_get_env_file(), override=True)
941
+
942
+ def launch_quota_viewer(self):
943
+ """Launch the quota stats viewer"""
944
+ clear_screen()
945
+
946
+ self.console.print("━" * 70)
947
+ self.console.print("Quota & Usage Statistics Viewer")
948
+ self.console.print("━" * 70)
949
+ self.console.print()
950
+
951
+ # Import the lightweight viewer (no heavy imports)
952
+ from proxy_app.quota_viewer import run_quota_viewer
953
+
954
+ run_quota_viewer()
955
+
956
+ def show_about(self):
957
+ """Display About page with project information"""
958
+ clear_screen()
959
+
960
+ self.console.print(
961
+ Panel.fit(
962
+ "[bold cyan]ℹ️ About LLM API Key Proxy[/bold cyan]", border_style="cyan"
963
+ )
964
+ )
965
+
966
+ self.console.print()
967
+ self.console.print("[bold]📦 Project Information[/bold]")
968
+ self.console.print("━" * 70)
969
+ self.console.print(" [bold cyan]LLM API Key Proxy[/bold cyan]")
970
+ self.console.print(
971
+ " A lightweight, high-performance proxy server for managing"
972
+ )
973
+ self.console.print(" LLM API keys with automatic rotation and OAuth support")
974
+ self.console.print()
975
+ self.console.print(
976
+ " [dim]GitHub:[/dim] [blue underline]https://github.com/Mirrowel/LLM-API-Key-Proxy[/blue underline]"
977
+ )
978
+
979
+ self.console.print()
980
+ self.console.print("[bold]✨ Key Features[/bold]")
981
+ self.console.print("━" * 70)
982
+ self.console.print(
983
+ " • [green]Smart Key Rotation[/green] - Automatic rotation across multiple API keys"
984
+ )
985
+ self.console.print(
986
+ " • [green]OAuth Support[/green] - Automated OAuth flows for supported providers"
987
+ )
988
+ self.console.print(
989
+ " • [green]Multiple Providers[/green] - Support for 10+ LLM providers"
990
+ )
991
+ self.console.print(
992
+ " • [green]Custom Providers[/green] - Easy integration of custom OpenAI-compatible APIs"
993
+ )
994
+ self.console.print(
995
+ " • [green]Advanced Filtering[/green] - Model whitelists and ignore lists per provider"
996
+ )
997
+ self.console.print(
998
+ " • [green]Concurrency Control[/green] - Per-key rate limiting and request management"
999
+ )
1000
+ self.console.print(
1001
+ " • [green]Cost Tracking[/green] - Track usage and costs across all providers"
1002
+ )
1003
+ self.console.print(
1004
+ " • [green]Interactive TUI[/green] - Beautiful terminal interface for easy configuration"
1005
+ )
1006
+
1007
+ self.console.print()
1008
+ self.console.print("[bold]📝 License & Credits[/bold]")
1009
+ self.console.print("━" * 70)
1010
+ self.console.print(" Made with ❤️ by the community")
1011
+ self.console.print(" Open source - contributions welcome!")
1012
+
1013
+ self.console.print()
1014
+ self.console.print("━" * 70)
1015
+ self.console.print()
1016
+
1017
+ Prompt.ask("Press Enter to return to main menu", default="")
1018
+
1019
+ def run_proxy(self):
1020
+ """Prepare and launch proxy in same window"""
1021
+ # Check if forced onboarding needed
1022
+ if self.needs_onboarding():
1023
+ clear_screen()
1024
+ self.console.print(
1025
+ Panel(
1026
+ Text.from_markup(
1027
+ "⚠️ [bold yellow]Setup Required[/bold yellow]\n\n"
1028
+ "Cannot start without .env.\n"
1029
+ "Launching credential tool..."
1030
+ ),
1031
+ border_style="yellow",
1032
+ )
1033
+ )
1034
+
1035
+ # Force credential tool
1036
+ from rotator_library.credential_tool import (
1037
+ ensure_env_defaults,
1038
+ run_credential_tool,
1039
+ )
1040
+
1041
+ ensure_env_defaults()
1042
+ load_dotenv(dotenv_path=_get_env_file(), override=True)
1043
+ run_credential_tool()
1044
+ load_dotenv(dotenv_path=_get_env_file(), override=True)
1045
+
1046
+ # Check again after credential tool
1047
+ if not os.getenv("PROXY_API_KEY"):
1048
+ self.console.print(
1049
+ "\n[red]❌ PROXY_API_KEY still not set. Cannot start proxy.[/red]"
1050
+ )
1051
+ return
1052
+
1053
+ # Clear console and modify sys.argv
1054
+ clear_screen()
1055
+ self.console.print(
1056
+ f"\n[bold green]🚀 Starting proxy on {self.config.config['host']}:{self.config.config['port']}...[/bold green]\n"
1057
+ )
1058
+
1059
+ # Brief pause so user sees the message before main.py takes over
1060
+ import time
1061
+
1062
+ time.sleep(0.5)
1063
+
1064
+ # Reconstruct sys.argv for main.py
1065
+ sys.argv = [
1066
+ "main.py",
1067
+ "--host",
1068
+ self.config.config["host"],
1069
+ "--port",
1070
+ str(self.config.config["port"]),
1071
+ ]
1072
+ if self.config.config["enable_request_logging"]:
1073
+ sys.argv.append("--enable-request-logging")
1074
+ if self.config.config.get("enable_raw_logging", False):
1075
+ sys.argv.append("--enable-raw-logging")
1076
+
1077
+ # Exit TUI - main.py will continue execution
1078
+ self.running = False
1079
+
1080
+
1081
+ def run_launcher_tui():
1082
+ """Entry point for launcher TUI"""
1083
+ tui = LauncherTUI()
1084
+ tui.run()
src/proxy_app/main.py ADDED
@@ -0,0 +1,1731 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Mirrowel
3
+
4
+ import time
5
+ import uuid
6
+
7
+ # Phase 1: Minimal imports for arg parsing and TUI
8
+ import asyncio
9
+ import os
10
+ from pathlib import Path
11
+ import sys
12
+ import argparse
13
+ import logging
14
+
15
+ # --- Argument Parsing (BEFORE heavy imports) ---
16
+ parser = argparse.ArgumentParser(description="API Key Proxy Server")
17
+ parser.add_argument(
18
+ "--host", type=str, default="0.0.0.0", help="Host to bind the server to."
19
+ )
20
+ parser.add_argument("--port", type=int, default=8000, help="Port to run the server on.")
21
+ parser.add_argument(
22
+ "--enable-request-logging",
23
+ action="store_true",
24
+ help="Enable transaction logging in the library (logs request/response with provider correlation).",
25
+ )
26
+ parser.add_argument(
27
+ "--enable-raw-logging",
28
+ action="store_true",
29
+ help="Enable raw I/O logging at proxy boundary (captures unmodified HTTP data, disabled by default).",
30
+ )
31
+ parser.add_argument(
32
+ "--add-credential",
33
+ action="store_true",
34
+ help="Launch the interactive tool to add a new OAuth credential.",
35
+ )
36
+ args, _ = parser.parse_known_args()
37
+
38
+ # Add the 'src' directory to the Python path
39
+ sys.path.append(str(Path(__file__).resolve().parent.parent))
40
+
41
+ # Check if we should launch TUI (no arguments = TUI mode)
42
+ if len(sys.argv) == 1:
43
+ # TUI MODE - Load ONLY what's needed for the launcher (fast path!)
44
+ from proxy_app.launcher_tui import run_launcher_tui
45
+
46
+ run_launcher_tui()
47
+ # Launcher modifies sys.argv and returns, or exits if user chose Exit
48
+ # If we get here, user chose "Run Proxy" and sys.argv is modified
49
+ # Re-parse arguments with modified sys.argv
50
+ args = parser.parse_args()
51
+
52
+ # Check if credential tool mode (also doesn't need heavy proxy imports)
53
+ if args.add_credential:
54
+ from rotator_library.credential_tool import run_credential_tool
55
+
56
+ run_credential_tool()
57
+ sys.exit(0)
58
+
59
+ # If we get here, we're ACTUALLY running the proxy - NOW show startup messages and start timer
60
+ _start_time = time.time()
61
+
62
+ # Load all .env files from root folder (main .env first, then any additional *.env files)
63
+ from dotenv import load_dotenv
64
+ from glob import glob
65
+
66
+ # Get the application root directory (EXE dir if frozen, else CWD)
67
+ # Inlined here to avoid triggering heavy rotator_library imports before loading screen
68
+ if getattr(sys, "frozen", False):
69
+ _root_dir = Path(sys.executable).parent
70
+ else:
71
+ _root_dir = Path.cwd()
72
+
73
+ # [HUGGING FACE SUPPORT] If a bulk environment block is provided via Secret, save it to a file
74
+ # This allows users to paste their entire .env content into a single HF Secret called CONFIG_ENV
75
+ _bulk_env = os.getenv("CONFIG_ENV")
76
+ if _bulk_env:
77
+ _bulk_env_file = _root_dir / "bulk_config.env"
78
+ with open(_bulk_env_file, "w", encoding="utf-8") as _f:
79
+ _f.write(_bulk_env)
80
+ print(f"📝 Detected 'CONFIG_ENV' secret, saved to '{_bulk_env_file.name}'")
81
+
82
+ # Load main .env first
83
+ load_dotenv(_root_dir / ".env")
84
+
85
+ # Load any additional .env files (e.g., antigravity_all_combined.env, gemini_cli_all_combined.env)
86
+ _env_files_found = list(_root_dir.glob("*.env"))
87
+ for _env_file in sorted(_root_dir.glob("*.env")):
88
+ if _env_file.name != ".env": # Skip main .env (already loaded)
89
+ load_dotenv(_env_file, override=False) # Don't override existing values
90
+
91
+ # Log discovered .env files for deployment verification
92
+ if _env_files_found:
93
+ _env_names = [_ef.name for _ef in _env_files_found]
94
+ print(f"📁 Loaded {len(_env_files_found)} .env file(s): {', '.join(_env_names)}")
95
+
96
+ # Get proxy API key for display
97
+ proxy_api_key = os.getenv("PROXY_API_KEY")
98
+ if proxy_api_key:
99
+ key_display = f"✓ {proxy_api_key}"
100
+ else:
101
+ key_display = "✗ Not Set (INSECURE - anyone can access!)"
102
+
103
+ print("━" * 70)
104
+ print(f"Starting proxy on {args.host}:{args.port}")
105
+ print(f"Proxy API Key: {key_display}")
106
+ print(f"GitHub: https://github.com/Mirrowel/LLM-API-Key-Proxy")
107
+ print("━" * 70)
108
+ print("Loading server components...")
109
+
110
+
111
+ # Phase 2: Load Rich for loading spinner (lightweight)
112
+ from rich.console import Console
113
+
114
+ _console = Console()
115
+
116
+ # Phase 3: Heavy dependencies with granular loading messages
117
+ print(" → Loading FastAPI framework...")
118
+ with _console.status("[dim]Loading FastAPI framework...", spinner="dots"):
119
+ from contextlib import asynccontextmanager
120
+ from fastapi import FastAPI, Request, HTTPException, Depends
121
+ from fastapi.middleware.cors import CORSMiddleware
122
+ from fastapi.responses import StreamingResponse, JSONResponse
123
+ from fastapi.security import APIKeyHeader
124
+
125
+ print(" → Loading core dependencies...")
126
+ with _console.status("[dim]Loading core dependencies...", spinner="dots"):
127
+ from dotenv import load_dotenv
128
+ import colorlog
129
+ import json
130
+ from typing import AsyncGenerator, Any, List, Optional, Union
131
+ from pydantic import BaseModel, ConfigDict, Field
132
+
133
+ # --- Early Log Level Configuration ---
134
+ logging.getLogger("LiteLLM").setLevel(logging.WARNING)
135
+
136
+ print(" → Loading LiteLLM library...")
137
+ with _console.status("[dim]Loading LiteLLM library...", spinner="dots"):
138
+ import litellm
139
+
140
+ # Phase 4: Application imports with granular loading messages
141
+ print(" → Initializing proxy core...")
142
+ with _console.status("[dim]Initializing proxy core...", spinner="dots"):
143
+ from rotator_library import RotatingClient
144
+ from rotator_library.credential_manager import CredentialManager
145
+ from rotator_library.background_refresher import BackgroundRefresher
146
+ from rotator_library.model_info_service import init_model_info_service
147
+ from proxy_app.request_logger import log_request_to_console
148
+ from proxy_app.batch_manager import EmbeddingBatcher
149
+ from proxy_app.detailed_logger import RawIOLogger
150
+
151
+ print(" → Discovering provider plugins...")
152
+ # Provider lazy loading happens during import, so time it here
153
+ _provider_start = time.time()
154
+ with _console.status("[dim]Discovering provider plugins...", spinner="dots"):
155
+ from rotator_library import (
156
+ PROVIDER_PLUGINS,
157
+ ) # This triggers lazy load via __getattr__
158
+ _provider_time = time.time() - _provider_start
159
+
160
+ # Get count after import (without timing to avoid double-counting)
161
+ _plugin_count = len(PROVIDER_PLUGINS)
162
+
163
+
164
+ # --- Pydantic Models ---
165
+ class EmbeddingRequest(BaseModel):
166
+ model: str
167
+ input: Union[str, List[str]]
168
+ input_type: Optional[str] = None
169
+ dimensions: Optional[int] = None
170
+ user: Optional[str] = None
171
+
172
+
173
+ class ModelCard(BaseModel):
174
+ """Basic model card for minimal response."""
175
+
176
+ id: str
177
+ object: str = "model"
178
+ created: int = Field(default_factory=lambda: int(time.time()))
179
+ owned_by: str = "Mirro-Proxy"
180
+
181
+
182
+ class ModelCapabilities(BaseModel):
183
+ """Model capability flags."""
184
+
185
+ tool_choice: bool = False
186
+ function_calling: bool = False
187
+ reasoning: bool = False
188
+ vision: bool = False
189
+ system_messages: bool = True
190
+ prompt_caching: bool = False
191
+ assistant_prefill: bool = False
192
+
193
+
194
+ class EnrichedModelCard(BaseModel):
195
+ """Extended model card with pricing and capabilities."""
196
+
197
+ id: str
198
+ object: str = "model"
199
+ created: int = Field(default_factory=lambda: int(time.time()))
200
+ owned_by: str = "unknown"
201
+ # Pricing (optional - may not be available for all models)
202
+ input_cost_per_token: Optional[float] = None
203
+ output_cost_per_token: Optional[float] = None
204
+ cache_read_input_token_cost: Optional[float] = None
205
+ cache_creation_input_token_cost: Optional[float] = None
206
+ # Limits (optional)
207
+ max_input_tokens: Optional[int] = None
208
+ max_output_tokens: Optional[int] = None
209
+ context_window: Optional[int] = None
210
+ # Capabilities
211
+ mode: str = "chat"
212
+ supported_modalities: List[str] = Field(default_factory=lambda: ["text"])
213
+ supported_output_modalities: List[str] = Field(default_factory=lambda: ["text"])
214
+ capabilities: Optional[ModelCapabilities] = None
215
+ # Debug info (optional)
216
+ _sources: Optional[List[str]] = None
217
+ _match_type: Optional[str] = None
218
+
219
+ model_config = ConfigDict(extra="allow") # Allow extra fields from the service
220
+
221
+
222
+ class ModelList(BaseModel):
223
+ """List of models response."""
224
+
225
+ object: str = "list"
226
+ data: List[ModelCard]
227
+
228
+
229
+ class EnrichedModelList(BaseModel):
230
+ """List of enriched models with pricing and capabilities."""
231
+
232
+ object: str = "list"
233
+ data: List[EnrichedModelCard]
234
+
235
+
236
+ # --- Anthropic API Models (imported from library) ---
237
+ from rotator_library.anthropic_compat import (
238
+ AnthropicMessagesRequest,
239
+ AnthropicCountTokensRequest,
240
+ )
241
+
242
+
243
+ # Calculate total loading time
244
+ _elapsed = time.time() - _start_time
245
+ print(
246
+ f"✓ Server ready in {_elapsed:.2f}s ({_plugin_count} providers discovered in {_provider_time:.2f}s)"
247
+ )
248
+
249
+ # Clear screen and reprint header for clean startup view
250
+ # This pushes loading messages up (still in scroll history) but shows a clean final screen
251
+ import os as _os_module
252
+
253
+ _os_module.system("cls" if _os_module.name == "nt" else "clear")
254
+
255
+ # Reprint header
256
+ print("━" * 70)
257
+ print(f"Starting proxy on {args.host}:{args.port}")
258
+ print(f"Proxy API Key: {key_display}")
259
+ print(f"GitHub: https://github.com/Mirrowel/LLM-API-Key-Proxy")
260
+ print("━" * 70)
261
+ print(
262
+ f"✓ Server ready in {_elapsed:.2f}s ({_plugin_count} providers discovered in {_provider_time:.2f}s)"
263
+ )
264
+
265
+
266
+ # Note: Debug logging will be added after logging configuration below
267
+
268
+ # --- Logging Configuration ---
269
+ # Import path utilities here (after loading screen) to avoid triggering heavy imports early
270
+ from rotator_library.utils.paths import get_logs_dir, get_data_file
271
+
272
+ LOG_DIR = get_logs_dir(_root_dir)
273
+
274
+ # Configure a console handler with color (INFO and above only, no DEBUG)
275
+ console_handler = colorlog.StreamHandler(sys.stdout)
276
+ console_handler.setLevel(logging.INFO)
277
+ formatter = colorlog.ColoredFormatter(
278
+ "%(log_color)s%(message)s",
279
+ log_colors={
280
+ "DEBUG": "cyan",
281
+ "INFO": "green",
282
+ "WARNING": "yellow",
283
+ "ERROR": "red",
284
+ "CRITICAL": "red,bg_white",
285
+ },
286
+ )
287
+ console_handler.setFormatter(formatter)
288
+
289
+ # Configure a file handler for INFO-level logs and higher
290
+ info_file_handler = logging.FileHandler(LOG_DIR / "proxy.log", encoding="utf-8")
291
+ info_file_handler.setLevel(logging.INFO)
292
+ info_file_handler.setFormatter(
293
+ logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
294
+ )
295
+
296
+ # Configure a dedicated file handler for all DEBUG-level logs
297
+ debug_file_handler = logging.FileHandler(LOG_DIR / "proxy_debug.log", encoding="utf-8")
298
+ debug_file_handler.setLevel(logging.DEBUG)
299
+ debug_file_handler.setFormatter(
300
+ logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
301
+ )
302
+
303
+
304
+ # Create a filter to ensure the debug handler ONLY gets DEBUG messages from the rotator_library
305
+ class RotatorDebugFilter(logging.Filter):
306
+ def filter(self, record):
307
+ return record.levelno == logging.DEBUG and record.name.startswith(
308
+ "rotator_library"
309
+ )
310
+
311
+
312
+ debug_file_handler.addFilter(RotatorDebugFilter())
313
+
314
+ # Configure a console handler with color
315
+ console_handler = colorlog.StreamHandler(sys.stdout)
316
+ console_handler.setLevel(logging.INFO)
317
+ formatter = colorlog.ColoredFormatter(
318
+ "%(log_color)s%(message)s",
319
+ log_colors={
320
+ "DEBUG": "cyan",
321
+ "INFO": "green",
322
+ "WARNING": "yellow",
323
+ "ERROR": "red",
324
+ "CRITICAL": "red,bg_white",
325
+ },
326
+ )
327
+ console_handler.setFormatter(formatter)
328
+
329
+
330
+ # Add a filter to prevent any LiteLLM logs from cluttering the console
331
+ class NoLiteLLMLogFilter(logging.Filter):
332
+ def filter(self, record):
333
+ return not record.name.startswith("LiteLLM")
334
+
335
+
336
+ console_handler.addFilter(NoLiteLLMLogFilter())
337
+
338
+ # Get the root logger and set it to DEBUG to capture all messages
339
+ root_logger = logging.getLogger()
340
+ root_logger.setLevel(logging.DEBUG)
341
+
342
+ # Add all handlers to the root logger
343
+ root_logger.addHandler(info_file_handler)
344
+ root_logger.addHandler(console_handler)
345
+ root_logger.addHandler(debug_file_handler)
346
+
347
+ # Silence other noisy loggers by setting their level higher than root
348
+ logging.getLogger("uvicorn").setLevel(logging.WARNING)
349
+ logging.getLogger("httpx").setLevel(logging.WARNING)
350
+
351
+ # Isolate LiteLLM's logger to prevent it from reaching the console.
352
+ # We will capture its logs via the logger_fn callback in the client instead.
353
+ litellm_logger = logging.getLogger("LiteLLM")
354
+ litellm_logger.handlers = []
355
+ litellm_logger.propagate = False
356
+
357
+ # Now that logging is configured, log the module load time to debug file only
358
+ logging.debug(f"Modules loaded in {_elapsed:.2f}s")
359
+
360
+ # Load environment variables from .env file
361
+ load_dotenv(_root_dir / ".env")
362
+
363
+ # --- Configuration ---
364
+ USE_EMBEDDING_BATCHER = False
365
+ ENABLE_REQUEST_LOGGING = args.enable_request_logging
366
+ ENABLE_RAW_LOGGING = args.enable_raw_logging
367
+ if ENABLE_REQUEST_LOGGING:
368
+ logging.info(
369
+ "Transaction logging is enabled (library-level with provider correlation)."
370
+ )
371
+ if ENABLE_RAW_LOGGING:
372
+ logging.info("Raw I/O logging is enabled (proxy boundary, unmodified HTTP data).")
373
+ PROXY_API_KEY = os.getenv("PROXY_API_KEY")
374
+ # Note: PROXY_API_KEY validation moved to server startup to allow credential tool to run first
375
+
376
+ # Discover API keys from environment variables
377
+ api_keys = {}
378
+ for key, value in os.environ.items():
379
+ if "_API_KEY" in key and key != "PROXY_API_KEY":
380
+ provider = key.split("_API_KEY")[0].lower()
381
+ if provider not in api_keys:
382
+ api_keys[provider] = []
383
+ api_keys[provider].append(value)
384
+
385
+ # Load model ignore lists from environment variables
386
+ ignore_models = {}
387
+ for key, value in os.environ.items():
388
+ if key.startswith("IGNORE_MODELS_"):
389
+ provider = key.replace("IGNORE_MODELS_", "").lower()
390
+ models_to_ignore = [
391
+ model.strip() for model in value.split(",") if model.strip()
392
+ ]
393
+ ignore_models[provider] = models_to_ignore
394
+ logging.debug(
395
+ f"Loaded ignore list for provider '{provider}': {models_to_ignore}"
396
+ )
397
+
398
+ # Load model whitelist from environment variables
399
+ whitelist_models = {}
400
+ for key, value in os.environ.items():
401
+ if key.startswith("WHITELIST_MODELS_"):
402
+ provider = key.replace("WHITELIST_MODELS_", "").lower()
403
+ models_to_whitelist = [
404
+ model.strip() for model in value.split(",") if model.strip()
405
+ ]
406
+ whitelist_models[provider] = models_to_whitelist
407
+ logging.debug(
408
+ f"Loaded whitelist for provider '{provider}': {models_to_whitelist}"
409
+ )
410
+
411
+ # Load max concurrent requests per key from environment variables
412
+ max_concurrent_requests_per_key = {}
413
+ for key, value in os.environ.items():
414
+ if key.startswith("MAX_CONCURRENT_REQUESTS_PER_KEY_"):
415
+ provider = key.replace("MAX_CONCURRENT_REQUESTS_PER_KEY_", "").lower()
416
+ try:
417
+ max_concurrent = int(value)
418
+ if max_concurrent < 1:
419
+ logging.warning(
420
+ f"Invalid max_concurrent value for provider '{provider}': {value}. Must be >= 1. Using default (1)."
421
+ )
422
+ max_concurrent = 1
423
+ max_concurrent_requests_per_key[provider] = max_concurrent
424
+ logging.debug(
425
+ f"Loaded max concurrent requests for provider '{provider}': {max_concurrent}"
426
+ )
427
+ except ValueError:
428
+ logging.warning(
429
+ f"Invalid max_concurrent value for provider '{provider}': {value}. Using default (1)."
430
+ )
431
+
432
+
433
+ # --- Lifespan Management ---
434
+ @asynccontextmanager
435
+ async def lifespan(app: FastAPI):
436
+ """Manage the RotatingClient's lifecycle with the app's lifespan."""
437
+ # [MODIFIED] Perform skippable OAuth initialization at startup
438
+ skip_oauth_init = os.getenv("SKIP_OAUTH_INIT_CHECK", "false").lower() == "true"
439
+
440
+ # The CredentialManager now handles all discovery, including .env overrides.
441
+ # We pass all environment variables to it for this purpose.
442
+ cred_manager = CredentialManager(os.environ)
443
+ oauth_credentials = cred_manager.discover_and_prepare()
444
+
445
+ if not skip_oauth_init and oauth_credentials:
446
+ logging.info("Starting OAuth credential validation and deduplication...")
447
+ processed_emails = {} # email -> {provider: path}
448
+ credentials_to_initialize = {} # provider -> [paths]
449
+ final_oauth_credentials = {}
450
+
451
+ # --- Pass 1: Pre-initialization Scan & Deduplication ---
452
+ # logging.info("Pass 1: Scanning for existing metadata to find duplicates...")
453
+ for provider, paths in oauth_credentials.items():
454
+ if provider not in credentials_to_initialize:
455
+ credentials_to_initialize[provider] = []
456
+ for path in paths:
457
+ # Skip env-based credentials (virtual paths) - they don't have metadata files
458
+ if path.startswith("env://"):
459
+ credentials_to_initialize[provider].append(path)
460
+ continue
461
+
462
+ try:
463
+ with open(path, "r") as f:
464
+ data = json.load(f)
465
+ metadata = data.get("_proxy_metadata", {})
466
+ email = metadata.get("email")
467
+
468
+ if email:
469
+ if email not in processed_emails:
470
+ processed_emails[email] = {}
471
+
472
+ if provider in processed_emails[email]:
473
+ original_path = processed_emails[email][provider]
474
+ logging.warning(
475
+ f"Duplicate for '{email}' on '{provider}' found in pre-scan: '{Path(path).name}'. Original: '{Path(original_path).name}'. Skipping."
476
+ )
477
+ continue
478
+ else:
479
+ processed_emails[email][provider] = path
480
+
481
+ credentials_to_initialize[provider].append(path)
482
+
483
+ except (FileNotFoundError, json.JSONDecodeError) as e:
484
+ logging.warning(
485
+ f"Could not pre-read metadata from '{path}': {e}. Will process during initialization."
486
+ )
487
+ credentials_to_initialize[provider].append(path)
488
+
489
+ # --- Pass 2: Parallel Initialization of Filtered Credentials ---
490
+ # logging.info("Pass 2: Initializing unique credentials and performing final check...")
491
+ async def process_credential(provider: str, path: str, provider_instance):
492
+ """Process a single credential: initialize and fetch user info."""
493
+ try:
494
+ await provider_instance.initialize_token(path)
495
+
496
+ if not hasattr(provider_instance, "get_user_info"):
497
+ return (provider, path, None, None)
498
+
499
+ user_info = await provider_instance.get_user_info(path)
500
+ email = user_info.get("email")
501
+ return (provider, path, email, None)
502
+
503
+ except Exception as e:
504
+ logging.error(
505
+ f"Failed to process OAuth token for {provider} at '{path}': {e}"
506
+ )
507
+ return (provider, path, None, e)
508
+
509
+ # Collect all tasks for parallel execution
510
+ tasks = []
511
+ for provider, paths in credentials_to_initialize.items():
512
+ if not paths:
513
+ continue
514
+
515
+ provider_plugin_class = PROVIDER_PLUGINS.get(provider)
516
+ if not provider_plugin_class:
517
+ continue
518
+
519
+ provider_instance = provider_plugin_class()
520
+
521
+ for path in paths:
522
+ tasks.append(process_credential(provider, path, provider_instance))
523
+
524
+ # Execute all credential processing tasks in parallel
525
+ results = await asyncio.gather(*tasks, return_exceptions=True)
526
+
527
+ # --- Pass 3: Sequential Deduplication and Final Assembly ---
528
+ for result in results:
529
+ # Handle exceptions from gather
530
+ if isinstance(result, Exception):
531
+ logging.error(f"Credential processing raised exception: {result}")
532
+ continue
533
+
534
+ provider, path, email, error = result
535
+
536
+ # Skip if there was an error
537
+ if error:
538
+ continue
539
+
540
+ # If provider doesn't support get_user_info, add directly
541
+ if email is None:
542
+ if provider not in final_oauth_credentials:
543
+ final_oauth_credentials[provider] = []
544
+ final_oauth_credentials[provider].append(path)
545
+ continue
546
+
547
+ # Handle empty email
548
+ if not email:
549
+ logging.warning(
550
+ f"Could not retrieve email for '{path}'. Treating as unique."
551
+ )
552
+ if provider not in final_oauth_credentials:
553
+ final_oauth_credentials[provider] = []
554
+ final_oauth_credentials[provider].append(path)
555
+ continue
556
+
557
+ # Deduplication check
558
+ if email not in processed_emails:
559
+ processed_emails[email] = {}
560
+
561
+ if (
562
+ provider in processed_emails[email]
563
+ and processed_emails[email][provider] != path
564
+ ):
565
+ original_path = processed_emails[email][provider]
566
+ logging.warning(
567
+ f"Duplicate for '{email}' on '{provider}' found post-init: '{Path(path).name}'. Original: '{Path(original_path).name}'. Skipping."
568
+ )
569
+ continue
570
+ else:
571
+ processed_emails[email][provider] = path
572
+ if provider not in final_oauth_credentials:
573
+ final_oauth_credentials[provider] = []
574
+ final_oauth_credentials[provider].append(path)
575
+
576
+ # Update metadata (skip for env-based credentials - they don't have files)
577
+ if not path.startswith("env://"):
578
+ try:
579
+ with open(path, "r+") as f:
580
+ data = json.load(f)
581
+ metadata = data.get("_proxy_metadata", {})
582
+ metadata["email"] = email
583
+ metadata["last_check_timestamp"] = time.time()
584
+ data["_proxy_metadata"] = metadata
585
+ f.seek(0)
586
+ json.dump(data, f, indent=2)
587
+ f.truncate()
588
+ except Exception as e:
589
+ logging.error(f"Failed to update metadata for '{path}': {e}")
590
+
591
+ logging.info("OAuth credential processing complete.")
592
+ oauth_credentials = final_oauth_credentials
593
+
594
+ # [NEW] Load provider-specific params
595
+ litellm_provider_params = {
596
+ "gemini_cli": {"project_id": os.getenv("GEMINI_CLI_PROJECT_ID")}
597
+ }
598
+
599
+ # Load global timeout from environment (default 30 seconds)
600
+ global_timeout = int(os.getenv("GLOBAL_TIMEOUT", "30"))
601
+
602
+ # The client now uses the root logger configuration
603
+ client = RotatingClient(
604
+ api_keys=api_keys,
605
+ oauth_credentials=oauth_credentials, # Pass OAuth config
606
+ configure_logging=True,
607
+ global_timeout=global_timeout,
608
+ litellm_provider_params=litellm_provider_params,
609
+ ignore_models=ignore_models,
610
+ whitelist_models=whitelist_models,
611
+ enable_request_logging=ENABLE_REQUEST_LOGGING,
612
+ max_concurrent_requests_per_key=max_concurrent_requests_per_key,
613
+ )
614
+
615
+ # Log loaded credentials summary (compact, always visible for deployment verification)
616
+ # _api_summary = ', '.join([f"{p}:{len(c)}" for p, c in api_keys.items()]) if api_keys else "none"
617
+ # _oauth_summary = ', '.join([f"{p}:{len(c)}" for p, c in oauth_credentials.items()]) if oauth_credentials else "none"
618
+ # _total_summary = ', '.join([f"{p}:{len(c)}" for p, c in client.all_credentials.items()])
619
+ # print(f"🔑 Credentials loaded: {_total_summary} (API: {_api_summary} | OAuth: {_oauth_summary})")
620
+ client.background_refresher.start() # Start the background task
621
+ app.state.rotating_client = client
622
+
623
+ # Warn if no provider credentials are configured
624
+ if not client.all_credentials:
625
+ logging.warning("=" * 70)
626
+ logging.warning("⚠️ NO PROVIDER CREDENTIALS CONFIGURED")
627
+ logging.warning("The proxy is running but cannot serve any LLM requests.")
628
+ logging.warning(
629
+ "Launch the credential tool to add API keys or OAuth credentials."
630
+ )
631
+ logging.warning(" • Executable: Run with --add-credential flag")
632
+ logging.warning(" • Source: python src/proxy_app/main.py --add-credential")
633
+ logging.warning("=" * 70)
634
+
635
+ os.environ["LITELLM_LOG"] = "ERROR"
636
+ litellm.set_verbose = False
637
+ litellm.drop_params = True
638
+ if USE_EMBEDDING_BATCHER:
639
+ batcher = EmbeddingBatcher(client=client)
640
+ app.state.embedding_batcher = batcher
641
+ logging.info("RotatingClient and EmbeddingBatcher initialized.")
642
+ else:
643
+ app.state.embedding_batcher = None
644
+ logging.info("RotatingClient initialized (EmbeddingBatcher disabled).")
645
+
646
+ # Start model info service in background (fetches pricing/capabilities data)
647
+ # This runs asynchronously and doesn't block proxy startup
648
+ model_info_service = await init_model_info_service()
649
+ app.state.model_info_service = model_info_service
650
+ logging.info("Model info service started (fetching pricing data in background).")
651
+
652
+ yield
653
+
654
+ await client.background_refresher.stop() # Stop the background task on shutdown
655
+ if app.state.embedding_batcher:
656
+ await app.state.embedding_batcher.stop()
657
+ await client.close()
658
+
659
+ # Stop model info service
660
+ if hasattr(app.state, "model_info_service") and app.state.model_info_service:
661
+ await app.state.model_info_service.stop()
662
+
663
+ if app.state.embedding_batcher:
664
+ logging.info("RotatingClient and EmbeddingBatcher closed.")
665
+ else:
666
+ logging.info("RotatingClient closed.")
667
+
668
+
669
+ # --- FastAPI App Setup ---
670
+ app = FastAPI(lifespan=lifespan)
671
+
672
+ # Add CORS middleware to allow all origins, methods, and headers
673
+ app.add_middleware(
674
+ CORSMiddleware,
675
+ allow_origins=["*"], # Allows all origins
676
+ allow_credentials=True,
677
+ allow_methods=["*"], # Allows all methods
678
+ allow_headers=["*"], # Allows all headers
679
+ )
680
+ api_key_header = APIKeyHeader(name="Authorization", auto_error=False)
681
+
682
+
683
+ def get_rotating_client(request: Request) -> RotatingClient:
684
+ """Dependency to get the rotating client instance from the app state."""
685
+ return request.app.state.rotating_client
686
+
687
+
688
+ def get_embedding_batcher(request: Request) -> EmbeddingBatcher:
689
+ """Dependency to get the embedding batcher instance from the app state."""
690
+ return request.app.state.embedding_batcher
691
+
692
+
693
+ async def verify_api_key(auth: str = Depends(api_key_header)):
694
+ """Dependency to verify the proxy API key."""
695
+ # If PROXY_API_KEY is not set or empty, skip verification (open access)
696
+ if not PROXY_API_KEY:
697
+ return auth
698
+ if not auth or auth != f"Bearer {PROXY_API_KEY}":
699
+ raise HTTPException(status_code=401, detail="Invalid or missing API Key")
700
+ return auth
701
+
702
+
703
+ # --- Anthropic API Key Header ---
704
+ anthropic_api_key_header = APIKeyHeader(name="x-api-key", auto_error=False)
705
+
706
+
707
+ async def verify_anthropic_api_key(
708
+ x_api_key: str = Depends(anthropic_api_key_header),
709
+ auth: str = Depends(api_key_header),
710
+ ):
711
+ """
712
+ Dependency to verify API key for Anthropic endpoints.
713
+ Accepts either x-api-key header (Anthropic style) or Authorization Bearer (OpenAI style).
714
+ """
715
+ # Check x-api-key first (Anthropic style)
716
+ if x_api_key and x_api_key == PROXY_API_KEY:
717
+ return x_api_key
718
+ # Fall back to Bearer token (OpenAI style)
719
+ if auth and auth == f"Bearer {PROXY_API_KEY}":
720
+ return auth
721
+ raise HTTPException(status_code=401, detail="Invalid or missing API Key")
722
+
723
+
724
+ async def streaming_response_wrapper(
725
+ request: Request,
726
+ request_data: dict,
727
+ response_stream: AsyncGenerator[str, None],
728
+ logger: Optional[RawIOLogger] = None,
729
+ ) -> AsyncGenerator[str, None]:
730
+ """
731
+ Wraps a streaming response to log the full response after completion
732
+ and ensures any errors during the stream are sent to the client.
733
+ """
734
+ response_chunks = []
735
+ full_response = {}
736
+
737
+ try:
738
+ async for chunk_str in response_stream:
739
+ if await request.is_disconnected():
740
+ logging.warning("Client disconnected, stopping stream.")
741
+ break
742
+ yield chunk_str
743
+ if chunk_str.strip() and chunk_str.startswith("data:"):
744
+ content = chunk_str[len("data:") :].strip()
745
+ if content != "[DONE]":
746
+ try:
747
+ chunk_data = json.loads(content)
748
+ response_chunks.append(chunk_data)
749
+ if logger:
750
+ logger.log_stream_chunk(chunk_data)
751
+ except json.JSONDecodeError:
752
+ pass
753
+ except Exception as e:
754
+ logging.error(f"An error occurred during the response stream: {e}")
755
+ # Yield a final error message to the client to ensure they are not left hanging.
756
+ error_payload = {
757
+ "error": {
758
+ "message": f"An unexpected error occurred during the stream: {str(e)}",
759
+ "type": "proxy_internal_error",
760
+ "code": 500,
761
+ }
762
+ }
763
+ yield f"data: {json.dumps(error_payload)}\n\n"
764
+ yield "data: [DONE]\n\n"
765
+ # Also log this as a failed request
766
+ if logger:
767
+ logger.log_final_response(
768
+ status_code=500, headers=None, body={"error": str(e)}
769
+ )
770
+ return # Stop further processing
771
+ finally:
772
+ if response_chunks:
773
+ # --- Aggregation Logic ---
774
+ final_message = {"role": "assistant"}
775
+ aggregated_tool_calls = {}
776
+ usage_data = None
777
+ finish_reason = None
778
+
779
+ for chunk in response_chunks:
780
+ if "choices" in chunk and chunk["choices"]:
781
+ choice = chunk["choices"][0]
782
+ delta = choice.get("delta", {})
783
+
784
+ # Dynamically aggregate all fields from the delta
785
+ for key, value in delta.items():
786
+ if value is None:
787
+ continue
788
+
789
+ if key == "content":
790
+ if "content" not in final_message:
791
+ final_message["content"] = ""
792
+ if value:
793
+ final_message["content"] += value
794
+
795
+ elif key == "tool_calls":
796
+ for tc_chunk in value:
797
+ index = tc_chunk["index"]
798
+ if index not in aggregated_tool_calls:
799
+ aggregated_tool_calls[index] = {
800
+ "type": "function",
801
+ "function": {"name": "", "arguments": ""},
802
+ }
803
+ # Ensure 'function' key exists for this index before accessing its sub-keys
804
+ if "function" not in aggregated_tool_calls[index]:
805
+ aggregated_tool_calls[index]["function"] = {
806
+ "name": "",
807
+ "arguments": "",
808
+ }
809
+ if tc_chunk.get("id"):
810
+ aggregated_tool_calls[index]["id"] = tc_chunk["id"]
811
+ if "function" in tc_chunk:
812
+ if "name" in tc_chunk["function"]:
813
+ if tc_chunk["function"]["name"] is not None:
814
+ aggregated_tool_calls[index]["function"][
815
+ "name"
816
+ ] += tc_chunk["function"]["name"]
817
+ if "arguments" in tc_chunk["function"]:
818
+ if (
819
+ tc_chunk["function"]["arguments"]
820
+ is not None
821
+ ):
822
+ aggregated_tool_calls[index]["function"][
823
+ "arguments"
824
+ ] += tc_chunk["function"]["arguments"]
825
+
826
+ elif key == "function_call":
827
+ if "function_call" not in final_message:
828
+ final_message["function_call"] = {
829
+ "name": "",
830
+ "arguments": "",
831
+ }
832
+ if "name" in value:
833
+ if value["name"] is not None:
834
+ final_message["function_call"]["name"] += value[
835
+ "name"
836
+ ]
837
+ if "arguments" in value:
838
+ if value["arguments"] is not None:
839
+ final_message["function_call"]["arguments"] += (
840
+ value["arguments"]
841
+ )
842
+
843
+ else: # Generic key handling for other data like 'reasoning'
844
+ # FIX: Role should always replace, never concatenate
845
+ if key == "role":
846
+ final_message[key] = value
847
+ elif key not in final_message:
848
+ final_message[key] = value
849
+ elif isinstance(final_message.get(key), str):
850
+ final_message[key] += value
851
+ else:
852
+ final_message[key] = value
853
+
854
+ if "finish_reason" in choice and choice["finish_reason"]:
855
+ finish_reason = choice["finish_reason"]
856
+
857
+ if "usage" in chunk and chunk["usage"]:
858
+ usage_data = chunk["usage"]
859
+
860
+ # --- Final Response Construction ---
861
+ if aggregated_tool_calls:
862
+ final_message["tool_calls"] = list(aggregated_tool_calls.values())
863
+ # CRITICAL FIX: Override finish_reason when tool_calls exist
864
+ # This ensures OpenCode and other agentic systems continue the conversation loop
865
+ finish_reason = "tool_calls"
866
+
867
+ # Ensure standard fields are present for consistent logging
868
+ for field in ["content", "tool_calls", "function_call"]:
869
+ if field not in final_message:
870
+ final_message[field] = None
871
+
872
+ first_chunk = response_chunks[0]
873
+ final_choice = {
874
+ "index": 0,
875
+ "message": final_message,
876
+ "finish_reason": finish_reason,
877
+ }
878
+
879
+ full_response = {
880
+ "id": first_chunk.get("id"),
881
+ "object": "chat.completion",
882
+ "created": first_chunk.get("created"),
883
+ "model": first_chunk.get("model"),
884
+ "choices": [final_choice],
885
+ "usage": usage_data,
886
+ }
887
+
888
+ if logger:
889
+ logger.log_final_response(
890
+ status_code=200,
891
+ headers=None, # Headers are not available at this stage
892
+ body=full_response,
893
+ )
894
+
895
+
896
+ @app.post("/v1/chat/completions")
897
+ async def chat_completions(
898
+ request: Request,
899
+ client: RotatingClient = Depends(get_rotating_client),
900
+ _=Depends(verify_api_key),
901
+ ):
902
+ """
903
+ OpenAI-compatible endpoint powered by the RotatingClient.
904
+ Handles both streaming and non-streaming responses and logs them.
905
+ """
906
+ # Raw I/O logger captures unmodified HTTP data at proxy boundary (disabled by default)
907
+ raw_logger = RawIOLogger() if ENABLE_RAW_LOGGING else None
908
+ try:
909
+ # Read and parse the request body only once at the beginning.
910
+ try:
911
+ request_data = await request.json()
912
+ except json.JSONDecodeError:
913
+ raise HTTPException(status_code=400, detail="Invalid JSON in request body.")
914
+
915
+ # Global temperature=0 override (controlled by .env variable, default: OFF)
916
+ # Low temperature makes models deterministic and prone to following training data
917
+ # instead of actual schemas, which can cause tool hallucination
918
+ # Modes: "remove" = delete temperature key, "set" = change to 1.0, "false" = disabled
919
+ override_temp_zero = os.getenv("OVERRIDE_TEMPERATURE_ZERO", "false").lower()
920
+
921
+ if (
922
+ override_temp_zero in ("remove", "set", "true", "1", "yes")
923
+ and "temperature" in request_data
924
+ and request_data["temperature"] == 0
925
+ ):
926
+ if override_temp_zero == "remove":
927
+ # Remove temperature key entirely
928
+ del request_data["temperature"]
929
+ logging.debug(
930
+ "OVERRIDE_TEMPERATURE_ZERO=remove: Removed temperature=0 from request"
931
+ )
932
+ else:
933
+ # Set to 1.0 (for "set", "true", "1", "yes")
934
+ request_data["temperature"] = 1.0
935
+ logging.debug(
936
+ "OVERRIDE_TEMPERATURE_ZERO=set: Converting temperature=0 to temperature=1.0"
937
+ )
938
+
939
+ # If raw logging is enabled, capture the unmodified request data.
940
+ if raw_logger:
941
+ raw_logger.log_request(headers=request.headers, body=request_data)
942
+
943
+ # Extract and log specific reasoning parameters for monitoring.
944
+ model = request_data.get("model")
945
+ generation_cfg = (
946
+ request_data.get("generationConfig", {})
947
+ or request_data.get("generation_config", {})
948
+ or {}
949
+ )
950
+ reasoning_effort = request_data.get("reasoning_effort") or generation_cfg.get(
951
+ "reasoning_effort"
952
+ )
953
+
954
+ logging.getLogger("rotator_library").debug(
955
+ f"Handling reasoning parameters: model={model}, reasoning_effort={reasoning_effort}"
956
+ )
957
+
958
+ # Log basic request info to console (this is a separate, simpler logger).
959
+ log_request_to_console(
960
+ url=str(request.url),
961
+ headers=dict(request.headers),
962
+ client_info=(request.client.host, request.client.port),
963
+ request_data=request_data,
964
+ )
965
+ is_streaming = request_data.get("stream", False)
966
+
967
+ if is_streaming:
968
+ response_generator = client.acompletion(request=request, **request_data)
969
+ return StreamingResponse(
970
+ streaming_response_wrapper(
971
+ request, request_data, response_generator, raw_logger
972
+ ),
973
+ media_type="text/event-stream",
974
+ )
975
+ else:
976
+ response = await client.acompletion(request=request, **request_data)
977
+ if raw_logger:
978
+ # Assuming response has status_code and headers attributes
979
+ # This might need adjustment based on the actual response object
980
+ response_headers = (
981
+ response.headers if hasattr(response, "headers") else None
982
+ )
983
+ status_code = (
984
+ response.status_code if hasattr(response, "status_code") else 200
985
+ )
986
+ raw_logger.log_final_response(
987
+ status_code=status_code,
988
+ headers=response_headers,
989
+ body=response.model_dump(),
990
+ )
991
+ return response
992
+
993
+ except (
994
+ litellm.InvalidRequestError,
995
+ ValueError,
996
+ litellm.ContextWindowExceededError,
997
+ ) as e:
998
+ raise HTTPException(status_code=400, detail=f"Invalid Request: {str(e)}")
999
+ except litellm.AuthenticationError as e:
1000
+ raise HTTPException(status_code=401, detail=f"Authentication Error: {str(e)}")
1001
+ except litellm.RateLimitError as e:
1002
+ raise HTTPException(status_code=429, detail=f"Rate Limit Exceeded: {str(e)}")
1003
+ except (litellm.ServiceUnavailableError, litellm.APIConnectionError) as e:
1004
+ raise HTTPException(status_code=503, detail=f"Service Unavailable: {str(e)}")
1005
+ except litellm.Timeout as e:
1006
+ raise HTTPException(status_code=504, detail=f"Gateway Timeout: {str(e)}")
1007
+ except (litellm.InternalServerError, litellm.OpenAIError) as e:
1008
+ raise HTTPException(status_code=502, detail=f"Bad Gateway: {str(e)}")
1009
+ except Exception as e:
1010
+ logging.error(f"Request failed after all retries: {e}")
1011
+ # Optionally log the failed request
1012
+ if ENABLE_REQUEST_LOGGING:
1013
+ try:
1014
+ request_data = await request.json()
1015
+ except json.JSONDecodeError:
1016
+ request_data = {"error": "Could not parse request body"}
1017
+ if raw_logger:
1018
+ raw_logger.log_final_response(
1019
+ status_code=500, headers=None, body={"error": str(e)}
1020
+ )
1021
+ raise HTTPException(status_code=500, detail=str(e))
1022
+
1023
+
1024
+ # --- Anthropic Messages API Endpoint ---
1025
+ @app.post("/v1/messages")
1026
+ async def anthropic_messages(
1027
+ request: Request,
1028
+ body: AnthropicMessagesRequest,
1029
+ client: RotatingClient = Depends(get_rotating_client),
1030
+ _=Depends(verify_anthropic_api_key),
1031
+ ):
1032
+ """
1033
+ Anthropic-compatible Messages API endpoint.
1034
+
1035
+ Accepts requests in Anthropic's format and returns responses in Anthropic's format.
1036
+ Internally translates to OpenAI format for processing via LiteLLM.
1037
+
1038
+ This endpoint is compatible with Claude Code and other Anthropic API clients.
1039
+ """
1040
+ # Initialize raw I/O logger if enabled (for debugging proxy boundary)
1041
+ logger = RawIOLogger() if ENABLE_RAW_LOGGING else None
1042
+
1043
+ # Log raw Anthropic request if raw logging is enabled
1044
+ if logger:
1045
+ logger.log_request(
1046
+ headers=dict(request.headers),
1047
+ body=body.model_dump(exclude_none=True),
1048
+ )
1049
+
1050
+ try:
1051
+ # Log the request to console
1052
+ log_request_to_console(
1053
+ url=str(request.url),
1054
+ headers=dict(request.headers),
1055
+ client_info=(
1056
+ request.client.host if request.client else "unknown",
1057
+ request.client.port if request.client else 0,
1058
+ ),
1059
+ request_data=body.model_dump(exclude_none=True),
1060
+ )
1061
+
1062
+ # Use the library method to handle the request
1063
+ result = await client.anthropic_messages(body, raw_request=request)
1064
+
1065
+ if body.stream:
1066
+ # Streaming response
1067
+ return StreamingResponse(
1068
+ result,
1069
+ media_type="text/event-stream",
1070
+ headers={
1071
+ "Cache-Control": "no-cache",
1072
+ "Connection": "keep-alive",
1073
+ "X-Accel-Buffering": "no",
1074
+ },
1075
+ )
1076
+ else:
1077
+ # Non-streaming response
1078
+ if logger:
1079
+ logger.log_final_response(
1080
+ status_code=200,
1081
+ headers=None,
1082
+ body=result,
1083
+ )
1084
+ return JSONResponse(content=result)
1085
+
1086
+ except (
1087
+ litellm.InvalidRequestError,
1088
+ ValueError,
1089
+ litellm.ContextWindowExceededError,
1090
+ ) as e:
1091
+ error_response = {
1092
+ "type": "error",
1093
+ "error": {"type": "invalid_request_error", "message": str(e)},
1094
+ }
1095
+ raise HTTPException(status_code=400, detail=error_response)
1096
+ except litellm.AuthenticationError as e:
1097
+ error_response = {
1098
+ "type": "error",
1099
+ "error": {"type": "authentication_error", "message": str(e)},
1100
+ }
1101
+ raise HTTPException(status_code=401, detail=error_response)
1102
+ except litellm.RateLimitError as e:
1103
+ error_response = {
1104
+ "type": "error",
1105
+ "error": {"type": "rate_limit_error", "message": str(e)},
1106
+ }
1107
+ raise HTTPException(status_code=429, detail=error_response)
1108
+ except (litellm.ServiceUnavailableError, litellm.APIConnectionError) as e:
1109
+ error_response = {
1110
+ "type": "error",
1111
+ "error": {"type": "api_error", "message": str(e)},
1112
+ }
1113
+ raise HTTPException(status_code=503, detail=error_response)
1114
+ except litellm.Timeout as e:
1115
+ error_response = {
1116
+ "type": "error",
1117
+ "error": {"type": "api_error", "message": f"Request timed out: {str(e)}"},
1118
+ }
1119
+ raise HTTPException(status_code=504, detail=error_response)
1120
+ except Exception as e:
1121
+ logging.error(f"Anthropic messages endpoint error: {e}")
1122
+ if logger:
1123
+ logger.log_final_response(
1124
+ status_code=500,
1125
+ headers=None,
1126
+ body={"error": str(e)},
1127
+ )
1128
+ error_response = {
1129
+ "type": "error",
1130
+ "error": {"type": "api_error", "message": str(e)},
1131
+ }
1132
+ raise HTTPException(status_code=500, detail=error_response)
1133
+
1134
+
1135
+ # --- Anthropic Count Tokens Endpoint ---
1136
+ @app.post("/v1/messages/count_tokens")
1137
+ async def anthropic_count_tokens(
1138
+ request: Request,
1139
+ body: AnthropicCountTokensRequest,
1140
+ client: RotatingClient = Depends(get_rotating_client),
1141
+ _=Depends(verify_anthropic_api_key),
1142
+ ):
1143
+ """
1144
+ Anthropic-compatible count_tokens endpoint.
1145
+
1146
+ Counts the number of tokens that would be used by a Messages API request.
1147
+ This is useful for estimating costs and managing context windows.
1148
+
1149
+ Accepts requests in Anthropic's format and returns token count in Anthropic's format.
1150
+ """
1151
+ try:
1152
+ # Use the library method to handle the request
1153
+ result = await client.anthropic_count_tokens(body)
1154
+ return JSONResponse(content=result)
1155
+
1156
+ except (
1157
+ litellm.InvalidRequestError,
1158
+ ValueError,
1159
+ litellm.ContextWindowExceededError,
1160
+ ) as e:
1161
+ error_response = {
1162
+ "type": "error",
1163
+ "error": {"type": "invalid_request_error", "message": str(e)},
1164
+ }
1165
+ raise HTTPException(status_code=400, detail=error_response)
1166
+ except litellm.AuthenticationError as e:
1167
+ error_response = {
1168
+ "type": "error",
1169
+ "error": {"type": "authentication_error", "message": str(e)},
1170
+ }
1171
+ raise HTTPException(status_code=401, detail=error_response)
1172
+ except Exception as e:
1173
+ logging.error(f"Anthropic count_tokens endpoint error: {e}")
1174
+ error_response = {
1175
+ "type": "error",
1176
+ "error": {"type": "api_error", "message": str(e)},
1177
+ }
1178
+ raise HTTPException(status_code=500, detail=error_response)
1179
+
1180
+
1181
+ @app.post("/v1/embeddings")
1182
+ async def embeddings(
1183
+ request: Request,
1184
+ body: EmbeddingRequest,
1185
+ client: RotatingClient = Depends(get_rotating_client),
1186
+ batcher: Optional[EmbeddingBatcher] = Depends(get_embedding_batcher),
1187
+ _=Depends(verify_api_key),
1188
+ ):
1189
+ """
1190
+ OpenAI-compatible endpoint for creating embeddings.
1191
+ Supports two modes based on the USE_EMBEDDING_BATCHER flag:
1192
+ - True: Uses a server-side batcher for high throughput.
1193
+ - False: Passes requests directly to the provider.
1194
+ """
1195
+ try:
1196
+ request_data = body.model_dump(exclude_none=True)
1197
+ log_request_to_console(
1198
+ url=str(request.url),
1199
+ headers=dict(request.headers),
1200
+ client_info=(request.client.host, request.client.port),
1201
+ request_data=request_data,
1202
+ )
1203
+ if USE_EMBEDDING_BATCHER and batcher:
1204
+ # --- Server-Side Batching Logic ---
1205
+ request_data = body.model_dump(exclude_none=True)
1206
+ inputs = request_data.get("input", [])
1207
+ if isinstance(inputs, str):
1208
+ inputs = [inputs]
1209
+
1210
+ tasks = []
1211
+ for single_input in inputs:
1212
+ individual_request = request_data.copy()
1213
+ individual_request["input"] = single_input
1214
+ tasks.append(batcher.add_request(individual_request))
1215
+
1216
+ results = await asyncio.gather(*tasks)
1217
+
1218
+ all_data = []
1219
+ total_prompt_tokens = 0
1220
+ total_tokens = 0
1221
+ for i, result in enumerate(results):
1222
+ result["data"][0]["index"] = i
1223
+ all_data.extend(result["data"])
1224
+ total_prompt_tokens += result["usage"]["prompt_tokens"]
1225
+ total_tokens += result["usage"]["total_tokens"]
1226
+
1227
+ final_response_data = {
1228
+ "object": "list",
1229
+ "model": results[0]["model"],
1230
+ "data": all_data,
1231
+ "usage": {
1232
+ "prompt_tokens": total_prompt_tokens,
1233
+ "total_tokens": total_tokens,
1234
+ },
1235
+ }
1236
+ response = litellm.EmbeddingResponse(**final_response_data)
1237
+
1238
+ else:
1239
+ # --- Direct Pass-Through Logic ---
1240
+ request_data = body.model_dump(exclude_none=True)
1241
+ if isinstance(request_data.get("input"), str):
1242
+ request_data["input"] = [request_data["input"]]
1243
+
1244
+ response = await client.aembedding(request=request, **request_data)
1245
+
1246
+ return response
1247
+
1248
+ except HTTPException as e:
1249
+ # Re-raise HTTPException to ensure it's not caught by the generic Exception handler
1250
+ raise e
1251
+ except (
1252
+ litellm.InvalidRequestError,
1253
+ ValueError,
1254
+ litellm.ContextWindowExceededError,
1255
+ ) as e:
1256
+ raise HTTPException(status_code=400, detail=f"Invalid Request: {str(e)}")
1257
+ except litellm.AuthenticationError as e:
1258
+ raise HTTPException(status_code=401, detail=f"Authentication Error: {str(e)}")
1259
+ except litellm.RateLimitError as e:
1260
+ raise HTTPException(status_code=429, detail=f"Rate Limit Exceeded: {str(e)}")
1261
+ except (litellm.ServiceUnavailableError, litellm.APIConnectionError) as e:
1262
+ raise HTTPException(status_code=503, detail=f"Service Unavailable: {str(e)}")
1263
+ except litellm.Timeout as e:
1264
+ raise HTTPException(status_code=504, detail=f"Gateway Timeout: {str(e)}")
1265
+ except (litellm.InternalServerError, litellm.OpenAIError) as e:
1266
+ raise HTTPException(status_code=502, detail=f"Bad Gateway: {str(e)}")
1267
+ except Exception as e:
1268
+ logging.error(f"Embedding request failed: {e}")
1269
+ raise HTTPException(status_code=500, detail=str(e))
1270
+
1271
+
1272
+ @app.get("/")
1273
+ def read_root():
1274
+ return {"Status": "API Key Proxy is running"}
1275
+
1276
+
1277
+ @app.get("/v1/models")
1278
+ async def list_models(
1279
+ request: Request,
1280
+ client: RotatingClient = Depends(get_rotating_client),
1281
+ _=Depends(verify_api_key),
1282
+ enriched: bool = True,
1283
+ ):
1284
+ """
1285
+ Returns a list of available models in the OpenAI-compatible format.
1286
+
1287
+ Query Parameters:
1288
+ enriched: If True (default), returns detailed model info with pricing and capabilities.
1289
+ If False, returns minimal OpenAI-compatible response.
1290
+ """
1291
+ model_ids = await client.get_all_available_models(grouped=False)
1292
+
1293
+ if enriched and hasattr(request.app.state, "model_info_service"):
1294
+ model_info_service = request.app.state.model_info_service
1295
+ if model_info_service.is_ready:
1296
+ # Return enriched model data
1297
+ enriched_data = model_info_service.enrich_model_list(model_ids)
1298
+ return {"object": "list", "data": enriched_data}
1299
+
1300
+ # Fallback to basic model cards
1301
+ model_cards = [
1302
+ {
1303
+ "id": model_id,
1304
+ "object": "model",
1305
+ "created": int(time.time()),
1306
+ "owned_by": "Mirro-Proxy",
1307
+ }
1308
+ for model_id in model_ids
1309
+ ]
1310
+ return {"object": "list", "data": model_cards}
1311
+
1312
+
1313
+ @app.get("/v1/models/{model_id:path}")
1314
+ async def get_model(
1315
+ model_id: str,
1316
+ request: Request,
1317
+ _=Depends(verify_api_key),
1318
+ ):
1319
+ """
1320
+ Returns detailed information about a specific model.
1321
+
1322
+ Path Parameters:
1323
+ model_id: The model ID (e.g., "anthropic/claude-3-opus", "openrouter/openai/gpt-4")
1324
+ """
1325
+ if hasattr(request.app.state, "model_info_service"):
1326
+ model_info_service = request.app.state.model_info_service
1327
+ if model_info_service.is_ready:
1328
+ info = model_info_service.get_model_info(model_id)
1329
+ if info:
1330
+ return info.to_dict()
1331
+
1332
+ # Return basic info if service not ready or model not found
1333
+ return {
1334
+ "id": model_id,
1335
+ "object": "model",
1336
+ "created": int(time.time()),
1337
+ "owned_by": model_id.split("/")[0] if "/" in model_id else "unknown",
1338
+ }
1339
+
1340
+
1341
+ @app.get("/v1/model-info/stats")
1342
+ async def model_info_stats(
1343
+ request: Request,
1344
+ _=Depends(verify_api_key),
1345
+ ):
1346
+ """
1347
+ Returns statistics about the model info service (for monitoring/debugging).
1348
+ """
1349
+ if hasattr(request.app.state, "model_info_service"):
1350
+ return request.app.state.model_info_service.get_stats()
1351
+ return {"error": "Model info service not initialized"}
1352
+
1353
+
1354
+ @app.get("/v1/providers")
1355
+ async def list_providers(_=Depends(verify_api_key)):
1356
+ """
1357
+ Returns a list of all available providers.
1358
+ """
1359
+ return list(PROVIDER_PLUGINS.keys())
1360
+
1361
+
1362
+ @app.get("/v1/quota-stats")
1363
+ async def get_quota_stats(
1364
+ request: Request,
1365
+ client: RotatingClient = Depends(get_rotating_client),
1366
+ _=Depends(verify_api_key),
1367
+ provider: str = None,
1368
+ ):
1369
+ """
1370
+ Returns quota and usage statistics for all credentials.
1371
+
1372
+ This returns cached data from the proxy without making external API calls.
1373
+ Use POST to reload from disk or force refresh from external APIs.
1374
+
1375
+ Query Parameters:
1376
+ provider: Optional filter to return stats for a specific provider only
1377
+
1378
+ Returns:
1379
+ {
1380
+ "providers": {
1381
+ "provider_name": {
1382
+ "credential_count": int,
1383
+ "active_count": int,
1384
+ "on_cooldown_count": int,
1385
+ "exhausted_count": int,
1386
+ "total_requests": int,
1387
+ "tokens": {...},
1388
+ "approx_cost": float | null,
1389
+ "quota_groups": {...}, // For Antigravity
1390
+ "credentials": [...]
1391
+ }
1392
+ },
1393
+ "summary": {...},
1394
+ "data_source": "cache",
1395
+ "timestamp": float
1396
+ }
1397
+ """
1398
+ try:
1399
+ stats = await client.get_quota_stats(provider_filter=provider)
1400
+ return stats
1401
+ except Exception as e:
1402
+ logging.error(f"Failed to get quota stats: {e}")
1403
+ raise HTTPException(status_code=500, detail=str(e))
1404
+
1405
+
1406
+ @app.post("/v1/quota-stats")
1407
+ async def refresh_quota_stats(
1408
+ request: Request,
1409
+ client: RotatingClient = Depends(get_rotating_client),
1410
+ _=Depends(verify_api_key),
1411
+ ):
1412
+ """
1413
+ Refresh quota and usage statistics.
1414
+
1415
+ Request body:
1416
+ {
1417
+ "action": "reload" | "force_refresh",
1418
+ "scope": "all" | "provider" | "credential",
1419
+ "provider": "antigravity", // required if scope != "all"
1420
+ "credential": "antigravity_oauth_1.json" // required if scope == "credential"
1421
+ }
1422
+
1423
+ Actions:
1424
+ - reload: Re-read data from disk (no external API calls)
1425
+ - force_refresh: For Antigravity, fetch live quota from API.
1426
+ For other providers, same as reload.
1427
+
1428
+ Returns:
1429
+ Same as GET, plus a "refresh_result" field with operation details.
1430
+ """
1431
+ try:
1432
+ data = await request.json()
1433
+ action = data.get("action", "reload")
1434
+ scope = data.get("scope", "all")
1435
+ provider = data.get("provider")
1436
+ credential = data.get("credential")
1437
+
1438
+ # Validate parameters
1439
+ if action not in ("reload", "force_refresh"):
1440
+ raise HTTPException(
1441
+ status_code=400,
1442
+ detail="action must be 'reload' or 'force_refresh'",
1443
+ )
1444
+
1445
+ if scope not in ("all", "provider", "credential"):
1446
+ raise HTTPException(
1447
+ status_code=400,
1448
+ detail="scope must be 'all', 'provider', or 'credential'",
1449
+ )
1450
+
1451
+ if scope in ("provider", "credential") and not provider:
1452
+ raise HTTPException(
1453
+ status_code=400,
1454
+ detail="'provider' is required when scope is 'provider' or 'credential'",
1455
+ )
1456
+
1457
+ if scope == "credential" and not credential:
1458
+ raise HTTPException(
1459
+ status_code=400,
1460
+ detail="'credential' is required when scope is 'credential'",
1461
+ )
1462
+
1463
+ refresh_result = {
1464
+ "action": action,
1465
+ "scope": scope,
1466
+ "provider": provider,
1467
+ "credential": credential,
1468
+ }
1469
+
1470
+ if action == "reload":
1471
+ # Just reload from disk
1472
+ start_time = time.time()
1473
+ await client.reload_usage_from_disk()
1474
+ refresh_result["duration_ms"] = int((time.time() - start_time) * 1000)
1475
+ refresh_result["success"] = True
1476
+ refresh_result["message"] = "Reloaded usage data from disk"
1477
+
1478
+ elif action == "force_refresh":
1479
+ # Force refresh from external API (for supported providers like Antigravity)
1480
+ result = await client.force_refresh_quota(
1481
+ provider=provider if scope in ("provider", "credential") else None,
1482
+ credential=credential if scope == "credential" else None,
1483
+ )
1484
+ refresh_result.update(result)
1485
+ refresh_result["success"] = result["failed_count"] == 0
1486
+
1487
+ # Get updated stats
1488
+ stats = await client.get_quota_stats(provider_filter=provider)
1489
+ stats["refresh_result"] = refresh_result
1490
+ stats["data_source"] = "refreshed"
1491
+
1492
+ return stats
1493
+
1494
+ except HTTPException:
1495
+ raise
1496
+ except Exception as e:
1497
+ logging.error(f"Failed to refresh quota stats: {e}")
1498
+ raise HTTPException(status_code=500, detail=str(e))
1499
+
1500
+
1501
+ @app.post("/v1/token-count")
1502
+ async def token_count(
1503
+ request: Request,
1504
+ client: RotatingClient = Depends(get_rotating_client),
1505
+ _=Depends(verify_api_key),
1506
+ ):
1507
+ """
1508
+ Calculates the token count for a given list of messages and a model.
1509
+ """
1510
+ try:
1511
+ data = await request.json()
1512
+ model = data.get("model")
1513
+ messages = data.get("messages")
1514
+
1515
+ if not model or not messages:
1516
+ raise HTTPException(
1517
+ status_code=400, detail="'model' and 'messages' are required."
1518
+ )
1519
+
1520
+ count = client.token_count(**data)
1521
+ return {"token_count": count}
1522
+
1523
+ except Exception as e:
1524
+ logging.error(f"Token count failed: {e}")
1525
+ raise HTTPException(status_code=500, detail=str(e))
1526
+
1527
+
1528
+ @app.post("/v1/cost-estimate")
1529
+ async def cost_estimate(request: Request, _=Depends(verify_api_key)):
1530
+ """
1531
+ Estimates the cost for a request based on token counts and model pricing.
1532
+
1533
+ Request body:
1534
+ {
1535
+ "model": "anthropic/claude-3-opus",
1536
+ "prompt_tokens": 1000,
1537
+ "completion_tokens": 500,
1538
+ "cache_read_tokens": 0, # optional
1539
+ "cache_creation_tokens": 0 # optional
1540
+ }
1541
+
1542
+ Returns:
1543
+ {
1544
+ "model": "anthropic/claude-3-opus",
1545
+ "cost": 0.0375,
1546
+ "currency": "USD",
1547
+ "pricing": {
1548
+ "input_cost_per_token": 0.000015,
1549
+ "output_cost_per_token": 0.000075
1550
+ },
1551
+ "source": "model_info_service" # or "litellm_fallback"
1552
+ }
1553
+ """
1554
+ try:
1555
+ data = await request.json()
1556
+ model = data.get("model")
1557
+ prompt_tokens = data.get("prompt_tokens", 0)
1558
+ completion_tokens = data.get("completion_tokens", 0)
1559
+ cache_read_tokens = data.get("cache_read_tokens", 0)
1560
+ cache_creation_tokens = data.get("cache_creation_tokens", 0)
1561
+
1562
+ if not model:
1563
+ raise HTTPException(status_code=400, detail="'model' is required.")
1564
+
1565
+ result = {
1566
+ "model": model,
1567
+ "cost": None,
1568
+ "currency": "USD",
1569
+ "pricing": {},
1570
+ "source": None,
1571
+ }
1572
+
1573
+ # Try model info service first
1574
+ if hasattr(request.app.state, "model_info_service"):
1575
+ model_info_service = request.app.state.model_info_service
1576
+ if model_info_service.is_ready:
1577
+ cost = model_info_service.calculate_cost(
1578
+ model,
1579
+ prompt_tokens,
1580
+ completion_tokens,
1581
+ cache_read_tokens,
1582
+ cache_creation_tokens,
1583
+ )
1584
+ if cost is not None:
1585
+ cost_info = model_info_service.get_cost_info(model)
1586
+ result["cost"] = cost
1587
+ result["pricing"] = cost_info or {}
1588
+ result["source"] = "model_info_service"
1589
+ return result
1590
+
1591
+ # Fallback to litellm
1592
+ try:
1593
+ import litellm
1594
+
1595
+ # Create a mock response for cost calculation
1596
+ model_info = litellm.get_model_info(model)
1597
+ input_cost = model_info.get("input_cost_per_token", 0)
1598
+ output_cost = model_info.get("output_cost_per_token", 0)
1599
+
1600
+ if input_cost or output_cost:
1601
+ cost = (prompt_tokens * input_cost) + (completion_tokens * output_cost)
1602
+ result["cost"] = cost
1603
+ result["pricing"] = {
1604
+ "input_cost_per_token": input_cost,
1605
+ "output_cost_per_token": output_cost,
1606
+ }
1607
+ result["source"] = "litellm_fallback"
1608
+ return result
1609
+ except Exception:
1610
+ pass
1611
+
1612
+ result["source"] = "unknown"
1613
+ result["error"] = "Pricing data not available for this model"
1614
+ return result
1615
+
1616
+ except HTTPException:
1617
+ raise
1618
+ except Exception as e:
1619
+ logging.error(f"Cost estimate failed: {e}")
1620
+ raise HTTPException(status_code=500, detail=str(e))
1621
+
1622
+
1623
+ if __name__ == "__main__":
1624
+ # Define ENV_FILE for onboarding checks using centralized path
1625
+ ENV_FILE = get_data_file(".env")
1626
+
1627
+ # Check if launcher TUI should be shown (no arguments provided)
1628
+ if len(sys.argv) == 1:
1629
+ # No arguments - show launcher TUI (lazy import)
1630
+ from proxy_app.launcher_tui import run_launcher_tui
1631
+
1632
+ run_launcher_tui()
1633
+ # Launcher modifies sys.argv and returns, or exits if user chose Exit
1634
+ # If we get here, user chose "Run Proxy" and sys.argv is modified
1635
+ # Re-parse arguments with modified sys.argv
1636
+ args = parser.parse_args()
1637
+
1638
+ def needs_onboarding() -> bool:
1639
+ """
1640
+ Check if the proxy needs onboarding (first-time setup).
1641
+ Returns True if onboarding is needed, False otherwise.
1642
+ """
1643
+ # Only check if .env file exists
1644
+ # PROXY_API_KEY is optional (will show warning if not set)
1645
+ if not ENV_FILE.is_file():
1646
+ return True
1647
+
1648
+ return False
1649
+
1650
+ def show_onboarding_message():
1651
+ """Display clear explanatory message for why onboarding is needed."""
1652
+ os.system(
1653
+ "cls" if os.name == "nt" else "clear"
1654
+ ) # Clear terminal for clean presentation
1655
+ console.print(
1656
+ Panel.fit(
1657
+ "[bold cyan]🚀 LLM API Key Proxy - First Time Setup[/bold cyan]",
1658
+ border_style="cyan",
1659
+ )
1660
+ )
1661
+ console.print("[bold yellow]⚠️ Configuration Required[/bold yellow]\n")
1662
+
1663
+ console.print("The proxy needs initial configuration:")
1664
+ console.print(" [red]❌ No .env file found[/red]")
1665
+
1666
+ console.print("\n[bold]Why this matters:[/bold]")
1667
+ console.print(" • The .env file stores your credentials and settings")
1668
+ console.print(" • PROXY_API_KEY protects your proxy from unauthorized access")
1669
+ console.print(" • Provider API keys enable LLM access")
1670
+
1671
+ console.print("\n[bold]What happens next:[/bold]")
1672
+ console.print(" 1. We'll create a .env file with PROXY_API_KEY")
1673
+ console.print(" 2. You can add LLM provider credentials (API keys or OAuth)")
1674
+ console.print(" 3. The proxy will then start normally")
1675
+
1676
+ console.print(
1677
+ "\n[bold yellow]⚠️ Note:[/bold yellow] The credential tool adds PROXY_API_KEY by default."
1678
+ )
1679
+ console.print(" You can remove it later if you want an unsecured proxy.\n")
1680
+
1681
+ console.input(
1682
+ "[bold green]Press Enter to launch the credential setup tool...[/bold green]"
1683
+ )
1684
+
1685
+ # Check if user explicitly wants to add credentials
1686
+ if args.add_credential:
1687
+ # Import and call ensure_env_defaults to create .env and PROXY_API_KEY if needed
1688
+ from rotator_library.credential_tool import ensure_env_defaults
1689
+
1690
+ ensure_env_defaults()
1691
+ # Reload environment variables after ensure_env_defaults creates/updates .env
1692
+ load_dotenv(ENV_FILE, override=True)
1693
+ run_credential_tool()
1694
+ else:
1695
+ # Check if onboarding is needed
1696
+ if needs_onboarding():
1697
+ # Import console from rich for better messaging
1698
+ from rich.console import Console
1699
+ from rich.panel import Panel
1700
+
1701
+ console = Console()
1702
+
1703
+ # Show clear explanatory message
1704
+ show_onboarding_message()
1705
+
1706
+ # Launch credential tool automatically
1707
+ from rotator_library.credential_tool import ensure_env_defaults
1708
+
1709
+ ensure_env_defaults()
1710
+ load_dotenv(ENV_FILE, override=True)
1711
+ run_credential_tool()
1712
+
1713
+ # After credential tool exits, reload and re-check
1714
+ load_dotenv(ENV_FILE, override=True)
1715
+ # Re-read PROXY_API_KEY from environment
1716
+ PROXY_API_KEY = os.getenv("PROXY_API_KEY")
1717
+
1718
+ # Verify onboarding is complete
1719
+ if needs_onboarding():
1720
+ console.print("\n[bold red]❌ Configuration incomplete.[/bold red]")
1721
+ console.print(
1722
+ "The proxy still cannot start. Please ensure PROXY_API_KEY is set in .env\n"
1723
+ )
1724
+ sys.exit(1)
1725
+ else:
1726
+ console.print("\n[bold green]✅ Configuration complete![/bold green]")
1727
+ console.print("\nStarting proxy server...\n")
1728
+
1729
+ import uvicorn
1730
+
1731
+ uvicorn.run(app, host=args.host, port=args.port)
src/proxy_app/model_filter_gui.py ADDED
The diff for this file is too large to render. See raw diff
 
src/proxy_app/provider_urls.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Mirrowel
3
+
4
+ import os
5
+ from typing import Optional
6
+
7
+ # A comprehensive map of provider names to their base URLs.
8
+ PROVIDER_URL_MAP = {
9
+ "perplexity": "https://api.perplexity.ai",
10
+ "anyscale": "https://api.endpoints.anyscale.com/v1",
11
+ "deepinfra": "https://api.deepinfra.com/v1/openai",
12
+ "mistral": "https://api.mistral.ai/v1",
13
+ "groq": "https://api.groq.com/openai/v1",
14
+ "nvidia_nim": "https://integrate.api.nvidia.com/v1",
15
+ "cerebras": "https://api.cerebras.ai/v1",
16
+ "sambanova": "https://api.sambanova.ai/v1",
17
+ "ai21_chat": "https://api.ai21.com/studio/v1",
18
+ "codestral": "https://codestral.mistral.ai/v1",
19
+ "text-completion-codestral": "https://codestral.mistral.ai/v1",
20
+ "empower": "https://app.empower.dev/api/v1",
21
+ "deepseek": "https://api.deepseek.com/v1",
22
+ "friendliai": "https://api.friendli.ai/serverless/v1",
23
+ "galadriel": "https://api.galadriel.com/v1",
24
+ "meta_llama": "https://api.llama.com/compat/v1",
25
+ "featherless_ai": "https://api.featherless.ai/v1",
26
+ "nscale": "https://api.nscale.com/v1",
27
+ "openai": "https://api.openai.com/v1",
28
+ "gemini": "https://generativelanguage.googleapis.com/v1beta",
29
+ "anthropic": "https://api.anthropic.com/v1",
30
+ "cohere": "https://api.cohere.ai/v1",
31
+ "bedrock": "https://bedrock-runtime.us-east-1.amazonaws.com",
32
+ "openrouter": "https://openrouter.ai/api/v1",
33
+ }
34
+
35
+ def get_provider_endpoint(provider: str, model_name: str, incoming_path: str) -> Optional[str]:
36
+ """
37
+ Constructs the full provider endpoint URL based on the provider and incoming request path.
38
+ Supports both hardcoded providers and custom OpenAI-compatible providers via environment variables.
39
+ """
40
+ # First, check the hardcoded map
41
+ base_url = PROVIDER_URL_MAP.get(provider)
42
+
43
+ # If not found, check for custom provider via environment variable
44
+ if not base_url:
45
+ api_base_env = f"{provider.upper()}_API_BASE"
46
+ base_url = os.getenv(api_base_env)
47
+ if not base_url:
48
+ return None
49
+
50
+ # Determine the specific action from the incoming path (e.g., 'chat/completions')
51
+ action = incoming_path.split('/v1/', 1)[-1] if '/v1/' in incoming_path else incoming_path
52
+
53
+ # --- Provider-specific endpoint structures ---
54
+ if provider == "gemini":
55
+ if action == "chat/completions":
56
+ return f"{base_url}/models/{model_name}:generateContent"
57
+ elif action == "embeddings":
58
+ return f"{base_url}/models/{model_name}:embedContent"
59
+
60
+ elif provider == "anthropic":
61
+ if action == "chat/completions":
62
+ return f"{base_url}/messages"
63
+
64
+ elif provider == "cohere":
65
+ if action == "chat/completions":
66
+ return f"{base_url}/chat"
67
+ elif action == "embeddings":
68
+ return f"{base_url}/embed"
69
+
70
+ # Default for OpenAI-compatible providers
71
+ # Most of these have /v1 in the base URL already, so we just append the action.
72
+ if base_url.endswith(("/v1", "/v1/openai")):
73
+ return f"{base_url}/{action}"
74
+
75
+ # Fallback for other cases
76
+ return f"{base_url}/v1/{action}"
src/proxy_app/quota_viewer.py ADDED
@@ -0,0 +1,1596 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Mirrowel
3
+
4
+ """
5
+ Lightweight Quota Stats Viewer TUI.
6
+
7
+ Connects to a running proxy to display quota and usage statistics.
8
+ Uses only httpx + rich (no heavy rotator_library imports).
9
+
10
+ TODO: Missing Features & Improvements
11
+ ======================================
12
+
13
+ Display Improvements:
14
+ - [ ] Add color legend/help screen explaining status colors and symbols
15
+ - [ ] Show credential email/project ID if available (currently just filename)
16
+ - [ ] Add keyboard shortcut hints (e.g., "Press ? for help")
17
+ - [ ] Support terminal resize / responsive layout
18
+
19
+ Global Stats Fix:
20
+ - [ ] HACK: Global requests currently set to current period requests only
21
+ (see client.py get_quota_stats). This doesn't include archived stats.
22
+ Fix requires tracking archived requests per quota group in usage_manager.py
23
+ to avoid double-counting models that share quota groups.
24
+
25
+ Data & Refresh:
26
+ - [ ] Auto-refresh option (configurable interval)
27
+ - [ ] Show last refresh timestamp more prominently
28
+ - [ ] Cache invalidation when switching between current/global view
29
+ - [ ] Support for non-OAuth providers (API keys like nvapi-*, gsk_*, etc.)
30
+
31
+ Remote Management:
32
+ - [ ] Test connection before saving remote
33
+ - [ ] Import/export remote configurations
34
+ - [ ] SSH tunnel support for remote proxies
35
+
36
+ Quota Groups:
37
+ - [ ] Show which models are in each quota group (expandable)
38
+ - [ ] Historical quota usage graphs (if data available)
39
+ - [ ] Alerts/notifications when quota is low
40
+
41
+ Credential Details:
42
+ - [ ] Show per-model breakdown within quota groups
43
+ - [ ] Edit credential priority/tier manually
44
+ - [ ] Disable/enable individual credentials
45
+ """
46
+
47
+ import os
48
+ import re
49
+ import sys
50
+ import time
51
+ from datetime import datetime, timezone
52
+ from typing import Any, Dict, List, Optional, Tuple
53
+
54
+ import httpx
55
+ from rich.console import Console
56
+ from rich.panel import Panel
57
+ from rich.progress import BarColumn, Progress, TextColumn
58
+ from rich.prompt import Prompt
59
+ from rich.table import Table
60
+ from rich.text import Text
61
+
62
+ from .quota_viewer_config import QuotaViewerConfig
63
+
64
+
65
+ def clear_screen():
66
+ """Clear the terminal screen."""
67
+ os.system("cls" if os.name == "nt" else "clear")
68
+
69
+
70
+ def format_tokens(count: int) -> str:
71
+ """Format token count for display (e.g., 125000 -> 125k)."""
72
+ if count >= 1_000_000:
73
+ return f"{count / 1_000_000:.1f}M"
74
+ elif count >= 1_000:
75
+ return f"{count / 1_000:.0f}k"
76
+ return str(count)
77
+
78
+
79
+ def format_cost(cost: Optional[float]) -> str:
80
+ """Format cost for display."""
81
+ if cost is None or cost == 0:
82
+ return "-"
83
+ if cost < 0.01:
84
+ return f"${cost:.4f}"
85
+ return f"${cost:.2f}"
86
+
87
+
88
+ def format_time_ago(timestamp: Optional[float]) -> str:
89
+ """Format timestamp as relative time (e.g., '5 min ago')."""
90
+ if not timestamp:
91
+ return "Never"
92
+ try:
93
+ delta = time.time() - timestamp
94
+ if delta < 60:
95
+ return f"{int(delta)}s ago"
96
+ elif delta < 3600:
97
+ return f"{int(delta / 60)} min ago"
98
+ elif delta < 86400:
99
+ return f"{int(delta / 3600)}h ago"
100
+ else:
101
+ return f"{int(delta / 86400)}d ago"
102
+ except (ValueError, OSError):
103
+ return "Unknown"
104
+
105
+
106
+ def format_reset_time(iso_time: Optional[str]) -> str:
107
+ """Format ISO time string for display."""
108
+ if not iso_time:
109
+ return "-"
110
+ try:
111
+ dt = datetime.fromisoformat(iso_time.replace("Z", "+00:00"))
112
+ # Convert to local time
113
+ local_dt = dt.astimezone()
114
+ return local_dt.strftime("%b %d %H:%M")
115
+ except (ValueError, AttributeError):
116
+ return iso_time[:16] if iso_time else "-"
117
+
118
+
119
+ def create_progress_bar(percent: Optional[int], width: int = 10) -> str:
120
+ """Create a text-based progress bar."""
121
+ if percent is None:
122
+ return "░" * width
123
+ filled = int(percent / 100 * width)
124
+ return "▓" * filled + "░" * (width - filled)
125
+
126
+
127
+ def is_local_host(host: str) -> bool:
128
+ """Check if host is a local/private address (should use http, not https)."""
129
+ if host in ("localhost", "127.0.0.1", "::1", "0.0.0.0", "::"):
130
+ return True
131
+ # Private IP ranges
132
+ if host.startswith("192.168.") or host.startswith("10."):
133
+ return True
134
+ if host.startswith("172."):
135
+ # 172.16.0.0 - 172.31.255.255
136
+ try:
137
+ second_octet = int(host.split(".")[1])
138
+ if 16 <= second_octet <= 31:
139
+ return True
140
+ except (ValueError, IndexError):
141
+ pass
142
+ return False
143
+
144
+
145
+ def normalize_host_for_connection(host: str) -> str:
146
+ """
147
+ Convert bind addresses to connectable addresses.
148
+
149
+ 0.0.0.0 and :: are valid for binding a server to all interfaces,
150
+ but clients cannot connect to them. Translate to loopback addresses.
151
+ """
152
+ if host == "0.0.0.0":
153
+ return "127.0.0.1"
154
+ if host == "::":
155
+ return "::1"
156
+ return host
157
+
158
+
159
+ def get_scheme_for_host(host: str, port: int) -> str:
160
+ """Determine http or https scheme based on host and port."""
161
+ if port == 443:
162
+ return "https"
163
+ if is_local_host(host):
164
+ return "http"
165
+ # For external domains, default to https
166
+ if "." in host:
167
+ return "https"
168
+ return "http"
169
+
170
+
171
+ def is_full_url(host: str) -> bool:
172
+ """Check if host is already a full URL (starts with http:// or https://)."""
173
+ return host.startswith("http://") or host.startswith("https://")
174
+
175
+
176
+ def format_cooldown(seconds: int) -> str:
177
+ """Format cooldown seconds as human-readable string."""
178
+ if seconds < 60:
179
+ return f"{seconds}s"
180
+ elif seconds < 3600:
181
+ mins = seconds // 60
182
+ secs = seconds % 60
183
+ return f"{mins}m {secs}s" if secs > 0 else f"{mins}m"
184
+ else:
185
+ hours = seconds // 3600
186
+ mins = (seconds % 3600) // 60
187
+ return f"{hours}h {mins}m" if mins > 0 else f"{hours}h"
188
+
189
+
190
+ def natural_sort_key(item: Dict[str, Any]) -> List:
191
+ """
192
+ Generate a sort key for natural/numeric sorting.
193
+
194
+ Sorts credentials like proj-1, proj-2, proj-10 correctly
195
+ instead of alphabetically (proj-1, proj-10, proj-2).
196
+ """
197
+ identifier = item.get("identifier", "")
198
+ # Split into text and numeric parts
199
+ parts = re.split(r"(\d+)", identifier)
200
+ return [int(p) if p.isdigit() else p.lower() for p in parts]
201
+
202
+
203
+ class QuotaViewer:
204
+ """Main Quota Viewer TUI class."""
205
+
206
+ def __init__(self, config: Optional[QuotaViewerConfig] = None):
207
+ """
208
+ Initialize the viewer.
209
+
210
+ Args:
211
+ config: Optional config object. If not provided, one will be created.
212
+ """
213
+ self.console = Console()
214
+ self.config = config or QuotaViewerConfig()
215
+ self.config.sync_with_launcher_config()
216
+
217
+ self.current_remote: Optional[Dict[str, Any]] = None
218
+ self.cached_stats: Optional[Dict[str, Any]] = None
219
+ self.last_error: Optional[str] = None
220
+ self.running = True
221
+ self.view_mode = "current" # "current" or "global"
222
+
223
+ def _get_headers(self) -> Dict[str, str]:
224
+ """Get HTTP headers including auth if configured."""
225
+ headers = {}
226
+ if self.current_remote and self.current_remote.get("api_key"):
227
+ headers["Authorization"] = f"Bearer {self.current_remote['api_key']}"
228
+ return headers
229
+
230
+ def _get_base_url(self) -> str:
231
+ """Get base URL for the current remote."""
232
+ if not self.current_remote:
233
+ return "http://127.0.0.1:8000"
234
+ host = self.current_remote.get("host", "127.0.0.1")
235
+ host = normalize_host_for_connection(host)
236
+
237
+ # If host is a full URL, use it directly (strip trailing slash)
238
+ if is_full_url(host):
239
+ return host.rstrip("/")
240
+
241
+ # Otherwise construct from host:port
242
+ port = self.current_remote.get("port", 8000)
243
+ scheme = get_scheme_for_host(host, port)
244
+ return f"{scheme}://{host}:{port}"
245
+
246
+ def _build_endpoint_url(self, endpoint: str) -> str:
247
+ """
248
+ Build a full endpoint URL with smart path handling.
249
+
250
+ Handles cases where base URL already contains a path (e.g., /v1):
251
+ - Base: "https://api.example.com/v1", Endpoint: "/v1/quota-stats"
252
+ -> "https://api.example.com/v1/quota-stats" (no duplication)
253
+ - Base: "http://localhost:8000", Endpoint: "/v1/quota-stats"
254
+ -> "http://localhost:8000/v1/quota-stats"
255
+
256
+ Args:
257
+ endpoint: The endpoint path (e.g., "/v1/quota-stats")
258
+
259
+ Returns:
260
+ Full URL string
261
+ """
262
+ base_url = self._get_base_url()
263
+ endpoint = endpoint.lstrip("/")
264
+
265
+ # Check if base URL already ends with a path segment that matches
266
+ # the start of the endpoint (e.g., base ends with /v1, endpoint starts with v1/)
267
+ from urllib.parse import urlparse
268
+
269
+ parsed = urlparse(base_url)
270
+ base_path = parsed.path.rstrip("/")
271
+
272
+ # If base has a path and endpoint starts with the same segment, avoid duplication
273
+ if base_path:
274
+ # e.g., base_path = "/v1", endpoint = "v1/quota-stats"
275
+ # We want to produce "/v1/quota-stats", not "/v1/v1/quota-stats"
276
+ base_segments = base_path.split("/")
277
+ endpoint_segments = endpoint.split("/")
278
+
279
+ # Check if first endpoint segment matches last base segment
280
+ if base_segments and endpoint_segments:
281
+ if base_segments[-1] == endpoint_segments[0]:
282
+ # Skip the duplicated segment in endpoint
283
+ endpoint = "/".join(endpoint_segments[1:])
284
+
285
+ return f"{base_url}/{endpoint}"
286
+
287
+ def check_connection(
288
+ self, remote: Dict[str, Any], timeout: float = 3.0
289
+ ) -> Tuple[bool, str]:
290
+ """
291
+ Check if a remote proxy is reachable.
292
+
293
+ Args:
294
+ remote: Remote configuration dict
295
+ timeout: Connection timeout in seconds
296
+
297
+ Returns:
298
+ Tuple of (is_online, status_message)
299
+ """
300
+ host = remote.get("host", "127.0.0.1")
301
+ host = normalize_host_for_connection(host)
302
+
303
+ # If host is a full URL, extract scheme and netloc to hit root
304
+ if is_full_url(host):
305
+ from urllib.parse import urlparse
306
+
307
+ parsed = urlparse(host)
308
+ # Hit the root domain, not the path (e.g., /v1 would 404)
309
+ url = f"{parsed.scheme}://{parsed.netloc}/"
310
+ else:
311
+ port = remote.get("port", 8000)
312
+ scheme = get_scheme_for_host(host, port)
313
+ url = f"{scheme}://{host}:{port}/"
314
+
315
+ headers = {}
316
+ if remote.get("api_key"):
317
+ headers["Authorization"] = f"Bearer {remote['api_key']}"
318
+
319
+ try:
320
+ with httpx.Client(timeout=timeout) as client:
321
+ response = client.get(url, headers=headers)
322
+ if response.status_code == 200:
323
+ return True, "Online"
324
+ elif response.status_code == 401:
325
+ return False, "Auth failed"
326
+ else:
327
+ return False, f"HTTP {response.status_code}"
328
+ except httpx.ConnectError:
329
+ return False, "Offline"
330
+ except httpx.TimeoutException:
331
+ return False, "Timeout"
332
+ except Exception as e:
333
+ return False, str(e)[:20]
334
+
335
+ def fetch_stats(self, provider: Optional[str] = None) -> Optional[Dict[str, Any]]:
336
+ """
337
+ Fetch quota stats from the current remote.
338
+
339
+ Args:
340
+ provider: Optional provider filter
341
+
342
+ Returns:
343
+ Stats dict or None on failure
344
+ """
345
+ url = self._build_endpoint_url("/v1/quota-stats")
346
+ if provider:
347
+ url += f"?provider={provider}"
348
+
349
+ try:
350
+ with httpx.Client(timeout=30.0) as client:
351
+ response = client.get(url, headers=self._get_headers())
352
+
353
+ if response.status_code == 401:
354
+ self.last_error = "Authentication failed. Check API key."
355
+ return None
356
+ elif response.status_code != 200:
357
+ self.last_error = (
358
+ f"HTTP {response.status_code}: {response.text[:100]}"
359
+ )
360
+ return None
361
+
362
+ self.cached_stats = response.json()
363
+ self.last_error = None
364
+ return self.cached_stats
365
+
366
+ except httpx.ConnectError:
367
+ self.last_error = "Connection failed. Is the proxy running?"
368
+ return None
369
+ except httpx.TimeoutException:
370
+ self.last_error = "Request timed out."
371
+ return None
372
+ except Exception as e:
373
+ self.last_error = str(e)
374
+ return None
375
+
376
+ def _merge_provider_stats(self, provider: str, result: Dict[str, Any]) -> None:
377
+ """
378
+ Merge provider-specific stats into the existing cache.
379
+
380
+ Updates just the specified provider's data and recalculates the
381
+ summary fields to reflect the change.
382
+
383
+ Args:
384
+ provider: Provider name that was refreshed
385
+ result: API response containing the refreshed provider data
386
+ """
387
+ if not self.cached_stats:
388
+ self.cached_stats = result
389
+ return
390
+
391
+ # Merge provider data
392
+ if "providers" in result and provider in result["providers"]:
393
+ if "providers" not in self.cached_stats:
394
+ self.cached_stats["providers"] = {}
395
+ self.cached_stats["providers"][provider] = result["providers"][provider]
396
+
397
+ # Update timestamp
398
+ if "timestamp" in result:
399
+ self.cached_stats["timestamp"] = result["timestamp"]
400
+
401
+ # Recalculate summary from all providers
402
+ self._recalculate_summary()
403
+
404
+ def _recalculate_summary(self) -> None:
405
+ """
406
+ Recalculate summary fields from all provider data in cache.
407
+
408
+ Updates both 'summary' and 'global_summary' based on current
409
+ provider stats.
410
+ """
411
+ providers = self.cached_stats.get("providers", {})
412
+ if not providers:
413
+ return
414
+
415
+ # Calculate summary from all providers
416
+ total_creds = 0
417
+ active_creds = 0
418
+ exhausted_creds = 0
419
+ total_requests = 0
420
+ total_input_cached = 0
421
+ total_input_uncached = 0
422
+ total_output = 0
423
+ total_cost = 0.0
424
+
425
+ for prov_stats in providers.values():
426
+ total_creds += prov_stats.get("credential_count", 0)
427
+ active_creds += prov_stats.get("active_count", 0)
428
+ exhausted_creds += prov_stats.get("exhausted_count", 0)
429
+ total_requests += prov_stats.get("total_requests", 0)
430
+
431
+ tokens = prov_stats.get("tokens", {})
432
+ total_input_cached += tokens.get("input_cached", 0)
433
+ total_input_uncached += tokens.get("input_uncached", 0)
434
+ total_output += tokens.get("output", 0)
435
+
436
+ cost = prov_stats.get("approx_cost")
437
+ if cost:
438
+ total_cost += cost
439
+
440
+ total_input = total_input_cached + total_input_uncached
441
+ input_cache_pct = (
442
+ round(total_input_cached / total_input * 100, 1) if total_input > 0 else 0
443
+ )
444
+
445
+ self.cached_stats["summary"] = {
446
+ "total_providers": len(providers),
447
+ "total_credentials": total_creds,
448
+ "active_credentials": active_creds,
449
+ "exhausted_credentials": exhausted_creds,
450
+ "total_requests": total_requests,
451
+ "tokens": {
452
+ "input_cached": total_input_cached,
453
+ "input_uncached": total_input_uncached,
454
+ "input_cache_pct": input_cache_pct,
455
+ "output": total_output,
456
+ },
457
+ "approx_total_cost": total_cost if total_cost > 0 else None,
458
+ }
459
+
460
+ # Also recalculate global_summary if it exists
461
+ if "global_summary" in self.cached_stats:
462
+ global_total_requests = 0
463
+ global_input_cached = 0
464
+ global_input_uncached = 0
465
+ global_output = 0
466
+ global_cost = 0.0
467
+
468
+ for prov_stats in providers.values():
469
+ global_data = prov_stats.get("global", prov_stats)
470
+ global_total_requests += global_data.get("total_requests", 0)
471
+
472
+ tokens = global_data.get("tokens", {})
473
+ global_input_cached += tokens.get("input_cached", 0)
474
+ global_input_uncached += tokens.get("input_uncached", 0)
475
+ global_output += tokens.get("output", 0)
476
+
477
+ cost = global_data.get("approx_cost")
478
+ if cost:
479
+ global_cost += cost
480
+
481
+ global_total_input = global_input_cached + global_input_uncached
482
+ global_cache_pct = (
483
+ round(global_input_cached / global_total_input * 100, 1)
484
+ if global_total_input > 0
485
+ else 0
486
+ )
487
+
488
+ self.cached_stats["global_summary"] = {
489
+ "total_providers": len(providers),
490
+ "total_credentials": total_creds,
491
+ "total_requests": global_total_requests,
492
+ "tokens": {
493
+ "input_cached": global_input_cached,
494
+ "input_uncached": global_input_uncached,
495
+ "input_cache_pct": global_cache_pct,
496
+ "output": global_output,
497
+ },
498
+ "approx_total_cost": global_cost if global_cost > 0 else None,
499
+ }
500
+
501
+ def post_action(
502
+ self,
503
+ action: str,
504
+ scope: str = "all",
505
+ provider: Optional[str] = None,
506
+ credential: Optional[str] = None,
507
+ ) -> Optional[Dict[str, Any]]:
508
+ """
509
+ Post a refresh action to the proxy.
510
+
511
+ Args:
512
+ action: "reload" or "force_refresh"
513
+ scope: "all", "provider", or "credential"
514
+ provider: Provider name (required for scope != "all")
515
+ credential: Credential identifier (required for scope == "credential")
516
+
517
+ Returns:
518
+ Response dict or None on failure
519
+ """
520
+ url = self._build_endpoint_url("/v1/quota-stats")
521
+ payload = {
522
+ "action": action,
523
+ "scope": scope,
524
+ }
525
+ if provider:
526
+ payload["provider"] = provider
527
+ if credential:
528
+ payload["credential"] = credential
529
+
530
+ try:
531
+ with httpx.Client(timeout=60.0) as client:
532
+ response = client.post(url, headers=self._get_headers(), json=payload)
533
+
534
+ if response.status_code == 401:
535
+ self.last_error = "Authentication failed. Check API key."
536
+ return None
537
+ elif response.status_code != 200:
538
+ self.last_error = (
539
+ f"HTTP {response.status_code}: {response.text[:100]}"
540
+ )
541
+ return None
542
+
543
+ result = response.json()
544
+
545
+ # If scope is provider-specific, merge into existing cache
546
+ if scope == "provider" and provider and self.cached_stats:
547
+ self._merge_provider_stats(provider, result)
548
+ else:
549
+ # Full refresh - replace everything
550
+ self.cached_stats = result
551
+
552
+ self.last_error = None
553
+ return result
554
+
555
+ except httpx.ConnectError:
556
+ self.last_error = "Connection failed. Is the proxy running?"
557
+ return None
558
+ except httpx.TimeoutException:
559
+ self.last_error = "Request timed out."
560
+ return None
561
+ except Exception as e:
562
+ self.last_error = str(e)
563
+ return None
564
+
565
+ # =========================================================================
566
+ # DISPLAY SCREENS
567
+ # =========================================================================
568
+
569
+ def show_connection_error(self) -> str:
570
+ """
571
+ Display connection error screen with options to configure remotes.
572
+
573
+ Returns:
574
+ User choice: 's' (switch), 'm' (manage), 'r' (retry), 'b' (back/exit)
575
+ """
576
+ clear_screen()
577
+
578
+ remote_name = (
579
+ self.current_remote.get("name", "Unknown")
580
+ if self.current_remote
581
+ else "None"
582
+ )
583
+ remote_host = self.current_remote.get("host", "") if self.current_remote else ""
584
+ remote_port = self.current_remote.get("port", "") if self.current_remote else ""
585
+
586
+ # Format connection display - handle full URLs
587
+ if is_full_url(remote_host):
588
+ connection_display = remote_host
589
+ elif remote_port:
590
+ connection_display = f"{remote_host}:{remote_port}"
591
+ else:
592
+ connection_display = remote_host
593
+
594
+ self.console.print(
595
+ Panel(
596
+ Text.from_markup(
597
+ "[bold red]Connection Error[/bold red]\n\n"
598
+ f"Remote: [bold]{remote_name}[/bold] ({connection_display})\n"
599
+ f"Error: {self.last_error or 'Unknown error'}\n\n"
600
+ "[bold]This tool requires the proxy to be running.[/bold]\n"
601
+ "Start the proxy first, or configure a different remote.\n\n"
602
+ "[dim]Tip: Select option 1 from the main menu to run the proxy.[/dim]"
603
+ ),
604
+ border_style="red",
605
+ expand=False,
606
+ )
607
+ )
608
+
609
+ self.console.print()
610
+ self.console.print("━" * 78)
611
+ self.console.print()
612
+ self.console.print(" S. Switch to a different remote")
613
+ self.console.print(" M. Manage remotes (add/edit/delete)")
614
+ self.console.print(" R. Retry connection")
615
+ self.console.print(" B. Back to main menu")
616
+ self.console.print()
617
+ self.console.print("━" * 78)
618
+
619
+ choice = Prompt.ask("Select option", default="B").strip().lower()
620
+
621
+ if choice in ("s", "m", "r", "b"):
622
+ return choice
623
+ return "b" # Default to back for invalid input
624
+
625
+ def show_summary_screen(self):
626
+ """Display the main summary screen with all providers."""
627
+ clear_screen()
628
+
629
+ # Header
630
+ remote_name = (
631
+ self.current_remote.get("name", "Unknown")
632
+ if self.current_remote
633
+ else "None"
634
+ )
635
+ remote_host = self.current_remote.get("host", "") if self.current_remote else ""
636
+ remote_port = self.current_remote.get("port", "") if self.current_remote else ""
637
+
638
+ # Format connection display - handle full URLs
639
+ if is_full_url(remote_host):
640
+ connection_display = remote_host
641
+ elif remote_port:
642
+ connection_display = f"{remote_host}:{remote_port}"
643
+ else:
644
+ connection_display = remote_host
645
+
646
+ # Calculate data age
647
+ data_age = ""
648
+ if self.cached_stats and self.cached_stats.get("timestamp"):
649
+ age_seconds = int(time.time() - self.cached_stats["timestamp"])
650
+ data_age = f"Data age: {age_seconds}s"
651
+
652
+ # View mode indicator
653
+ if self.view_mode == "global":
654
+ view_label = "[magenta]📊 Global/Lifetime[/magenta]"
655
+ else:
656
+ view_label = "[cyan]📈 Current Period[/cyan]"
657
+
658
+ self.console.print("━" * 78)
659
+ self.console.print(
660
+ f"[bold cyan]📈 Quota & Usage Statistics[/bold cyan] | {view_label}"
661
+ )
662
+ self.console.print("━" * 78)
663
+ self.console.print(
664
+ f"Connected to: [bold]{remote_name}[/bold] ({connection_display}) "
665
+ f"[green]✅[/green] | {data_age}"
666
+ )
667
+ self.console.print()
668
+
669
+ if not self.cached_stats:
670
+ self.console.print("[yellow]No data available. Press R to reload.[/yellow]")
671
+ else:
672
+ # Build provider table
673
+ table = Table(
674
+ box=None, show_header=True, header_style="bold", padding=(0, 1)
675
+ )
676
+ table.add_column("Provider", style="cyan", min_width=10)
677
+ table.add_column("Creds", justify="center", min_width=5)
678
+ table.add_column("Quota Status", min_width=28)
679
+ table.add_column("Requests", justify="right", min_width=8)
680
+ table.add_column("Tokens (in/out)", min_width=20)
681
+ table.add_column("Cost", justify="right", min_width=6)
682
+
683
+ providers = self.cached_stats.get("providers", {})
684
+ provider_list = list(providers.keys())
685
+
686
+ for idx, (provider, prov_stats) in enumerate(providers.items(), 1):
687
+ cred_count = prov_stats.get("credential_count", 0)
688
+
689
+ # Use global stats if in global mode
690
+ if self.view_mode == "global":
691
+ stats_source = prov_stats.get("global", prov_stats)
692
+ total_requests = stats_source.get("total_requests", 0)
693
+ tokens = stats_source.get("tokens", {})
694
+ cost_value = stats_source.get("approx_cost")
695
+ else:
696
+ total_requests = prov_stats.get("total_requests", 0)
697
+ tokens = prov_stats.get("tokens", {})
698
+ cost_value = prov_stats.get("approx_cost")
699
+
700
+ # Format tokens
701
+ input_total = tokens.get("input_cached", 0) + tokens.get(
702
+ "input_uncached", 0
703
+ )
704
+ output = tokens.get("output", 0)
705
+ cache_pct = tokens.get("input_cache_pct", 0)
706
+ token_str = f"{format_tokens(input_total)}/{format_tokens(output)} ({cache_pct}% cached)"
707
+
708
+ # Format cost
709
+ cost_str = format_cost(cost_value)
710
+
711
+ # Build quota status string (for providers with quota groups)
712
+ quota_groups = prov_stats.get("quota_groups", {})
713
+ if quota_groups:
714
+ quota_lines = []
715
+ for group_name, group_stats in quota_groups.items():
716
+ # Use remaining requests (not used) so percentage matches displayed value
717
+ total_remaining = group_stats.get("total_requests_remaining", 0)
718
+ total_max = group_stats.get("total_requests_max", 0)
719
+ total_pct = group_stats.get("total_remaining_pct")
720
+ tiers = group_stats.get("tiers", {})
721
+
722
+ # Format tier info: "5(15)f/2s" = 5 active out of 15 free, 2 standard all active
723
+ # Sort by priority (lower number = higher priority, appears first)
724
+ tier_parts = []
725
+ sorted_tiers = sorted(
726
+ tiers.items(), key=lambda x: x[1].get("priority", 10)
727
+ )
728
+ for tier_name, tier_info in sorted_tiers:
729
+ if tier_name == "unknown":
730
+ continue # Skip unknown tiers in display
731
+ total_t = tier_info.get("total", 0)
732
+ active_t = tier_info.get("active", 0)
733
+ # Use first letter: standard-tier -> s, free-tier -> f
734
+ short = tier_name.replace("-tier", "")[0]
735
+
736
+ if active_t < total_t:
737
+ # Some exhausted - show active(total)
738
+ tier_parts.append(f"{active_t}({total_t}){short}")
739
+ else:
740
+ # All active - just show total
741
+ tier_parts.append(f"{total_t}{short}")
742
+ tier_str = "/".join(tier_parts) if tier_parts else ""
743
+
744
+ # Determine color based purely on remaining percentage
745
+ if total_pct is not None:
746
+ if total_pct <= 10:
747
+ color = "red"
748
+ elif total_pct < 30:
749
+ color = "yellow"
750
+ else:
751
+ color = "green"
752
+ else:
753
+ color = "dim"
754
+
755
+ bar = create_progress_bar(total_pct)
756
+ pct_str = f"{total_pct}%" if total_pct is not None else "?"
757
+
758
+ # Build status suffix (just tiers now, no outer parens)
759
+ status = tier_str
760
+
761
+ # Fixed-width format for aligned bars
762
+ # Adjust these to change column spacing:
763
+ QUOTA_NAME_WIDTH = 10 # name + colon, left-aligned
764
+ QUOTA_USAGE_WIDTH = (
765
+ 12 # remaining/max ratio, right-aligned (handles 100k+)
766
+ )
767
+ display_name = group_name[: QUOTA_NAME_WIDTH - 1]
768
+ usage_str = f"{total_remaining}/{total_max}"
769
+ quota_lines.append(
770
+ f"[{color}]{display_name + ':':<{QUOTA_NAME_WIDTH}}{usage_str:>{QUOTA_USAGE_WIDTH}} {pct_str:>4} {bar}[/{color}] {status}"
771
+ )
772
+
773
+ # First line goes in the main row
774
+ first_quota = quota_lines[0] if quota_lines else "-"
775
+ table.add_row(
776
+ provider,
777
+ str(cred_count),
778
+ first_quota,
779
+ str(total_requests),
780
+ token_str,
781
+ cost_str,
782
+ )
783
+ # Additional quota lines as sub-rows
784
+ for quota_line in quota_lines[1:]:
785
+ table.add_row("", "", quota_line, "", "", "")
786
+ else:
787
+ # No quota groups
788
+ table.add_row(
789
+ provider,
790
+ str(cred_count),
791
+ "-",
792
+ str(total_requests),
793
+ token_str,
794
+ cost_str,
795
+ )
796
+
797
+ # Add separator between providers (except last)
798
+ if idx < len(providers):
799
+ table.add_row(
800
+ "─" * 10, "─" * 4, "─" * 26, "─" * 7, "─" * 20, "─" * 6
801
+ )
802
+
803
+ self.console.print(table)
804
+
805
+ # Summary line - use global_summary if in global mode
806
+ if self.view_mode == "global":
807
+ summary = self.cached_stats.get(
808
+ "global_summary", self.cached_stats.get("summary", {})
809
+ )
810
+ else:
811
+ summary = self.cached_stats.get("summary", {})
812
+
813
+ total_creds = summary.get("total_credentials", 0)
814
+ total_requests = summary.get("total_requests", 0)
815
+ total_tokens = summary.get("tokens", {})
816
+ total_input = total_tokens.get("input_cached", 0) + total_tokens.get(
817
+ "input_uncached", 0
818
+ )
819
+ total_output = total_tokens.get("output", 0)
820
+ total_cost = format_cost(summary.get("approx_total_cost"))
821
+
822
+ self.console.print()
823
+ self.console.print(
824
+ f"[bold]Total:[/bold] {total_creds} credentials | "
825
+ f"{total_requests} requests | "
826
+ f"{format_tokens(total_input)}/{format_tokens(total_output)} tokens | "
827
+ f"{total_cost} cost"
828
+ )
829
+
830
+ # Menu
831
+ self.console.print()
832
+ self.console.print("━" * 78)
833
+ self.console.print()
834
+
835
+ # Build provider menu options
836
+ providers = self.cached_stats.get("providers", {}) if self.cached_stats else {}
837
+ provider_list = list(providers.keys())
838
+
839
+ for idx, provider in enumerate(provider_list, 1):
840
+ self.console.print(f" {idx}. View [cyan]{provider}[/cyan] details")
841
+
842
+ self.console.print()
843
+ self.console.print(" G. Toggle view mode (current/global)")
844
+ self.console.print(" R. Reload all stats (re-read from proxy)")
845
+ self.console.print(" S. Switch remote")
846
+ self.console.print(" M. Manage remotes")
847
+ self.console.print(" B. Back to main menu")
848
+ self.console.print()
849
+ self.console.print("━" * 78)
850
+
851
+ # Get input
852
+ valid_choices = [str(i) for i in range(1, len(provider_list) + 1)]
853
+ valid_choices.extend(["r", "R", "s", "S", "m", "M", "b", "B", "g", "G"])
854
+
855
+ choice = Prompt.ask("Select option", default="").strip()
856
+
857
+ if choice.lower() == "b":
858
+ self.running = False
859
+ elif choice == "":
860
+ # Empty input - just refresh the screen
861
+ pass
862
+ elif choice.lower() == "g":
863
+ # Toggle view mode
864
+ self.view_mode = "global" if self.view_mode == "current" else "current"
865
+ elif choice.lower() == "r":
866
+ with self.console.status("[bold]Reloading stats...", spinner="dots"):
867
+ self.post_action("reload", scope="all")
868
+ elif choice.lower() == "s":
869
+ self.show_switch_remote_screen()
870
+ elif choice.lower() == "m":
871
+ self.show_manage_remotes_screen()
872
+ elif choice.isdigit() and 1 <= int(choice) <= len(provider_list):
873
+ provider = provider_list[int(choice) - 1]
874
+ self.show_provider_detail_screen(provider)
875
+
876
+ def show_provider_detail_screen(self, provider: str):
877
+ """Display detailed stats for a specific provider."""
878
+ while True:
879
+ clear_screen()
880
+
881
+ # View mode indicator
882
+ if self.view_mode == "global":
883
+ view_label = "[magenta]Global/Lifetime[/magenta]"
884
+ else:
885
+ view_label = "[cyan]Current Period[/cyan]"
886
+
887
+ self.console.print("━" * 78)
888
+ self.console.print(
889
+ f"[bold cyan]📊 {provider.title()} - Detailed Stats[/bold cyan] | {view_label}"
890
+ )
891
+ self.console.print("━" * 78)
892
+ self.console.print()
893
+
894
+ if not self.cached_stats:
895
+ self.console.print("[yellow]No data available.[/yellow]")
896
+ else:
897
+ prov_stats = self.cached_stats.get("providers", {}).get(provider, {})
898
+ credentials = prov_stats.get("credentials", [])
899
+
900
+ # Sort credentials naturally (1, 2, 10 not 1, 10, 2)
901
+ credentials = sorted(credentials, key=natural_sort_key)
902
+
903
+ if not credentials:
904
+ self.console.print(
905
+ "[dim]No credentials configured for this provider.[/dim]"
906
+ )
907
+ else:
908
+ for idx, cred in enumerate(credentials, 1):
909
+ self._render_credential_panel(idx, cred, provider)
910
+ self.console.print()
911
+
912
+ # Menu
913
+ self.console.print("━" * 78)
914
+ self.console.print()
915
+ self.console.print(" G. Toggle view mode (current/global)")
916
+ self.console.print(" R. Reload stats (from proxy cache)")
917
+ self.console.print(" RA. Reload all stats")
918
+
919
+ # Force refresh options (only for providers that support it)
920
+ has_quota_groups = bool(
921
+ self.cached_stats
922
+ and self.cached_stats.get("providers", {})
923
+ .get(provider, {})
924
+ .get("quota_groups")
925
+ )
926
+
927
+ if has_quota_groups:
928
+ self.console.print()
929
+ self.console.print(
930
+ f" F. [yellow]Force refresh ALL {provider} quotas from API[/yellow]"
931
+ )
932
+ credentials = (
933
+ self.cached_stats.get("providers", {})
934
+ .get(provider, {})
935
+ .get("credentials", [])
936
+ if self.cached_stats
937
+ else []
938
+ )
939
+ # Sort credentials naturally
940
+ credentials = sorted(credentials, key=natural_sort_key)
941
+ for idx, cred in enumerate(credentials, 1):
942
+ identifier = cred.get("identifier", f"credential {idx}")
943
+ email = cred.get("email", identifier)
944
+ self.console.print(
945
+ f" F{idx}. Force refresh [{idx}] only ({email})"
946
+ )
947
+
948
+ self.console.print()
949
+ self.console.print(" B. Back to summary")
950
+ self.console.print()
951
+ self.console.print("━" * 78)
952
+
953
+ choice = Prompt.ask("Select option", default="B").strip().upper()
954
+
955
+ if choice == "B":
956
+ break
957
+ elif choice == "G":
958
+ # Toggle view mode
959
+ self.view_mode = "global" if self.view_mode == "current" else "current"
960
+ elif choice == "R":
961
+ with self.console.status(
962
+ f"[bold]Reloading {provider} stats...", spinner="dots"
963
+ ):
964
+ self.post_action("reload", scope="provider", provider=provider)
965
+ elif choice == "RA":
966
+ with self.console.status(
967
+ "[bold]Reloading all stats...", spinner="dots"
968
+ ):
969
+ self.post_action("reload", scope="all")
970
+ elif choice == "F" and has_quota_groups:
971
+ result = None
972
+ with self.console.status(
973
+ f"[bold]Fetching live quota for ALL {provider} credentials...",
974
+ spinner="dots",
975
+ ):
976
+ result = self.post_action(
977
+ "force_refresh", scope="provider", provider=provider
978
+ )
979
+ # Handle result OUTSIDE spinner
980
+ if result and result.get("refresh_result"):
981
+ rr = result["refresh_result"]
982
+ self.console.print(
983
+ f"\n[green]Refreshed {rr.get('credentials_refreshed', 0)} credentials "
984
+ f"in {rr.get('duration_ms', 0)}ms[/green]"
985
+ )
986
+ if rr.get("errors"):
987
+ for err in rr["errors"]:
988
+ self.console.print(f"[red] Error: {err}[/red]")
989
+ Prompt.ask("Press Enter to continue", default="")
990
+ elif choice.startswith("F") and choice[1:].isdigit() and has_quota_groups:
991
+ idx = int(choice[1:])
992
+ credentials = (
993
+ self.cached_stats.get("providers", {})
994
+ .get(provider, {})
995
+ .get("credentials", [])
996
+ if self.cached_stats
997
+ else []
998
+ )
999
+ # Sort credentials naturally to match display order
1000
+ credentials = sorted(credentials, key=natural_sort_key)
1001
+ if 1 <= idx <= len(credentials):
1002
+ cred = credentials[idx - 1]
1003
+ cred_id = cred.get("identifier", "")
1004
+ email = cred.get("email", cred_id)
1005
+ result = None
1006
+ with self.console.status(
1007
+ f"[bold]Fetching live quota for {email}...", spinner="dots"
1008
+ ):
1009
+ result = self.post_action(
1010
+ "force_refresh",
1011
+ scope="credential",
1012
+ provider=provider,
1013
+ credential=cred_id,
1014
+ )
1015
+ # Handle result OUTSIDE spinner
1016
+ if result and result.get("refresh_result"):
1017
+ rr = result["refresh_result"]
1018
+ self.console.print(
1019
+ f"\n[green]Refreshed in {rr.get('duration_ms', 0)}ms[/green]"
1020
+ )
1021
+ if rr.get("errors"):
1022
+ for err in rr["errors"]:
1023
+ self.console.print(f"[red] Error: {err}[/red]")
1024
+ Prompt.ask("Press Enter to continue", default="")
1025
+
1026
+ def _render_credential_panel(self, idx: int, cred: Dict[str, Any], provider: str):
1027
+ """Render a single credential as a panel."""
1028
+ identifier = cred.get("identifier", f"credential {idx}")
1029
+ email = cred.get("email")
1030
+ tier = cred.get("tier", "")
1031
+ status = cred.get("status", "unknown")
1032
+
1033
+ # Check for active cooldowns
1034
+ key_cooldown = cred.get("key_cooldown_remaining")
1035
+ model_cooldowns = cred.get("model_cooldowns", {})
1036
+ has_cooldown = key_cooldown or model_cooldowns
1037
+
1038
+ # Status indicator
1039
+ if status == "exhausted":
1040
+ status_icon = "[red]⛔ Exhausted[/red]"
1041
+ elif status == "cooldown" or has_cooldown:
1042
+ if key_cooldown:
1043
+ status_icon = f"[yellow]⚠️ Cooldown ({format_cooldown(int(key_cooldown))})[/yellow]"
1044
+ else:
1045
+ status_icon = "[yellow]⚠️ Cooldown[/yellow]"
1046
+ else:
1047
+ status_icon = "[green]✅ Active[/green]"
1048
+
1049
+ # Header line
1050
+ display_name = email if email else identifier
1051
+ tier_str = f" ({tier})" if tier else ""
1052
+ header = f"[{idx}] {display_name}{tier_str} {status_icon}"
1053
+
1054
+ # Use global stats if in global mode
1055
+ if self.view_mode == "global":
1056
+ stats_source = cred.get("global", cred)
1057
+ else:
1058
+ stats_source = cred
1059
+
1060
+ # Stats line
1061
+ last_used = format_time_ago(cred.get("last_used_ts")) # Always from current
1062
+ requests = stats_source.get("requests", 0)
1063
+ tokens = stats_source.get("tokens", {})
1064
+ input_total = tokens.get("input_cached", 0) + tokens.get("input_uncached", 0)
1065
+ output = tokens.get("output", 0)
1066
+ cost = format_cost(stats_source.get("approx_cost"))
1067
+
1068
+ stats_line = (
1069
+ f"Last used: {last_used} | Requests: {requests} | "
1070
+ f"Tokens: {format_tokens(input_total)}/{format_tokens(output)}"
1071
+ )
1072
+ if cost != "-":
1073
+ stats_line += f" | Cost: {cost}"
1074
+
1075
+ # Build panel content
1076
+ content_lines = [
1077
+ f"[dim]{stats_line}[/dim]",
1078
+ ]
1079
+
1080
+ # Model groups (for providers with quota tracking)
1081
+ model_groups = cred.get("model_groups", {})
1082
+
1083
+ # Show cooldowns grouped by quota group (if model_groups exist)
1084
+ if model_cooldowns:
1085
+ if model_groups:
1086
+ # Group cooldowns by quota group
1087
+ group_cooldowns: Dict[
1088
+ str, int
1089
+ ] = {} # group_name -> max_remaining_seconds
1090
+ ungrouped_cooldowns: List[Tuple[str, int]] = []
1091
+
1092
+ for model_name, cooldown_info in model_cooldowns.items():
1093
+ remaining = cooldown_info.get("remaining_seconds", 0)
1094
+ if remaining <= 0:
1095
+ continue
1096
+
1097
+ # Find which group this model belongs to
1098
+ clean_model = model_name.split("/")[-1]
1099
+ found_group = None
1100
+ for group_name, group_info in model_groups.items():
1101
+ group_models = group_info.get("models", [])
1102
+ if clean_model in group_models:
1103
+ found_group = group_name
1104
+ break
1105
+
1106
+ if found_group:
1107
+ group_cooldowns[found_group] = max(
1108
+ group_cooldowns.get(found_group, 0), remaining
1109
+ )
1110
+ else:
1111
+ ungrouped_cooldowns.append((model_name, remaining))
1112
+
1113
+ if group_cooldowns or ungrouped_cooldowns:
1114
+ content_lines.append("")
1115
+ content_lines.append("[yellow]Active Cooldowns:[/yellow]")
1116
+
1117
+ # Show grouped cooldowns
1118
+ for group_name in sorted(group_cooldowns.keys()):
1119
+ remaining = group_cooldowns[group_name]
1120
+ content_lines.append(
1121
+ f" [yellow]⏱️ {group_name}: {format_cooldown(remaining)}[/yellow]"
1122
+ )
1123
+
1124
+ # Show ungrouped (shouldn't happen often)
1125
+ for model_name, remaining in ungrouped_cooldowns:
1126
+ short_model = model_name.split("/")[-1][:35]
1127
+ content_lines.append(
1128
+ f" [yellow]⏱️ {short_model}: {format_cooldown(remaining)}[/yellow]"
1129
+ )
1130
+ else:
1131
+ # No model groups - show per-model cooldowns
1132
+ content_lines.append("")
1133
+ content_lines.append("[yellow]Active Cooldowns:[/yellow]")
1134
+ for model_name, cooldown_info in model_cooldowns.items():
1135
+ remaining = cooldown_info.get("remaining_seconds", 0)
1136
+ if remaining > 0:
1137
+ short_model = model_name.split("/")[-1][:35]
1138
+ content_lines.append(
1139
+ f" [yellow]⏱��� {short_model}: {format_cooldown(int(remaining))}[/yellow]"
1140
+ )
1141
+
1142
+ # Display model groups with quota info
1143
+ if model_groups:
1144
+ content_lines.append("")
1145
+ for group_name, group_stats in model_groups.items():
1146
+ remaining_pct = group_stats.get("remaining_pct")
1147
+ requests_used = group_stats.get("requests_used", 0)
1148
+ requests_max = group_stats.get("requests_max")
1149
+ requests_remaining = group_stats.get("requests_remaining")
1150
+ is_exhausted = group_stats.get("is_exhausted", False)
1151
+ reset_time = format_reset_time(group_stats.get("reset_time_iso"))
1152
+ confidence = group_stats.get("confidence", "low")
1153
+
1154
+ # Format display - use requests_remaining/max format
1155
+ if requests_remaining is None and requests_max:
1156
+ requests_remaining = max(0, requests_max - requests_used)
1157
+ display = group_stats.get(
1158
+ "display", f"{requests_remaining or 0}/{requests_max or '?'}"
1159
+ )
1160
+ bar = create_progress_bar(remaining_pct)
1161
+
1162
+ # Build status text - always show reset time if available
1163
+ has_reset_time = reset_time and reset_time != "-"
1164
+
1165
+ # Color based on status
1166
+ if is_exhausted:
1167
+ color = "red"
1168
+ if has_reset_time:
1169
+ status_text = f"⛔ Resets: {reset_time}"
1170
+ else:
1171
+ status_text = "⛔ EXHAUSTED"
1172
+ elif remaining_pct is not None and remaining_pct < 20:
1173
+ color = "yellow"
1174
+ if has_reset_time:
1175
+ status_text = f"⚠️ Resets: {reset_time}"
1176
+ else:
1177
+ status_text = "⚠️ LOW"
1178
+ else:
1179
+ color = "green"
1180
+ if has_reset_time:
1181
+ status_text = f"Resets: {reset_time}"
1182
+ else:
1183
+ status_text = "" # Hide if unused/no reset time
1184
+
1185
+ # Confidence indicator
1186
+ conf_indicator = ""
1187
+ if confidence == "low":
1188
+ conf_indicator = " [dim](~)[/dim]"
1189
+ elif confidence == "medium":
1190
+ conf_indicator = " [dim](?)[/dim]"
1191
+
1192
+ pct_str = f"{remaining_pct}%" if remaining_pct is not None else "?%"
1193
+ content_lines.append(
1194
+ f" [{color}]{group_name:<18} {display:<10} {pct_str:>4} {bar}[/{color}] {status_text}{conf_indicator}"
1195
+ )
1196
+ else:
1197
+ # For providers without quota groups, show model breakdown if available
1198
+ models = cred.get("models", {})
1199
+ if models:
1200
+ content_lines.append("")
1201
+ content_lines.append(" [dim]Models used:[/dim]")
1202
+ for model_name, model_stats in models.items():
1203
+ req_count = model_stats.get("success_count", 0)
1204
+ model_cost = format_cost(model_stats.get("approx_cost"))
1205
+ # Shorten model name for display
1206
+ short_name = model_name.split("/")[-1][:30]
1207
+ content_lines.append(
1208
+ f" {short_name}: {req_count} requests, {model_cost}"
1209
+ )
1210
+
1211
+ self.console.print(
1212
+ Panel(
1213
+ "\n".join(content_lines),
1214
+ title=header,
1215
+ title_align="left",
1216
+ border_style="dim",
1217
+ expand=True,
1218
+ )
1219
+ )
1220
+
1221
+ def show_switch_remote_screen(self):
1222
+ """Display remote selection screen."""
1223
+ clear_screen()
1224
+
1225
+ self.console.print("━" * 78)
1226
+ self.console.print("[bold cyan]🔄 Switch Remote[/bold cyan]")
1227
+ self.console.print("━" * 78)
1228
+ self.console.print()
1229
+
1230
+ current_name = self.current_remote.get("name") if self.current_remote else None
1231
+ self.console.print(f"Current: [bold]{current_name}[/bold]")
1232
+ self.console.print()
1233
+ self.console.print("Available remotes:")
1234
+
1235
+ remotes = self.config.get_remotes()
1236
+ remote_status: List[Tuple[Dict, bool, str]] = []
1237
+
1238
+ # Check status of all remotes
1239
+ with self.console.status("[dim]Checking remote status...", spinner="dots"):
1240
+ for remote in remotes:
1241
+ is_online, status_msg = self.check_connection(remote)
1242
+ remote_status.append((remote, is_online, status_msg))
1243
+
1244
+ for idx, (remote, is_online, status_msg) in enumerate(remote_status, 1):
1245
+ name = remote.get("name", "Unknown")
1246
+ host = remote.get("host", "")
1247
+ port = remote.get("port", "")
1248
+
1249
+ # Format connection display - handle full URLs
1250
+ if is_full_url(host):
1251
+ connection_display = host
1252
+ elif port:
1253
+ connection_display = f"{host}:{port}"
1254
+ else:
1255
+ connection_display = host
1256
+
1257
+ is_current = name == current_name
1258
+ current_marker = " (current)" if is_current else ""
1259
+
1260
+ if is_online:
1261
+ status_icon = "[green]✅ Online[/green]"
1262
+ else:
1263
+ status_icon = f"[red]⚠️ {status_msg}[/red]"
1264
+
1265
+ self.console.print(
1266
+ f" {idx}. {name:<20} {connection_display:<30} {status_icon}{current_marker}"
1267
+ )
1268
+
1269
+ self.console.print()
1270
+ self.console.print("━" * 78)
1271
+ self.console.print()
1272
+
1273
+ choice = Prompt.ask(
1274
+ f"Select remote (1-{len(remotes)}) or B to go back", default="B"
1275
+ ).strip()
1276
+
1277
+ if choice.lower() == "b":
1278
+ return
1279
+
1280
+ if choice.isdigit() and 1 <= int(choice) <= len(remotes):
1281
+ selected = remotes[int(choice) - 1]
1282
+ self.current_remote = selected
1283
+ self.config.set_last_used(selected["name"])
1284
+ self.cached_stats = None # Clear cache
1285
+
1286
+ # Try to fetch stats from new remote
1287
+ with self.console.status("[bold]Connecting...", spinner="dots"):
1288
+ stats = self.fetch_stats()
1289
+ if stats is None:
1290
+ # Try with API key from .env for Local
1291
+ if selected["name"] == "Local" and not selected.get("api_key"):
1292
+ env_key = self.config.get_api_key_from_env()
1293
+ if env_key:
1294
+ self.current_remote["api_key"] = env_key
1295
+ stats = self.fetch_stats()
1296
+
1297
+ if stats is None:
1298
+ self.show_api_key_prompt()
1299
+
1300
+ def show_api_key_prompt(self):
1301
+ """Prompt for API key when authentication fails."""
1302
+ self.console.print()
1303
+ self.console.print(
1304
+ "[yellow]Authentication required or connection failed.[/yellow]"
1305
+ )
1306
+ self.console.print(f"Error: {self.last_error}")
1307
+ self.console.print()
1308
+
1309
+ api_key = Prompt.ask(
1310
+ "Enter API key (or press Enter to cancel)", default=""
1311
+ ).strip()
1312
+
1313
+ if api_key:
1314
+ self.current_remote["api_key"] = api_key
1315
+ # Update config with new API key
1316
+ self.config.update_remote(self.current_remote["name"], api_key=api_key)
1317
+
1318
+ # Try again
1319
+ with self.console.status("[bold]Reconnecting...", spinner="dots"):
1320
+ if self.fetch_stats() is None:
1321
+ self.console.print(f"[red]Still failed: {self.last_error}[/red]")
1322
+ Prompt.ask("Press Enter to continue", default="")
1323
+ else:
1324
+ self.console.print("[dim]Cancelled.[/dim]")
1325
+ Prompt.ask("Press Enter to continue", default="")
1326
+
1327
+ def show_manage_remotes_screen(self):
1328
+ """Display remote management screen."""
1329
+ while True:
1330
+ clear_screen()
1331
+
1332
+ self.console.print("━" * 78)
1333
+ self.console.print("[bold cyan]⚙️ Manage Remotes[/bold cyan]")
1334
+ self.console.print("━" * 78)
1335
+ self.console.print()
1336
+
1337
+ remotes = self.config.get_remotes()
1338
+
1339
+ table = Table(box=None, show_header=True, header_style="bold")
1340
+ table.add_column("#", style="dim", width=3)
1341
+ table.add_column("Name", min_width=16)
1342
+ table.add_column("Host", min_width=24)
1343
+ table.add_column("Port", justify="right", width=6)
1344
+ table.add_column("Default", width=8)
1345
+
1346
+ for idx, remote in enumerate(remotes, 1):
1347
+ is_default = "★" if remote.get("is_default") else ""
1348
+ table.add_row(
1349
+ str(idx),
1350
+ remote.get("name", ""),
1351
+ remote.get("host", ""),
1352
+ str(remote.get("port", 8000)),
1353
+ is_default,
1354
+ )
1355
+
1356
+ self.console.print(table)
1357
+
1358
+ self.console.print()
1359
+ self.console.print("━" * 78)
1360
+ self.console.print()
1361
+ self.console.print(" A. Add new remote")
1362
+ self.console.print(" E. Edit remote (enter number, e.g., E1)")
1363
+ self.console.print(" D. Delete remote (enter number, e.g., D1)")
1364
+ self.console.print(" S. Set default remote")
1365
+ self.console.print(" B. Back")
1366
+ self.console.print()
1367
+ self.console.print("━" * 78)
1368
+
1369
+ choice = Prompt.ask("Select option", default="B").strip().upper()
1370
+
1371
+ if choice == "B":
1372
+ break
1373
+ elif choice == "A":
1374
+ self._add_remote_dialog()
1375
+ elif choice == "S":
1376
+ self._set_default_dialog(remotes)
1377
+ elif choice.startswith("E") and choice[1:].isdigit():
1378
+ idx = int(choice[1:])
1379
+ if 1 <= idx <= len(remotes):
1380
+ self._edit_remote_dialog(remotes[idx - 1])
1381
+ elif choice.startswith("D") and choice[1:].isdigit():
1382
+ idx = int(choice[1:])
1383
+ if 1 <= idx <= len(remotes):
1384
+ self._delete_remote_dialog(remotes[idx - 1])
1385
+
1386
+ def _add_remote_dialog(self):
1387
+ """Dialog to add a new remote."""
1388
+ self.console.print()
1389
+ self.console.print("[bold]Add New Remote[/bold]")
1390
+ self.console.print(
1391
+ "[dim]For full URLs (e.g., https://api.example.com/v1), leave port empty[/dim]"
1392
+ )
1393
+ self.console.print()
1394
+
1395
+ name = Prompt.ask("Name", default="").strip()
1396
+ if not name:
1397
+ self.console.print("[dim]Cancelled.[/dim]")
1398
+ return
1399
+
1400
+ host = Prompt.ask("Host (or full URL)", default="").strip()
1401
+ if not host:
1402
+ self.console.print("[dim]Cancelled.[/dim]")
1403
+ return
1404
+
1405
+ # For full URLs, default to empty port
1406
+ if is_full_url(host):
1407
+ port_default = ""
1408
+ else:
1409
+ port_default = "8000"
1410
+
1411
+ port_str = Prompt.ask(
1412
+ "Port (empty for full URLs)", default=port_default
1413
+ ).strip()
1414
+ if port_str == "":
1415
+ port = ""
1416
+ else:
1417
+ try:
1418
+ port = int(port_str)
1419
+ except ValueError:
1420
+ port = 8000
1421
+
1422
+ api_key = Prompt.ask("API Key (optional)", default="").strip() or None
1423
+
1424
+ if self.config.add_remote(name, host, port, api_key):
1425
+ self.console.print(f"[green]Added remote '{name}'.[/green]")
1426
+ else:
1427
+ self.console.print(f"[red]Remote '{name}' already exists.[/red]")
1428
+
1429
+ Prompt.ask("Press Enter to continue", default="")
1430
+
1431
+ def _edit_remote_dialog(self, remote: Dict[str, Any]):
1432
+ """Dialog to edit an existing remote."""
1433
+ self.console.print()
1434
+ self.console.print(f"[bold]Edit Remote: {remote['name']}[/bold]")
1435
+ self.console.print(
1436
+ "[dim]Press Enter to keep current value. For full URLs, leave port empty.[/dim]"
1437
+ )
1438
+ self.console.print()
1439
+
1440
+ new_name = Prompt.ask("Name", default=remote["name"]).strip()
1441
+ new_host = Prompt.ask(
1442
+ "Host (or full URL)", default=remote.get("host", "")
1443
+ ).strip()
1444
+
1445
+ # Get current port, handle empty string
1446
+ current_port = remote.get("port", "")
1447
+ port_default = str(current_port) if current_port != "" else ""
1448
+
1449
+ new_port_str = Prompt.ask(
1450
+ "Port (empty for full URLs)", default=port_default
1451
+ ).strip()
1452
+ if new_port_str == "":
1453
+ new_port = ""
1454
+ else:
1455
+ try:
1456
+ new_port = int(new_port_str)
1457
+ except ValueError:
1458
+ new_port = current_port if current_port != "" else 8000
1459
+
1460
+ current_key = remote.get("api_key", "") or ""
1461
+ display_key = f"{current_key[:8]}..." if len(current_key) > 8 else current_key
1462
+ new_key = Prompt.ask(
1463
+ f"API Key (current: {display_key or 'none'})", default=""
1464
+ ).strip()
1465
+
1466
+ updates = {}
1467
+ if new_name != remote["name"]:
1468
+ updates["new_name"] = new_name
1469
+ if new_host != remote.get("host"):
1470
+ updates["host"] = new_host
1471
+ if new_port != remote.get("port"):
1472
+ updates["port"] = new_port
1473
+ if new_key:
1474
+ updates["api_key"] = new_key
1475
+
1476
+ if updates:
1477
+ if self.config.update_remote(remote["name"], **updates):
1478
+ self.console.print("[green]Remote updated.[/green]")
1479
+ # Update current_remote if it was the one being edited
1480
+ if (
1481
+ self.current_remote
1482
+ and self.current_remote["name"] == remote["name"]
1483
+ ):
1484
+ self.current_remote.update(updates)
1485
+ if "new_name" in updates:
1486
+ self.current_remote["name"] = updates["new_name"]
1487
+ else:
1488
+ self.console.print("[red]Failed to update remote.[/red]")
1489
+ else:
1490
+ self.console.print("[dim]No changes made.[/dim]")
1491
+
1492
+ Prompt.ask("Press Enter to continue", default="")
1493
+
1494
+ def _delete_remote_dialog(self, remote: Dict[str, Any]):
1495
+ """Dialog to delete a remote."""
1496
+ self.console.print()
1497
+ self.console.print(f"[yellow]Delete remote '{remote['name']}'?[/yellow]")
1498
+
1499
+ confirm = Prompt.ask("Type 'yes' to confirm", default="no").strip().lower()
1500
+
1501
+ if confirm == "yes":
1502
+ if self.config.delete_remote(remote["name"]):
1503
+ self.console.print(f"[green]Deleted remote '{remote['name']}'.[/green]")
1504
+ # If deleted current remote, switch to another
1505
+ if (
1506
+ self.current_remote
1507
+ and self.current_remote["name"] == remote["name"]
1508
+ ):
1509
+ self.current_remote = self.config.get_default_remote()
1510
+ self.cached_stats = None
1511
+ else:
1512
+ self.console.print(
1513
+ "[red]Cannot delete. At least one remote must exist.[/red]"
1514
+ )
1515
+ else:
1516
+ self.console.print("[dim]Cancelled.[/dim]")
1517
+
1518
+ Prompt.ask("Press Enter to continue", default="")
1519
+
1520
+ def _set_default_dialog(self, remotes: List[Dict[str, Any]]):
1521
+ """Dialog to set the default remote."""
1522
+ self.console.print()
1523
+ choice = Prompt.ask(f"Set default (1-{len(remotes)})", default="").strip()
1524
+
1525
+ if choice.isdigit() and 1 <= int(choice) <= len(remotes):
1526
+ remote = remotes[int(choice) - 1]
1527
+ if self.config.set_default_remote(remote["name"]):
1528
+ self.console.print(
1529
+ f"[green]'{remote['name']}' is now the default.[/green]"
1530
+ )
1531
+ else:
1532
+ self.console.print("[red]Failed to set default.[/red]")
1533
+ Prompt.ask("Press Enter to continue", default="")
1534
+
1535
+ # =========================================================================
1536
+ # MAIN LOOP
1537
+ # =========================================================================
1538
+
1539
+ def run(self):
1540
+ """Main viewer loop."""
1541
+ # Get initial remote
1542
+ self.current_remote = self.config.get_last_used_remote()
1543
+
1544
+ if not self.current_remote:
1545
+ self.console.print("[red]No remotes configured.[/red]")
1546
+ return
1547
+
1548
+ # Connection loop - allows retry after configuring remotes
1549
+ while True:
1550
+ # For Local remote, try to get API key from .env if not set
1551
+ if self.current_remote["name"] == "Local" and not self.current_remote.get(
1552
+ "api_key"
1553
+ ):
1554
+ env_key = self.config.get_api_key_from_env()
1555
+ if env_key:
1556
+ self.current_remote["api_key"] = env_key
1557
+
1558
+ # Try to connect
1559
+ with self.console.status("[bold]Connecting to proxy...", spinner="dots"):
1560
+ stats = self.fetch_stats()
1561
+
1562
+ if stats is not None:
1563
+ break # Connected successfully
1564
+
1565
+ # Connection failed - show error with options
1566
+ choice = self.show_connection_error()
1567
+
1568
+ if choice == "b":
1569
+ return # Exit to main menu
1570
+ elif choice == "s":
1571
+ self.show_switch_remote_screen()
1572
+ elif choice == "m":
1573
+ self.show_manage_remotes_screen()
1574
+ elif choice == "r":
1575
+ continue # Retry connection
1576
+
1577
+ # After switch/manage, refresh current_remote from config
1578
+ # (it may have been changed)
1579
+ if self.current_remote:
1580
+ updated = self.config.get_remote_by_name(self.current_remote["name"])
1581
+ if updated:
1582
+ self.current_remote = updated
1583
+
1584
+ # Main loop
1585
+ while self.running:
1586
+ self.show_summary_screen()
1587
+
1588
+
1589
+ def run_quota_viewer():
1590
+ """Entry point for the quota viewer."""
1591
+ viewer = QuotaViewer()
1592
+ viewer.run()
1593
+
1594
+
1595
+ if __name__ == "__main__":
1596
+ run_quota_viewer()
src/proxy_app/quota_viewer_config.py ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Mirrowel
3
+
4
+ """
5
+ Configuration management for the Quota Viewer.
6
+
7
+ Handles remote proxy configurations including:
8
+ - Multiple remote proxies (local, VPS, etc.)
9
+ - API key storage per remote
10
+ - Default and last-used remote tracking
11
+ """
12
+
13
+ import json
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional, Union
16
+
17
+
18
+ class QuotaViewerConfig:
19
+ """Manages quota viewer configuration including remote proxies."""
20
+
21
+ def __init__(self, config_path: Optional[Path] = None):
22
+ """
23
+ Initialize the config manager.
24
+
25
+ Args:
26
+ config_path: Path to config file. Defaults to quota_viewer_config.json
27
+ in the current directory or EXE directory.
28
+ """
29
+ if config_path is None:
30
+ import sys
31
+
32
+ if getattr(sys, "frozen", False):
33
+ base_dir = Path(sys.executable).parent
34
+ else:
35
+ base_dir = Path.cwd()
36
+ config_path = base_dir / "quota_viewer_config.json"
37
+
38
+ self.config_path = config_path
39
+ self.config = self._load()
40
+
41
+ def _load(self) -> Dict[str, Any]:
42
+ """Load config from file or return defaults."""
43
+ if self.config_path.exists():
44
+ try:
45
+ with open(self.config_path, "r", encoding="utf-8") as f:
46
+ config = json.load(f)
47
+ # Ensure required fields exist
48
+ if "remotes" not in config:
49
+ config["remotes"] = []
50
+ return config
51
+ except (json.JSONDecodeError, IOError):
52
+ pass
53
+
54
+ # Return default config with Local remote
55
+ return {
56
+ "remotes": [
57
+ {
58
+ "name": "Local",
59
+ "host": "127.0.0.1",
60
+ "port": 8000,
61
+ "api_key": None,
62
+ "is_default": True,
63
+ }
64
+ ],
65
+ "last_used": "Local",
66
+ }
67
+
68
+ def _save(self) -> bool:
69
+ """Save config to file. Returns True on success."""
70
+ try:
71
+ with open(self.config_path, "w", encoding="utf-8") as f:
72
+ json.dump(self.config, f, indent=2)
73
+ return True
74
+ except IOError:
75
+ return False
76
+
77
+ def get_remotes(self) -> List[Dict[str, Any]]:
78
+ """Get list of all configured remotes."""
79
+ return self.config.get("remotes", [])
80
+
81
+ def get_remote_by_name(self, name: str) -> Optional[Dict[str, Any]]:
82
+ """Get a remote by name."""
83
+ for remote in self.config.get("remotes", []):
84
+ if remote["name"] == name:
85
+ return remote
86
+ return None
87
+
88
+ def get_default_remote(self) -> Optional[Dict[str, Any]]:
89
+ """Get the default remote."""
90
+ for remote in self.config.get("remotes", []):
91
+ if remote.get("is_default"):
92
+ return remote
93
+ # Fallback to first remote
94
+ remotes = self.config.get("remotes", [])
95
+ return remotes[0] if remotes else None
96
+
97
+ def get_last_used_remote(self) -> Optional[Dict[str, Any]]:
98
+ """Get the last used remote, or default if not set."""
99
+ last_used_name = self.config.get("last_used")
100
+ if last_used_name:
101
+ remote = self.get_remote_by_name(last_used_name)
102
+ if remote:
103
+ return remote
104
+ return self.get_default_remote()
105
+
106
+ def set_last_used(self, name: str) -> bool:
107
+ """Set the last used remote name."""
108
+ self.config["last_used"] = name
109
+ return self._save()
110
+
111
+ def add_remote(
112
+ self,
113
+ name: str,
114
+ host: str,
115
+ port: Optional[Union[int, str]] = 8000,
116
+ api_key: Optional[str] = None,
117
+ is_default: bool = False,
118
+ ) -> bool:
119
+ """
120
+ Add a new remote configuration.
121
+
122
+ Args:
123
+ name: Display name for the remote
124
+ host: Hostname, IP address, or full URL (e.g., https://api.example.com/v1)
125
+ port: Port number (default 8000). Can be None or empty string for full URLs.
126
+ api_key: Optional API key for authentication
127
+ is_default: Whether this should be the default remote
128
+
129
+ Returns:
130
+ True on success, False if name already exists
131
+ """
132
+ # Check for duplicate name
133
+ if self.get_remote_by_name(name):
134
+ return False
135
+
136
+ # If setting as default, clear default from others
137
+ if is_default:
138
+ for remote in self.config.get("remotes", []):
139
+ remote["is_default"] = False
140
+
141
+ # Normalize port - allow empty/None for full URL hosts
142
+ if port == "" or port is None:
143
+ normalized_port = ""
144
+ else:
145
+ normalized_port = (
146
+ int(port) if isinstance(port, str) and port.isdigit() else port
147
+ )
148
+
149
+ remote = {
150
+ "name": name,
151
+ "host": host,
152
+ "port": normalized_port,
153
+ "api_key": api_key,
154
+ "is_default": is_default,
155
+ }
156
+ self.config.setdefault("remotes", []).append(remote)
157
+ return self._save()
158
+
159
+ def update_remote(self, name: str, **kwargs) -> bool:
160
+ """
161
+ Update an existing remote configuration.
162
+
163
+ Args:
164
+ name: Name of the remote to update
165
+ **kwargs: Fields to update (host, port, api_key, is_default, new_name)
166
+ port can be int, str, or empty string for full URL hosts
167
+
168
+ Returns:
169
+ True on success, False if remote not found
170
+ """
171
+ remote = self.get_remote_by_name(name)
172
+ if not remote:
173
+ return False
174
+
175
+ # Handle rename
176
+ if "new_name" in kwargs:
177
+ new_name = kwargs.pop("new_name")
178
+ if new_name != name and self.get_remote_by_name(new_name):
179
+ return False # New name already exists
180
+ remote["name"] = new_name
181
+ # Update last_used if it was this remote
182
+ if self.config.get("last_used") == name:
183
+ self.config["last_used"] = new_name
184
+
185
+ # If setting as default, clear default from others
186
+ if kwargs.get("is_default"):
187
+ for r in self.config.get("remotes", []):
188
+ r["is_default"] = False
189
+
190
+ # Update other fields
191
+ for key in ("host", "port", "api_key", "is_default"):
192
+ if key in kwargs:
193
+ remote[key] = kwargs[key]
194
+
195
+ return self._save()
196
+
197
+ def delete_remote(self, name: str) -> bool:
198
+ """
199
+ Delete a remote configuration.
200
+
201
+ Args:
202
+ name: Name of the remote to delete
203
+
204
+ Returns:
205
+ True on success, False if remote not found or is the only one
206
+ """
207
+ remotes = self.config.get("remotes", [])
208
+ if len(remotes) <= 1:
209
+ return False # Don't delete the last remote
210
+
211
+ for i, remote in enumerate(remotes):
212
+ if remote["name"] == name:
213
+ remotes.pop(i)
214
+ # Update last_used if it was this remote
215
+ if self.config.get("last_used") == name:
216
+ self.config["last_used"] = remotes[0]["name"] if remotes else None
217
+ return self._save()
218
+ return False
219
+
220
+ def set_default_remote(self, name: str) -> bool:
221
+ """Set a remote as the default."""
222
+ remote = self.get_remote_by_name(name)
223
+ if not remote:
224
+ return False
225
+
226
+ # Clear default from all remotes
227
+ for r in self.config.get("remotes", []):
228
+ r["is_default"] = False
229
+
230
+ # Set new default
231
+ remote["is_default"] = True
232
+ return self._save()
233
+
234
+ def sync_with_launcher_config(self) -> None:
235
+ """
236
+ Sync the Local remote with launcher_config.json if it exists.
237
+
238
+ This ensures the Local remote always matches the launcher settings.
239
+ """
240
+ import sys
241
+
242
+ if getattr(sys, "frozen", False):
243
+ base_dir = Path(sys.executable).parent
244
+ else:
245
+ base_dir = Path.cwd()
246
+
247
+ launcher_config_path = base_dir / "launcher_config.json"
248
+
249
+ if launcher_config_path.exists():
250
+ try:
251
+ with open(launcher_config_path, "r", encoding="utf-8") as f:
252
+ launcher_config = json.load(f)
253
+
254
+ host = launcher_config.get("host", "127.0.0.1")
255
+ port = launcher_config.get("port", 8000)
256
+
257
+ # Update Local remote
258
+ local_remote = self.get_remote_by_name("Local")
259
+ if local_remote:
260
+ local_remote["host"] = host
261
+ local_remote["port"] = port
262
+ self._save()
263
+ else:
264
+ # Create Local remote if it doesn't exist
265
+ self.add_remote("Local", host, port, is_default=True)
266
+
267
+ except (json.JSONDecodeError, IOError):
268
+ pass
269
+
270
+ def get_api_key_from_env(self) -> Optional[str]:
271
+ """
272
+ Get PROXY_API_KEY from .env file for Local remote.
273
+
274
+ Returns:
275
+ API key string or None
276
+ """
277
+ import sys
278
+
279
+ if getattr(sys, "frozen", False):
280
+ base_dir = Path(sys.executable).parent
281
+ else:
282
+ base_dir = Path.cwd()
283
+
284
+ env_path = base_dir / ".env"
285
+ if not env_path.exists():
286
+ return None
287
+
288
+ try:
289
+ with open(env_path, "r", encoding="utf-8") as f:
290
+ for line in f:
291
+ line = line.strip()
292
+ if line.startswith("PROXY_API_KEY="):
293
+ value = line.split("=", 1)[1].strip()
294
+ # Remove quotes if present
295
+ if value and value[0] in ('"', "'") and value[-1] == value[0]:
296
+ value = value[1:-1]
297
+ return value if value else None
298
+ except IOError:
299
+ pass
300
+ return None
src/proxy_app/request_logger.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Mirrowel
3
+
4
+ import json
5
+ import os
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ import uuid
9
+ from typing import Literal, Dict
10
+ import logging
11
+
12
+ from .provider_urls import get_provider_endpoint
13
+
14
+ def log_request_to_console(url: str, headers: dict, client_info: tuple, request_data: dict):
15
+ """
16
+ Logs a concise, single-line summary of an incoming request to the console.
17
+ """
18
+ time_str = datetime.now().strftime("%H:%M")
19
+ model_full = request_data.get("model", "N/A")
20
+
21
+ provider = "N/A"
22
+ model_name = model_full
23
+ endpoint_url = "N/A"
24
+
25
+ if '/' in model_full:
26
+ parts = model_full.split('/', 1)
27
+ provider = parts[0]
28
+ model_name = parts[1]
29
+ # Use the helper function to get the full endpoint URL
30
+ endpoint_url = get_provider_endpoint(provider, model_name, url) or "N/A"
31
+
32
+ log_message = f"{time_str} - {client_info[0]}:{client_info[1]} - provider: {provider}, model: {model_name} - {endpoint_url}"
33
+ logging.info(log_message)
34
+
src/proxy_app/settings_tool.py ADDED
The diff for this file is too large to render. See raw diff
 
src/rotator_library/COPYING ADDED
@@ -0,0 +1,674 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ GNU GENERAL PUBLIC LICENSE
2
+ Version 3, 29 June 2007
3
+
4
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
5
+ Everyone is permitted to copy and distribute verbatim copies
6
+ of this license document, but changing it is not allowed.
7
+
8
+ Preamble
9
+
10
+ The GNU General Public License is a free, copyleft license for
11
+ software and other kinds of works.
12
+
13
+ The licenses for most software and other practical works are designed
14
+ to take away your freedom to share and change the works. By contrast,
15
+ the GNU General Public License is intended to guarantee your freedom to
16
+ share and change all versions of a program--to make sure it remains free
17
+ software for all its users. We, the Free Software Foundation, use the
18
+ GNU General Public License for most of our software; it applies also to
19
+ any other work released this way by its authors. You can apply it to
20
+ your programs, too.
21
+
22
+ When we speak of free software, we are referring to freedom, not
23
+ price. Our General Public Licenses are designed to make sure that you
24
+ have the freedom to distribute copies of free software (and charge for
25
+ them if you wish), that you receive source code or can get it if you
26
+ want it, that you can change the software or use pieces of it in new
27
+ free programs, and that you know you can do these things.
28
+
29
+ To protect your rights, we need to prevent others from denying you
30
+ these rights or asking you to surrender the rights. Therefore, you have
31
+ certain responsibilities if you distribute copies of the software, or if
32
+ you modify it: responsibilities to respect the freedom of others.
33
+
34
+ For example, if you distribute copies of such a program, whether
35
+ gratis or for a fee, you must pass on to the recipients the same
36
+ freedoms that you received. You must make sure that they, too, receive
37
+ or can get the source code. And you must show them these terms so they
38
+ know their rights.
39
+
40
+ Developers that use the GNU GPL protect your rights with two steps:
41
+ (1) assert copyright on the software, and (2) offer you this License
42
+ giving you legal permission to copy, distribute and/or modify it.
43
+
44
+ For the developers' and authors' protection, the GPL clearly explains
45
+ that there is no warranty for this free software. For both users' and
46
+ authors' sake, the GPL requires that modified versions be marked as
47
+ changed, so that their problems will not be attributed erroneously to
48
+ authors of previous versions.
49
+
50
+ Some devices are designed to deny users access to install or run
51
+ modified versions of the software inside them, although the manufacturer
52
+ can do so. This is fundamentally incompatible with the aim of
53
+ protecting users' freedom to change the software. The systematic
54
+ pattern of such abuse occurs in the area of products for individuals to
55
+ use, which is precisely where it is most unacceptable. Therefore, we
56
+ have designed this version of the GPL to prohibit the practice for those
57
+ products. If such problems arise substantially in other domains, we
58
+ stand ready to extend this provision to those domains in future versions
59
+ of the GPL, as needed to protect the freedom of users.
60
+
61
+ Finally, every program is threatened constantly by software patents.
62
+ States should not allow patents to restrict development and use of
63
+ software on general-purpose computers, but in those that do, we wish to
64
+ avoid the special danger that patents applied to a free program could
65
+ make it effectively proprietary. To prevent this, the GPL assures that
66
+ patents cannot be used to render the program non-free.
67
+
68
+ The precise terms and conditions for copying, distribution and
69
+ modification follow.
70
+
71
+ TERMS AND CONDITIONS
72
+
73
+ 0. Definitions.
74
+
75
+ "This License" refers to version 3 of the GNU General Public License.
76
+
77
+ "Copyright" also means copyright-like laws that apply to other kinds of
78
+ works, such as semiconductor masks.
79
+
80
+ "The Program" refers to any copyrightable work licensed under this
81
+ License. Each licensee is addressed as "you". "Licensees" and
82
+ "recipients" may be individuals or organizations.
83
+
84
+ To "modify" a work means to copy from or adapt all or part of the work
85
+ in a fashion requiring copyright permission, other than the making of an
86
+ exact copy. The resulting work is called a "modified version" of the
87
+ earlier work or a work "based on" the earlier work.
88
+
89
+ A "covered work" means either the unmodified Program or a work based
90
+ on the Program.
91
+
92
+ To "propagate" a work means to do anything with it that, without
93
+ permission, would make you directly or secondarily liable for
94
+ infringement under applicable copyright law, except executing it on a
95
+ computer or modifying a private copy. Propagation includes copying,
96
+ distribution (with or without modification), making available to the
97
+ public, and in some countries other activities as well.
98
+
99
+ To "convey" a work means any kind of propagation that enables other
100
+ parties to make or receive copies. Mere interaction with a user through
101
+ a computer network, with no transfer of a copy, is not conveying.
102
+
103
+ An interactive user interface displays "Appropriate Legal Notices"
104
+ to the extent that it includes a convenient and prominently visible
105
+ feature that (1) displays an appropriate copyright notice, and (2)
106
+ tells the user that there is no warranty for the work (except to the
107
+ extent that warranties are provided), that licensees may convey the
108
+ work under this License, and how to view a copy of this License. If
109
+ the interface presents a list of user commands or options, such as a
110
+ menu, a prominent item in the list meets this criterion.
111
+
112
+ 1. Source Code.
113
+
114
+ The "source code" for a work means the preferred form of the work
115
+ for making modifications to it. "Object code" means any non-source
116
+ form of a work.
117
+
118
+ A "Standard Interface" means an interface that either is an official
119
+ standard defined by a recognized standards body, or, in the case of
120
+ interfaces specified for a particular programming language, one that
121
+ is widely used among developers working in that language.
122
+
123
+ The "System Libraries" of an executable work include anything, other
124
+ than the work as a whole, that (a) is included in the normal form of
125
+ packaging a Major Component, but which is not part of that Major
126
+ Component, and (b) serves only to enable use of the work with that
127
+ Major Component, or to implement a Standard Interface for which an
128
+ implementation is available to the public in source code form. A
129
+ "Major Component", in this context, means a major essential component
130
+ (kernel, window system, and so on) of the specific operating system
131
+ (if any) on which the executable work runs, or a compiler used to
132
+ produce the work, or an object code interpreter used to run it.
133
+
134
+ The "Corresponding Source" for a work in object code form means all
135
+ the source code needed to generate, install, and (for an executable
136
+ work) run the object code and to modify the work, including scripts to
137
+ control those activities. However, it does not include the work's
138
+ System Libraries, or general-purpose tools or generally available free
139
+ programs which are used unmodified in performing those activities but
140
+ which are not part of the work. For example, Corresponding Source
141
+ includes interface definition files associated with source files for
142
+ the work, and the source code for shared libraries and dynamically
143
+ linked subprograms that the work is specifically designed to require,
144
+ such as by intimate data communication or control flow between those
145
+ subprograms and other parts of the work.
146
+
147
+ The Corresponding Source need not include anything that users
148
+ can regenerate automatically from other parts of the Corresponding
149
+ Source.
150
+
151
+ The Corresponding Source for a work in source code form is that
152
+ same work.
153
+
154
+ 2. Basic Permissions.
155
+
156
+ All rights granted under this License are granted for the term of
157
+ copyright on the Program, and are irrevocable provided the stated
158
+ conditions are met. This License explicitly affirms your unlimited
159
+ permission to run the unmodified Program. The output from running a
160
+ covered work is covered by this License only if the output, given its
161
+ content, constitutes a covered work. This License acknowledges your
162
+ rights of fair use or other equivalent, as provided by copyright law.
163
+
164
+ You may make, run and propagate covered works that you do not
165
+ convey, without conditions so long as your license otherwise remains
166
+ in force. You may convey covered works to others for the sole purpose
167
+ of having them make modifications exclusively for you, or provide you
168
+ with facilities for running those works, provided that you comply with
169
+ the terms of this License in conveying all material for which you do
170
+ not control copyright. Those thus making or running the covered works
171
+ for you must do so exclusively on your behalf, under your direction
172
+ and control, on terms that prohibit them from making any copies of
173
+ your copyrighted material outside their relationship with you.
174
+
175
+ Conveying under any other circumstances is permitted solely under
176
+ the conditions stated below. Sublicensing is not allowed; section 10
177
+ makes it unnecessary.
178
+
179
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180
+
181
+ No covered work shall be deemed part of an effective technological
182
+ measure under any applicable law fulfilling obligations under article
183
+ 11 of the WIPO copyright treaty adopted on 20 December 1996, or
184
+ similar laws prohibiting or restricting circumvention of such
185
+ measures.
186
+
187
+ When you convey a covered work, you waive any legal power to forbid
188
+ circumvention of technological measures to the extent such circumvention
189
+ is effected by exercising rights under this License with respect to
190
+ the covered work, and you disclaim any intention to limit operation or
191
+ modification of the work as a means of enforcing, against the work's
192
+ users, your or third parties' legal rights to forbid circumvention of
193
+ technological measures.
194
+
195
+ 4. Conveying Verbatim Copies.
196
+
197
+ You may convey verbatim copies of the Program's source code as you
198
+ receive it, in any medium, provided that you conspicuously and
199
+ appropriately publish on each copy an appropriate copyright notice;
200
+ keep intact all notices stating that this License and any
201
+ non-permissive terms added in accord with section 7 apply to the code;
202
+ keep intact all notices of the absence of any warranty; and give all
203
+ recipients a copy of this License along with the Program.
204
+
205
+ You may charge any price or no price for each copy that you convey,
206
+ and you may offer support or warranty protection for a fee.
207
+
208
+ 5. Conveying Modified Source Versions.
209
+
210
+ You may convey a work based on the Program, or the modifications to
211
+ produce it from the Program, in the form of source code under the
212
+ terms of section 4, provided that you also meet all of these conditions:
213
+
214
+ a) The work must carry prominent notices stating that you modified
215
+ it, and giving a relevant date.
216
+
217
+ b) The work must carry prominent notices stating that it is
218
+ released under this License and any conditions added under section
219
+ 7. This requirement modifies the requirement in section 4 to
220
+ "keep intact all notices".
221
+
222
+ c) You must license the entire work, as a whole, under this
223
+ License to anyone who comes into possession of a copy. This
224
+ License will therefore apply, along with any applicable section 7
225
+ additional terms, to the whole of the work, and all its parts,
226
+ regardless of how they are packaged. This License gives no
227
+ permission to license the work in any other way, but it does not
228
+ invalidate such permission if you have separately received it.
229
+
230
+ d) If the work has interactive user interfaces, each must display
231
+ Appropriate Legal Notices; however, if the Program has interactive
232
+ interfaces that do not display Appropriate Legal Notices, your
233
+ work need not make them do so.
234
+
235
+ A compilation of a covered work with other separate and independent
236
+ works, which are not by their nature extensions of the covered work,
237
+ and which are not combined with it such as to form a larger program,
238
+ in or on a volume of a storage or distribution medium, is called an
239
+ "aggregate" if the compilation and its resulting copyright are not
240
+ used to limit the access or legal rights of the compilation's users
241
+ beyond what the individual works permit. Inclusion of a covered work
242
+ in an aggregate does not cause this License to apply to the other
243
+ parts of the aggregate.
244
+
245
+ 6. Conveying Non-Source Forms.
246
+
247
+ You may convey a covered work in object code form under the terms
248
+ of sections 4 and 5, provided that you also convey the
249
+ machine-readable Corresponding Source under the terms of this License,
250
+ in one of these ways:
251
+
252
+ a) Convey the object code in, or embodied in, a physical product
253
+ (including a physical distribution medium), accompanied by the
254
+ Corresponding Source fixed on a durable physical medium
255
+ customarily used for software interchange.
256
+
257
+ b) Convey the object code in, or embodied in, a physical product
258
+ (including a physical distribution medium), accompanied by a
259
+ written offer, valid for at least three years and valid for as
260
+ long as you offer spare parts or customer support for that product
261
+ model, to give anyone who possesses the object code either (1) a
262
+ copy of the Corresponding Source for all the software in the
263
+ product that is covered by this License, on a durable physical
264
+ medium customarily used for software interchange, for a price no
265
+ more than your reasonable cost of physically performing this
266
+ conveying of source, or (2) access to copy the
267
+ Corresponding Source from a network server at no charge.
268
+
269
+ c) Convey individual copies of the object code with a copy of the
270
+ written offer to provide the Corresponding Source. This
271
+ alternative is allowed only occasionally and noncommercially, and
272
+ only if you received the object code with such an offer, in accord
273
+ with subsection 6b.
274
+
275
+ d) Convey the object code by offering access from a designated
276
+ place (gratis or for a charge), and offer equivalent access to the
277
+ Corresponding Source in the same way through the same place at no
278
+ further charge. You need not require recipients to copy the
279
+ Corresponding Source along with the object code. If the place to
280
+ copy the object code is a network server, the Corresponding Source
281
+ may be on a different server (operated by you or a third party)
282
+ that supports equivalent copying facilities, provided you maintain
283
+ clear directions next to the object code saying where to find the
284
+ Corresponding Source. Regardless of what server hosts the
285
+ Corresponding Source, you remain obligated to ensure that it is
286
+ available for as long as needed to satisfy these requirements.
287
+
288
+ e) Convey the object code using peer-to-peer transmission, provided
289
+ you inform other peers where the object code and Corresponding
290
+ Source of the work are being offered to the general public at no
291
+ charge under subsection 6d.
292
+
293
+ A separable portion of the object code, whose source code is excluded
294
+ from the Corresponding Source as a System Library, need not be
295
+ included in conveying the object code work.
296
+
297
+ A "User Product" is either (1) a "consumer product", which means any
298
+ tangible personal property which is normally used for personal, family,
299
+ or household purposes, or (2) anything designed or sold for incorporation
300
+ into a dwelling. In determining whether a product is a consumer product,
301
+ doubtful cases shall be resolved in favor of coverage. For a particular
302
+ product received by a particular user, "normally used" refers to a
303
+ typical or common use of that class of product, regardless of the status
304
+ of the particular user or of the way in which the particular user
305
+ actually uses, or expects or is expected to use, the product. A product
306
+ is a consumer product regardless of whether the product has substantial
307
+ commercial, industrial or non-consumer uses, unless such uses represent
308
+ the only significant mode of use of the product.
309
+
310
+ "Installation Information" for a User Product means any methods,
311
+ procedures, authorization keys, or other information required to install
312
+ and execute modified versions of a covered work in that User Product from
313
+ a modified version of its Corresponding Source. The information must
314
+ suffice to ensure that the continued functioning of the modified object
315
+ code is in no case prevented or interfered with solely because
316
+ modification has been made.
317
+
318
+ If you convey an object code work under this section in, or with, or
319
+ specifically for use in, a User Product, and the conveying occurs as
320
+ part of a transaction in which the right of possession and use of the
321
+ User Product is transferred to the recipient in perpetuity or for a
322
+ fixed term (regardless of how the transaction is characterized), the
323
+ Corresponding Source conveyed under this section must be accompanied
324
+ by the Installation Information. But this requirement does not apply
325
+ if neither you nor any third party retains the ability to install
326
+ modified object code on the User Product (for example, the work has
327
+ been installed in ROM).
328
+
329
+ The requirement to provide Installation Information does not include a
330
+ requirement to continue to provide support service, warranty, or updates
331
+ for a work that has been modified or installed by the recipient, or for
332
+ the User Product in which it has been modified or installed. Access to a
333
+ network may be denied when the modification itself materially and
334
+ adversely affects the operation of the network or violates the rules and
335
+ protocols for communication across the network.
336
+
337
+ Corresponding Source conveyed, and Installation Information provided,
338
+ in accord with this section must be in a format that is publicly
339
+ documented (and with an implementation available to the public in
340
+ source code form), and must require no special password or key for
341
+ unpacking, reading or copying.
342
+
343
+ 7. Additional Terms.
344
+
345
+ "Additional permissions" are terms that supplement the terms of this
346
+ License by making exceptions from one or more of its conditions.
347
+ Additional permissions that are applicable to the entire Program shall
348
+ be treated as though they were included in this License, to the extent
349
+ that they are valid under applicable law. If additional permissions
350
+ apply only to part of the Program, that part may be used separately
351
+ under those permissions, but the entire Program remains governed by
352
+ this License without regard to the additional permissions.
353
+
354
+ When you convey a copy of a covered work, you may at your option
355
+ remove any additional permissions from that copy, or from any part of
356
+ it. (Additional permissions may be written to require their own
357
+ removal in certain cases when you modify the work.) You may place
358
+ additional permissions on material, added by you to a covered work,
359
+ for which you have or can give appropriate copyright permission.
360
+
361
+ Notwithstanding any other provision of this License, for material you
362
+ add to a covered work, you may (if authorized by the copyright holders of
363
+ that material) supplement the terms of this License with terms:
364
+
365
+ a) Disclaiming warranty or limiting liability differently from the
366
+ terms of sections 15 and 16 of this License; or
367
+
368
+ b) Requiring preservation of specified reasonable legal notices or
369
+ author attributions in that material or in the Appropriate Legal
370
+ Notices displayed by works containing it; or
371
+
372
+ c) Prohibiting misrepresentation of the origin of that material, or
373
+ requiring that modified versions of such material be marked in
374
+ reasonable ways as different from the original version; or
375
+
376
+ d) Limiting the use for publicity purposes of names of licensors or
377
+ authors of the material; or
378
+
379
+ e) Declining to grant rights under trademark law for use of some
380
+ trade names, trademarks, or service marks; or
381
+
382
+ f) Requiring indemnification of licensors and authors of that
383
+ material by anyone who conveys the material (or modified versions of
384
+ it) with contractual assumptions of liability to the recipient, for
385
+ any liability that these contractual assumptions directly impose on
386
+ those licensors and authors.
387
+
388
+ All other non-permissive additional terms are considered "further
389
+ restrictions" within the meaning of section 10. If the Program as you
390
+ received it, or any part of it, contains a notice stating that it is
391
+ governed by this License along with a term that is a further
392
+ restriction, you may remove that term. If a license document contains
393
+ a further restriction but permits relicensing or conveying under this
394
+ License, you may add to a covered work material governed by the terms
395
+ of that license document, provided that the further restriction does
396
+ not survive such relicensing or conveying.
397
+
398
+ If you add terms to a covered work in accord with this section, you
399
+ must place, in the relevant source files, a statement of the
400
+ additional terms that apply to those files, or a notice indicating
401
+ where to find the applicable terms.
402
+
403
+ Additional terms, permissive or non-permissive, may be stated in the
404
+ form of a separately written license, or stated as exceptions;
405
+ the above requirements apply either way.
406
+
407
+ 8. Termination.
408
+
409
+ You may not propagate or modify a covered work except as expressly
410
+ provided under this License. Any attempt otherwise to propagate or
411
+ modify it is void, and will automatically terminate your rights under
412
+ this License (including any patent licenses granted under the third
413
+ paragraph of section 11).
414
+
415
+ However, if you cease all violation of this License, then your
416
+ license from a particular copyright holder is reinstated (a)
417
+ provisionally, unless and until the copyright holder explicitly and
418
+ finally terminates your license, and (b) permanently, if the copyright
419
+ holder fails to notify you of the violation by some reasonable means
420
+ prior to 60 days after the cessation.
421
+
422
+ Moreover, your license from a particular copyright holder is
423
+ reinstated permanently if the copyright holder notifies you of the
424
+ violation by some reasonable means, this is the first time you have
425
+ received notice of violation of this License (for any work) from that
426
+ copyright holder, and you cure the violation prior to 30 days after
427
+ your receipt of the notice.
428
+
429
+ Termination of your rights under this section does not terminate the
430
+ licenses of parties who have received copies or rights from you under
431
+ this License. If your rights have been terminated and not permanently
432
+ reinstated, you do not qualify to receive new licenses for the same
433
+ material under section 10.
434
+
435
+ 9. Acceptance Not Required for Having Copies.
436
+
437
+ You are not required to accept this License in order to receive or
438
+ run a copy of the Program. Ancillary propagation of a covered work
439
+ occurring solely as a consequence of using peer-to-peer transmission
440
+ to receive a copy likewise does not require acceptance. However,
441
+ nothing other than this License grants you permission to propagate or
442
+ modify any covered work. These actions infringe copyright if you do
443
+ not accept this License. Therefore, by modifying or propagating a
444
+ covered work, you indicate your acceptance of this License to do so.
445
+
446
+ 10. Automatic Licensing of Downstream Recipients.
447
+
448
+ Each time you convey a covered work, the recipient automatically
449
+ receives a license from the original licensors, to run, modify and
450
+ propagate that work, subject to this License. You are not responsible
451
+ for enforcing compliance by third parties with this License.
452
+
453
+ An "entity transaction" is a transaction transferring control of an
454
+ organization, or substantially all assets of one, or subdividing an
455
+ organization, or merging organizations. If propagation of a covered
456
+ work results from an entity transaction, each party to that
457
+ transaction who receives a copy of the work also receives whatever
458
+ licenses to the work the party's predecessor in interest had or could
459
+ give under the previous paragraph, plus a right to possession of the
460
+ Corresponding Source of the work from the predecessor in interest, if
461
+ the predecessor has it or can get it with reasonable efforts.
462
+
463
+ You may not impose any further restrictions on the exercise of the
464
+ rights granted or affirmed under this License. For example, you may
465
+ not impose a license fee, royalty, or other charge for exercise of
466
+ rights granted under this License, and you may not initiate litigation
467
+ (including a cross-claim or counterclaim in a lawsuit) alleging that
468
+ any patent claim is infringed by making, using, selling, offering for
469
+ sale, or importing the Program or any portion of it.
470
+
471
+ 11. Patents.
472
+
473
+ A "contributor" is a copyright holder who authorizes use under this
474
+ License of the Program or a work on which the Program is based. The
475
+ work thus licensed is called the contributor's "contributor version".
476
+
477
+ A contributor's "essential patent claims" are all patent claims
478
+ owned or controlled by the contributor, whether already acquired or
479
+ hereafter acquired, that would be infringed by some manner, permitted
480
+ by this License, of making, using, or selling its contributor version,
481
+ but do not include claims that would be infringed only as a
482
+ consequence of further modification of the contributor version. For
483
+ purposes of this definition, "control" includes the right to grant
484
+ patent sublicenses in a manner consistent with the requirements of
485
+ this License.
486
+
487
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
488
+ patent license under the contributor's essential patent claims, to
489
+ make, use, sell, offer for sale, import and otherwise run, modify and
490
+ propagate the contents of its contributor version.
491
+
492
+ In the following three paragraphs, a "patent license" is any express
493
+ agreement or commitment, however denominated, not to enforce a patent
494
+ (such as an express permission to practice a patent or covenant not to
495
+ sue for patent infringement). To "grant" such a patent license to a
496
+ party means to make such an agreement or commitment not to enforce a
497
+ patent against the party.
498
+
499
+ If you convey a covered work, knowingly relying on a patent license,
500
+ and the Corresponding Source of the work is not available for anyone
501
+ to copy, free of charge and under the terms of this License, through a
502
+ publicly available network server or other readily accessible means,
503
+ then you must either (1) cause the Corresponding Source to be so
504
+ available, or (2) arrange to deprive yourself of the benefit of the
505
+ patent license for this particular work, or (3) arrange, in a manner
506
+ consistent with the requirements of this License, to extend the patent
507
+ license to downstream recipients. "Knowingly relying" means you have
508
+ actual knowledge that, but for the patent license, your conveying the
509
+ covered work in a country, or your recipient's use of the covered work
510
+ in a country, would infringe one or more identifiable patents in that
511
+ country that you have reason to believe are valid.
512
+
513
+ If, pursuant to or in connection with a single transaction or
514
+ arrangement, you convey, or propagate by procuring conveyance of, a
515
+ covered work, and grant a patent license to some of the parties
516
+ receiving the covered work authorizing them to use, propagate, modify
517
+ or convey a specific copy of the covered work, then the patent license
518
+ you grant is automatically extended to all recipients of the covered
519
+ work and works based on it.
520
+
521
+ A patent license is "discriminatory" if it does not include within
522
+ the scope of its coverage, prohibits the exercise of, or is
523
+ conditioned on the non-exercise of one or more of the rights that are
524
+ specifically granted under this License. You may not convey a covered
525
+ work if you are a party to an arrangement with a third party that is
526
+ in the business of distributing software, under which you make payment
527
+ to the third party based on the extent of your activity of conveying
528
+ the work, and under which the third party grants, to any of the
529
+ parties who would receive the covered work from you, a discriminatory
530
+ patent license (a) in connection with copies of the covered work
531
+ conveyed by you (or copies made from those copies), or (b) primarily
532
+ for and in connection with specific products or compilations that
533
+ contain the covered work, unless you entered into that arrangement,
534
+ or that patent license was granted, prior to 28 March 2007.
535
+
536
+ Nothing in this License shall be construed as excluding or limiting
537
+ any implied license or other defenses to infringement that may
538
+ otherwise be available to you under applicable patent law.
539
+
540
+ 12. No Surrender of Others' Freedom.
541
+
542
+ If conditions are imposed on you (whether by court order, agreement or
543
+ otherwise) that contradict the conditions of this License, they do not
544
+ excuse you from the conditions of this License. If you cannot convey a
545
+ covered work so as to satisfy simultaneously your obligations under this
546
+ License and any other pertinent obligations, then as a consequence you may
547
+ not convey it at all. For example, if you agree to terms that obligate you
548
+ to collect a royalty for further conveying from those to whom you convey
549
+ the Program, the only way you could satisfy both those terms and this
550
+ License would be to refrain entirely from conveying the Program.
551
+
552
+ 13. Use with the GNU Affero General Public License.
553
+
554
+ Notwithstanding any other provision of this License, you have
555
+ permission to link or combine any covered work with a work licensed
556
+ under version 3 of the GNU Affero General Public License into a single
557
+ combined work, and to convey the resulting work. The terms of this
558
+ License will continue to apply to the part which is the covered work,
559
+ but the special requirements of the GNU Affero General Public License,
560
+ section 13, concerning interaction through a network will apply to the
561
+ combination as such.
562
+
563
+ 14. Revised Versions of this License.
564
+
565
+ The Free Software Foundation may publish revised and/or new versions of
566
+ the GNU General Public License from time to time. Such new versions will
567
+ be similar in spirit to the present version, but may differ in detail to
568
+ address new problems or concerns.
569
+
570
+ Each version is given a distinguishing version number. If the
571
+ Program specifies that a certain numbered version of the GNU General
572
+ Public License "or any later version" applies to it, you have the
573
+ option of following the terms and conditions either of that numbered
574
+ version or of any later version published by the Free Software
575
+ Foundation. If the Program does not specify a version number of the
576
+ GNU General Public License, you may choose any version ever published
577
+ by the Free Software Foundation.
578
+
579
+ If the Program specifies that a proxy can decide which future
580
+ versions of the GNU General Public License can be used, that proxy's
581
+ public statement of acceptance of a version permanently authorizes you
582
+ to choose that version for the Program.
583
+
584
+ Later license versions may give you additional or different
585
+ permissions. However, no additional obligations are imposed on any
586
+ author or copyright holder as a result of your choosing to follow a
587
+ later version.
588
+
589
+ 15. Disclaimer of Warranty.
590
+
591
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592
+ APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593
+ HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594
+ OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595
+ THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596
+ PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597
+ IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598
+ ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599
+
600
+ 16. Limitation of Liability.
601
+
602
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603
+ WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604
+ THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605
+ GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606
+ USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607
+ DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608
+ PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609
+ EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610
+ SUCH DAMAGES.
611
+
612
+ 17. Interpretation of Sections 15 and 16.
613
+
614
+ If the disclaimer of warranty and limitation of liability provided
615
+ above cannot be given local legal effect according to their terms,
616
+ reviewing courts shall apply local law that most closely approximates
617
+ an absolute waiver of all civil liability in connection with the
618
+ Program, unless a warranty or assumption of liability accompanies a
619
+ copy of the Program in return for a fee.
620
+
621
+ END OF TERMS AND CONDITIONS
622
+
623
+ How to Apply These Terms to Your New Programs
624
+
625
+ If you develop a new program, and you want it to be of the greatest
626
+ possible use to the public, the best way to achieve this is to make it
627
+ free software which everyone can redistribute and change under these terms.
628
+
629
+ To do so, attach the following notices to the program. It is safest
630
+ to attach them to the start of each source file to most effectively
631
+ state the exclusion of warranty; and each file should have at least
632
+ the "copyright" line and a pointer to where the full notice is found.
633
+
634
+ <one line to give the program's name and a brief idea of what it does.>
635
+ Copyright (C) <year> <name of author>
636
+
637
+ This program is free software: you can redistribute it and/or modify
638
+ it under the terms of the GNU General Public License as published by
639
+ the Free Software Foundation, either version 3 of the License, or
640
+ (at your option) any later version.
641
+
642
+ This program is distributed in the hope that it will be useful,
643
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
644
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645
+ GNU General Public License for more details.
646
+
647
+ You should have received a copy of the GNU General Public License
648
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
649
+
650
+ Also add information on how to contact you by electronic and paper mail.
651
+
652
+ If the program does terminal interaction, make it output a short
653
+ notice like this when it starts in an interactive mode:
654
+
655
+ <program> Copyright (C) <year> <name of author>
656
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657
+ This is free software, and you are welcome to redistribute it
658
+ under certain conditions; type `show c' for details.
659
+
660
+ The hypothetical commands `show w' and `show c' should show the appropriate
661
+ parts of the General Public License. Of course, your program's commands
662
+ might be different; for a GUI interface, you would use an "about box".
663
+
664
+ You should also get your employer (if you work as a programmer) or school,
665
+ if any, to sign a "copyright disclaimer" for the program, if necessary.
666
+ For more information on this, and how to apply and follow the GNU GPL, see
667
+ <https://www.gnu.org/licenses/>.
668
+
669
+ The GNU General Public License does not permit incorporating your program
670
+ into proprietary programs. If your program is a subroutine library, you
671
+ may consider it more useful to permit linking proprietary applications with
672
+ the library. If this is what you want to do, use the GNU Lesser General
673
+ Public License instead of this License. But first, please read
674
+ <https://www.gnu.org/licenses/why-not-lgpl.html>.
src/rotator_library/COPYING.LESSER ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ GNU LESSER GENERAL PUBLIC LICENSE
2
+ Version 3, 29 June 2007
3
+
4
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
5
+ Everyone is permitted to copy and distribute verbatim copies
6
+ of this license document, but changing it is not allowed.
7
+
8
+
9
+ This version of the GNU Lesser General Public License incorporates
10
+ the terms and conditions of version 3 of the GNU General Public
11
+ License, supplemented by the additional permissions listed below.
12
+
13
+ 0. Additional Definitions.
14
+
15
+ As used herein, "this License" refers to version 3 of the GNU Lesser
16
+ General Public License, and the "GNU GPL" refers to version 3 of the GNU
17
+ General Public License.
18
+
19
+ "The Library" refers to a covered work governed by this License,
20
+ other than an Application or a Combined Work as defined below.
21
+
22
+ An "Application" is any work that makes use of an interface provided
23
+ by the Library, but which is not otherwise based on the Library.
24
+ Defining a subclass of a class defined by the Library is deemed a mode
25
+ of using an interface provided by the Library.
26
+
27
+ A "Combined Work" is a work produced by combining or linking an
28
+ Application with the Library. The particular version of the Library
29
+ with which the Combined Work was made is also called the "Linked
30
+ Version".
31
+
32
+ The "Minimal Corresponding Source" for a Combined Work means the
33
+ Corresponding Source for the Combined Work, excluding any source code
34
+ for portions of the Combined Work that, considered in isolation, are
35
+ based on the Application, and not on the Linked Version.
36
+
37
+ The "Corresponding Application Code" for a Combined Work means the
38
+ object code and/or source code for the Application, including any data
39
+ and utility programs needed for reproducing the Combined Work from the
40
+ Application, but excluding the System Libraries of the Combined Work.
41
+
42
+ 1. Exception to Section 3 of the GNU GPL.
43
+
44
+ You may convey a covered work under sections 3 and 4 of this License
45
+ without being bound by section 3 of the GNU GPL.
46
+
47
+ 2. Conveying Modified Versions.
48
+
49
+ If you modify a copy of the Library, and, in your modifications, a
50
+ facility refers to a function or data to be supplied by an Application
51
+ that uses the facility (other than as an argument passed when the
52
+ facility is invoked), then you may convey a copy of the modified
53
+ version:
54
+
55
+ a) under this License, provided that you make a good faith effort to
56
+ ensure that, in the event an Application does not supply the
57
+ function or data, the facility still operates, and performs
58
+ whatever part of its purpose remains meaningful, or
59
+
60
+ b) under the GNU GPL, with none of the additional permissions of
61
+ this License applicable to that copy.
62
+
63
+ 3. Object Code Incorporating Material from Library Header Files.
64
+
65
+ The object code form of an Application may incorporate material from
66
+ a header file that is part of the Library. You may convey such object
67
+ code under terms of your choice, provided that, if the incorporated
68
+ material is not limited to numerical parameters, data structure
69
+ layouts and accessors, or small macros, inline functions and templates
70
+ (ten or fewer lines in length), you do both of the following:
71
+
72
+ a) Give prominent notice with each copy of the object code that the
73
+ Library is used in it and that the Library and its use are
74
+ covered by this License.
75
+
76
+ b) Accompany the object code with a copy of the GNU GPL and this license
77
+ document.
78
+
79
+ 4. Combined Works.
80
+
81
+ You may convey a Combined Work under terms of your choice that,
82
+ taken together, effectively do not restrict modification of the
83
+ portions of the Library contained in the Combined Work and reverse
84
+ engineering for debugging such modifications, if you also do each of
85
+ the following:
86
+
87
+ a) Give prominent notice with each copy of the Combined Work that
88
+ the Library is used in it and that the Library and its use are
89
+ covered by this License.
90
+
91
+ b) Accompany the Combined Work with a copy of the GNU GPL and this license
92
+ document.
93
+
94
+ c) For a Combined Work that displays copyright notices during
95
+ execution, include the copyright notice for the Library among
96
+ these notices, as well as a reference directing the user to the
97
+ copies of the GNU GPL and this license document.
98
+
99
+ d) Do one of the following:
100
+
101
+ 0) Convey the Minimal Corresponding Source under the terms of this
102
+ License, and the Corresponding Application Code in a form
103
+ suitable for, and under terms that permit, the user to
104
+ recombine or relink the Application with a modified version of
105
+ the Linked Version to produce a modified Combined Work, in the
106
+ manner specified by section 6 of the GNU GPL for conveying
107
+ Corresponding Source.
108
+
109
+ 1) Use a suitable shared library mechanism for linking with the
110
+ Library. A suitable mechanism is one that (a) uses at run time
111
+ a copy of the Library already present on the user's computer
112
+ system, and (b) will operate properly with a modified version
113
+ of the Library that is interface-compatible with the Linked
114
+ Version.
115
+
116
+ e) Provide Installation Information, but only if you would otherwise
117
+ be required to provide such information under section 6 of the
118
+ GNU GPL, and only to the extent that such information is
119
+ necessary to install and execute a modified version of the
120
+ Combined Work produced by recombining or relinking the
121
+ Application with a modified version of the Linked Version. (If
122
+ you use option 4d0, the Installation Information must accompany
123
+ the Minimal Corresponding Source and Corresponding Application
124
+ Code. If you use option 4d1, you must provide the Installation
125
+ Information in the manner specified by section 6 of the GNU GPL
126
+ for conveying Corresponding Source.)
127
+
128
+ 5. Combined Libraries.
129
+
130
+ You may place library facilities that are a work based on the
131
+ Library side by side in a single library together with other library
132
+ facilities that are not Applications and are not covered by this
133
+ License, and convey such a combined library under terms of your
134
+ choice, if you do both of the following:
135
+
136
+ a) Accompany the combined library with a copy of the same work based
137
+ on the Library, uncombined with any other library facilities,
138
+ conveyed under the terms of this License.
139
+
140
+ b) Give prominent notice with the combined library that part of it
141
+ is a work based on the Library, and explaining where to find the
142
+ accompanying uncombined form of the same work.
143
+
144
+ 6. Revised Versions of the GNU Lesser General Public License.
145
+
146
+ The Free Software Foundation may publish revised and/or new versions
147
+ of the GNU Lesser General Public License from time to time. Such new
148
+ versions will be similar in spirit to the present version, but may
149
+ differ in detail to address new problems or concerns.
150
+
151
+ Each version is given a distinguishing version number. If the
152
+ Library as you received it specifies that a certain numbered version
153
+ of the GNU Lesser General Public License "or any later version"
154
+ applies to it, you have the option of following the terms and
155
+ conditions either of that published version or of any later version
156
+ published by the Free Software Foundation. If the Library as you
157
+ received it does not specify a version number of the GNU Lesser
158
+ General Public License, you may choose any version of the GNU Lesser
159
+ General Public License ever published by the Free Software Foundation.
160
+
161
+ If the Library as you received it specifies that a proxy can decide
162
+ whether future versions of the GNU Lesser General Public License shall
163
+ apply, that proxy's public statement of acceptance of any version is
164
+ permanent authorization for you to choose that version for the
165
+ Library.
src/rotator_library/README.md ADDED
@@ -0,0 +1,345 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Resilience & API Key Management Library
2
+
3
+ A robust, asynchronous, and thread-safe Python library for managing a pool of API keys. It is designed to be integrated into applications (such as the Universal LLM API Proxy included in this project) to provide a powerful layer of resilience and high availability when interacting with multiple LLM providers.
4
+
5
+ ## Key Features
6
+
7
+ - **Asynchronous by Design**: Built with `asyncio` and `httpx` for high-performance, non-blocking I/O.
8
+ - **Anthropic API Compatibility**: Built-in translation layer (`anthropic_compat`) enables Anthropic API clients (like Claude Code) to use any supported provider.
9
+ - **Advanced Concurrency Control**: A single API key can be used for multiple concurrent requests. By default, it supports concurrent requests to *different* models. With configuration (`MAX_CONCURRENT_REQUESTS_PER_KEY_<PROVIDER>`), it can also support multiple concurrent requests to the *same* model using the same key.
10
+ - **Smart Key Management**: Selects the optimal key for each request using a tiered, model-aware locking strategy to distribute load evenly and maximize availability.
11
+ - **Configurable Rotation Strategy**: Choose between deterministic least-used selection (perfect balance) or default weighted random selection (unpredictable, harder to fingerprint).
12
+ - **Deadline-Driven Requests**: A global timeout ensures that no request, including all retries and key selections, exceeds a specified time limit.
13
+ - **OAuth & API Key Support**: Built-in support for standard API keys and complex OAuth flows.
14
+ - **Gemini CLI**: Full OAuth 2.0 web flow with automatic project discovery, free-tier onboarding, and credential prioritization (paid vs free tier).
15
+ - **Antigravity**: Full OAuth 2.0 support for Gemini 3, Gemini 2.5, and Claude Sonnet 4.5 models with thought signature caching(Full support for Gemini 3 and Claude models). **First on the scene to provide full support for Gemini 3** via Antigravity with advanced features like thought signature caching and tool hallucination prevention.
16
+ - **Qwen Code**: Device Code flow support.
17
+ - **iFlow**: Authorization Code flow with local callback handling.
18
+ - **Stateless Deployment Ready**: Can load complex OAuth credentials from environment variables, eliminating the need for physical credential files in containerized environments.
19
+ - **Intelligent Error Handling**:
20
+ - **Escalating Per-Model Cooldowns**: Failed keys are placed on a temporary, escalating cooldown for specific models.
21
+ - **Key-Level Lockouts**: Keys failing across multiple models are temporarily removed from rotation.
22
+ - **Stream Recovery**: The client detects mid-stream errors (like quota limits) and gracefully handles them.
23
+ - **Credential Prioritization**: Automatic tier detection and priority-based credential selection (e.g., paid tier credentials used first for models that require them).
24
+ - **Advanced Model Requirements**: Support for model-tier restrictions (e.g., Gemini 3 requires paid-tier credentials).
25
+ - **Robust Streaming Support**: Includes a wrapper for streaming responses that reassembles fragmented JSON chunks.
26
+ - **Detailed Usage Tracking**: Tracks daily and global usage for each key, persisted to a JSON file.
27
+ - **Automatic Daily Resets**: Automatically resets cooldowns and archives stats daily.
28
+ - **Provider Agnostic**: Works with any provider supported by `litellm`.
29
+ - **Extensible**: Easily add support for new providers through a simple plugin-based architecture.
30
+ - **Temperature Override**: Global temperature=0 override to prevent tool hallucination with low-temperature settings.
31
+ - **Shared OAuth Base**: Refactored OAuth implementation with reusable [`GoogleOAuthBase`](providers/google_oauth_base.py) for multiple providers.
32
+ - **Fair Cycle Rotation**: Ensures each credential exhausts at least once before any can be reused within a tier. Prevents a single credential from being repeatedly used while others sit idle. Configurable per provider with tracking modes and cross-tier support.
33
+ - **Custom Usage Caps**: Set custom limits per tier, per model/group that are more restrictive than actual API limits. Supports percentages (e.g., "80%") and multiple cooldown modes (`quota_reset`, `offset`, `fixed`). Credentials go on cooldown before hitting actual API limits.
34
+ - **Centralized Defaults**: All tunable defaults are defined in [`config/defaults.py`](config/defaults.py) for easy customization and visibility.
35
+
36
+ ## Installation
37
+
38
+ To install the library, you can install it directly from a local path. Using the `-e` flag installs it in "editable" mode, which is recommended for development.
39
+
40
+ ```bash
41
+ pip install -e .
42
+ ```
43
+
44
+ ## `RotatingClient` Class
45
+
46
+ This is the main class for interacting with the library. It is designed to be a long-lived object that manages the state of your API key pool.
47
+
48
+ ### Initialization
49
+
50
+ ```python
51
+ import os
52
+ from dotenv import load_dotenv
53
+ from rotator_library import RotatingClient
54
+
55
+ # Load environment variables from .env file
56
+ load_dotenv()
57
+
58
+ # Dynamically load all provider API keys from environment variables
59
+ api_keys = {}
60
+ for key, value in os.environ.items():
61
+ # This pattern finds keys like "GEMINI_API_KEY_1" or "OPENAI_API_KEY"
62
+ if (key.endswith("_API_KEY") or "_API_KEY_" in key) and key != "PROXY_API_KEY":
63
+ # Extracts "gemini" from "GEMINI_API_KEY_1"
64
+ provider = key.split("_API_KEY")[0].lower()
65
+ if provider not in api_keys:
66
+ api_keys[provider] = []
67
+ api_keys[provider].append(value)
68
+
69
+ # Initialize empty dictionary for OAuth credentials (or load from CredentialManager)
70
+ oauth_credentials = {}
71
+
72
+ client = RotatingClient(
73
+ api_keys=api_keys,
74
+ oauth_credentials=oauth_credentials,
75
+ max_retries=2,
76
+ usage_file_path="key_usage.json",
77
+ configure_logging=True,
78
+ global_timeout=30,
79
+ abort_on_callback_error=True,
80
+ litellm_provider_params={},
81
+ ignore_models={},
82
+ whitelist_models={},
83
+ enable_request_logging=False,
84
+ max_concurrent_requests_per_key={},
85
+ rotation_tolerance=2.0 # 0.0=deterministic, 2.0=recommended random
86
+ )
87
+ ```
88
+
89
+ #### Arguments
90
+
91
+ - `api_keys` (`Optional[Dict[str, List[str]]]`): A dictionary mapping provider names (e.g., "openai", "anthropic") to a list of API keys.
92
+ - `oauth_credentials` (`Optional[Dict[str, List[str]]]`): A dictionary mapping provider names (e.g., "gemini_cli", "qwen_code") to a list of file paths to OAuth credential JSON files.
93
+ - `max_retries` (`int`, default: `2`): The number of times to retry a request with the *same key* if a transient server error (e.g., 500, 503) occurs.
94
+ - `usage_file_path` (`str`, default: `"key_usage.json"`): The path to the JSON file where usage statistics (tokens, cost, success counts) are persisted.
95
+ - `configure_logging` (`bool`, default: `True`): If `True`, configures the library's logger to propagate logs to the root logger. Set to `False` if you want to handle logging configuration manually.
96
+ - `global_timeout` (`int`, default: `30`): A hard time limit (in seconds) for the entire request lifecycle. If the request (including all retries) takes longer than this, it is aborted.
97
+ - `abort_on_callback_error` (`bool`, default: `True`): If `True`, any exception raised by `pre_request_callback` will abort the request. If `False`, the error is logged and the request proceeds.
98
+ - `litellm_provider_params` (`Optional[Dict[str, Any]]`, default: `None`): A dictionary of extra parameters to pass to `litellm` for specific providers.
99
+ - `ignore_models` (`Optional[Dict[str, List[str]]]`, default: `None`): A dictionary where keys are provider names and values are lists of model names/patterns to exclude (blacklist). Supports wildcards (e.g., `"*-preview"`).
100
+ - `whitelist_models` (`Optional[Dict[str, List[str]]]`, default: `None`): A dictionary where keys are provider names and values are lists of model names/patterns to always include, overriding `ignore_models`.
101
+ - `enable_request_logging` (`bool`, default: `False`): If `True`, enables detailed per-request file logging (useful for debugging complex interactions).
102
+ - `max_concurrent_requests_per_key` (`Optional[Dict[str, int]]`, default: `None`): A dictionary defining the maximum number of concurrent requests allowed for a single API key for a specific provider. Defaults to 1 if not specified.
103
+ - `rotation_tolerance` (`float`, default: `0.0`): Controls credential rotation strategy:
104
+ - `0.0`: **Deterministic** - Always selects the least-used credential for perfect load balance.
105
+ - `2.0` (default, recommended): **Weighted Random** - Randomly selects credentials with bias toward less-used ones. Provides unpredictability (harder to fingerprint) while maintaining good balance.
106
+ - `5.0+`: **High Randomness** - Even heavily-used credentials have significant selection probability. Maximum unpredictability.
107
+
108
+ The weight formula is: `weight = (max_usage - credential_usage) + tolerance + 1`
109
+
110
+ **Use Cases:**
111
+ - `0.0`: When perfect load balance is critical
112
+ - `2.0`: When avoiding fingerprinting/rate limit detection is important
113
+ - `5.0+`: For stress testing or maximum unpredictability
114
+
115
+ ### Concurrency and Resource Management
116
+
117
+ The `RotatingClient` is asynchronous and manages an `httpx.AsyncClient` internally. It's crucial to close the client properly to release resources. The recommended way is to use an `async with` block.
118
+
119
+ ```python
120
+ import asyncio
121
+
122
+ async def main():
123
+ async with RotatingClient(api_keys=api_keys) as client:
124
+ # ... use the client ...
125
+ response = await client.acompletion(
126
+ model="gemini/gemini-1.5-flash",
127
+ messages=[{"role": "user", "content": "Hello!"}]
128
+ )
129
+ print(response)
130
+
131
+ asyncio.run(main())
132
+ ```
133
+
134
+ ### Methods
135
+
136
+ #### `async def acompletion(self, **kwargs) -> Any:`
137
+
138
+ This is the primary method for making API calls. It's a wrapper around `litellm.acompletion` that adds the core logic for key acquisition, selection, and retries.
139
+
140
+ - **Parameters**: Accepts the same keyword arguments as `litellm.acompletion`. The `model` parameter is required and must be a string in the format `provider/model_name`.
141
+ - **Returns**:
142
+ - For non-streaming requests, it returns the `litellm` response object.
143
+ - For streaming requests, it returns an async generator that yields OpenAI-compatible Server-Sent Events (SSE). The wrapper ensures that key locks are released and usage is recorded only after the stream is fully consumed.
144
+
145
+ **Streaming Example:**
146
+
147
+ ```python
148
+ async def stream_example():
149
+ async with RotatingClient(api_keys=api_keys) as client:
150
+ response_stream = await client.acompletion(
151
+ model="gemini/gemini-1.5-flash",
152
+ messages=[{"role": "user", "content": "Tell me a long story."}],
153
+ stream=True
154
+ )
155
+ async for chunk in response_stream:
156
+ print(chunk)
157
+
158
+ asyncio.run(stream_example())
159
+ ```
160
+
161
+ #### `async def aembedding(self, **kwargs) -> Any:`
162
+
163
+ A wrapper around `litellm.aembedding` that provides the same key management and retry logic for embedding requests.
164
+
165
+ #### `def token_count(self, model: str, text: str = None, messages: List[Dict[str, str]] = None) -> int:`
166
+
167
+ Calculates the token count for a given text or list of messages using `litellm.token_counter`.
168
+
169
+ #### `async def get_available_models(self, provider: str) -> List[str]:`
170
+
171
+ Fetches a list of available models for a specific provider, applying any configured whitelists or blacklists. Results are cached in memory.
172
+
173
+ #### `async def get_all_available_models(self, grouped: bool = True) -> Union[Dict[str, List[str]], List[str]]:`
174
+
175
+ Fetches a dictionary of all available models, grouped by provider, or as a single flat list if `grouped=False`.
176
+
177
+ #### `async def anthropic_messages(self, request, raw_request=None, pre_request_callback=None) -> Any:`
178
+
179
+ Handle Anthropic Messages API requests. Accepts requests in Anthropic's format, translates them to OpenAI format internally, processes them through `acompletion`, and returns responses in Anthropic's format.
180
+
181
+ - **Parameters**:
182
+ - `request`: An `AnthropicMessagesRequest` object (from `anthropic_compat.models`)
183
+ - `raw_request`: Optional raw request object for client disconnect checks
184
+ - `pre_request_callback`: Optional async callback before each API request
185
+ - **Returns**:
186
+ - For non-streaming: dict in Anthropic Messages format
187
+ - For streaming: AsyncGenerator yielding Anthropic SSE format strings
188
+
189
+ #### `async def anthropic_count_tokens(self, request) -> dict:`
190
+
191
+ Handle Anthropic count_tokens API requests. Counts the number of tokens that would be used by a Messages API request.
192
+
193
+ - **Parameters**: `request` - An `AnthropicCountTokensRequest` object
194
+ - **Returns**: Dict with `input_tokens` count in Anthropic format
195
+
196
+ ## Anthropic API Compatibility
197
+
198
+ The library includes a translation layer (`anthropic_compat`) that enables Anthropic API clients to use any OpenAI-compatible provider.
199
+
200
+ ### Usage
201
+
202
+ ```python
203
+ from rotator_library.anthropic_compat import (
204
+ AnthropicMessagesRequest,
205
+ AnthropicCountTokensRequest,
206
+ translate_anthropic_request,
207
+ openai_to_anthropic_response,
208
+ anthropic_streaming_wrapper,
209
+ )
210
+
211
+ # Create an Anthropic-format request
212
+ request = AnthropicMessagesRequest(
213
+ model="gemini/gemini-2.5-flash",
214
+ max_tokens=1024,
215
+ messages=[{"role": "user", "content": "Hello!"}]
216
+ )
217
+
218
+ # Use with RotatingClient
219
+ async with RotatingClient(api_keys=api_keys) as client:
220
+ response = await client.anthropic_messages(request)
221
+ print(response["content"][0]["text"])
222
+ ```
223
+
224
+ ### Features
225
+
226
+ - **Full Message Translation**: Converts between Anthropic and OpenAI message formats including text, images, tool_use, and tool_result blocks
227
+ - **Extended Thinking Support**: Translates Anthropic's `thinking` configuration to `reasoning_effort` for providers that support it
228
+ - **Streaming SSE Conversion**: Converts OpenAI streaming chunks to Anthropic's SSE event format (`message_start`, `content_block_delta`, etc.)
229
+ - **Cache Token Handling**: Properly translates `prompt_tokens_details.cached_tokens` to Anthropic's `cache_read_input_tokens`
230
+ - **Tool Call Support**: Full support for tool definitions and tool use/result blocks
231
+
232
+ ## Credential Tool
233
+
234
+ The library includes a utility to manage credentials easily:
235
+
236
+ ```bash
237
+ python -m src.rotator_library.credential_tool
238
+ ```
239
+
240
+ Use this tool to:
241
+ 1. **Initialize OAuth**: Run the interactive login flows for Gemini, Qwen, and iFlow.
242
+ 2. **Export Credentials**: Generate `.env` compatible configuration blocks from your saved OAuth JSON files. This is essential for setting up stateless deployments.
243
+
244
+ ## Provider Specifics
245
+
246
+ ### Qwen Code
247
+ - **Auth**: Uses OAuth 2.0 Device Flow. Requires manual entry of email/identifier if not returned by the provider.
248
+ - **Resilience**: Injects a dummy tool (`do_not_call_me`) into requests with no tools to prevent known stream corruption issues on the API.
249
+ - **Reasoning**: Parses `<think>` tags in the response and exposes them as `reasoning_content`.
250
+ - **Schema Cleaning**: Recursively removes `strict` and `additionalProperties` from all tool schemas. Qwen's API has stricter validation than OpenAI's, and these properties cause `400 Bad Request` errors.
251
+
252
+ ### iFlow
253
+ - **Auth**: Uses Authorization Code Flow with a local callback server (port 11451).
254
+ - **Key Separation**: Distinguishes between the OAuth `access_token` (used to fetch user info) and the `api_key` (used for actual chat requests).
255
+ - **Resilience**: Similar to Qwen, injects a placeholder tool to stabilize streaming for empty tool lists.
256
+ - **Schema Cleaning**: Recursively removes `strict` and `additionalProperties` from all tool schemas to prevent API validation errors.
257
+ - **Custom Models**: Supports model definitions via `IFLOW_MODELS` environment variable (JSON array of model IDs or objects).
258
+
259
+ ### NVIDIA NIM
260
+ - **Discovery**: Dynamically fetches available models from the NVIDIA API.
261
+ - **Thinking**: Automatically injects the `thinking` parameter into `extra_body` for DeepSeek models (`deepseek-v3.1`, etc.) when `reasoning_effort` is set to low/medium/high.
262
+
263
+ ### Google Gemini (CLI)
264
+ - **Auth**: Simulates the Google Cloud CLI authentication flow.
265
+ - **Project Discovery**: Automatically discovers the default Google Cloud Project ID with enhanced onboarding flow.
266
+ - **Credential Prioritization**: Automatic detection and prioritization of paid vs free tier credentials.
267
+ - **Model Tier Requirements**: Gemini 3 models automatically filtered to paid-tier credentials only.
268
+ - **Gemini 3 Support**: Full support for Gemini 3 models with:
269
+ - `thinkingLevel` configuration (low/high)
270
+ - Tool hallucination prevention via system instruction injection
271
+ - ThoughtSignature caching for multi-turn conversations
272
+ - Parameter signature injection into tool descriptions
273
+ - **Rate Limits**: Implements smart fallback strategies (e.g., switching from `gemini-1.5-pro` to `gemini-1.5-pro-002`) when rate limits are hit.
274
+
275
+ ### Antigravity
276
+ - **Auth**: Uses OAuth 2.0 flow similar to Gemini CLI, with Antigravity-specific credentials and scopes.
277
+ - **Credential Prioritization**: Automatic detection and prioritization of paid vs free tier credentials (paid tier resets every 5 hours, free tier resets weekly).
278
+ - **Models**: Supports Gemini 3 Pro, Gemini 2.5 Flash/Flash Lite, Claude Sonnet 4.5 (with/without thinking), Claude Opus 4.5 (thinking only), and GPT-OSS 120B via Google's internal Antigravity API.
279
+ - **Quota Groups**: Models that share quota are automatically grouped:
280
+ - Claude/GPT-OSS: `claude-sonnet-4-5`, `claude-opus-4-5`, `gpt-oss-120b-medium`
281
+ - Gemini 3 Pro: `gemini-3-pro-high`, `gemini-3-pro-low`, `gemini-3-pro-preview`
282
+ - Gemini 2.5 Flash: `gemini-2.5-flash`, `gemini-2.5-flash-thinking`, `gemini-2.5-flash-lite`
283
+ - All models in a group deplete the usage of the group equally. So in claude group - it is beneficial to use only Opus, and forget about Sonnet and GPT-OSS.
284
+ - **Quota Baseline Tracking**: Background job fetches quota status from API every 5 minutes to provide accurate remaining quota estimates.
285
+ - **Thought Signature Caching**: Server-side caching of `thoughtSignature` data for multi-turn conversations with Gemini 3 models.
286
+ - **Tool Hallucination Prevention**: Automatic injection of system instructions and parameter signatures for Gemini 3 and Claude to prevent tool parameter hallucination.
287
+ - **Parallel Tool Usage Instruction**: Configurable instruction injection to encourage parallel tool calls (enabled by default for Claude).
288
+ - **Thinking Support**:
289
+ - Gemini 3: Uses `thinkingLevel` (string: "low"/"high")
290
+ - Gemini 2.5 Flash: Uses `-thinking` variant when `reasoning_effort` is provided
291
+ - Claude Sonnet 4.5: Uses `thinkingBudget` (optional - supports both thinking and non-thinking modes)
292
+ - Claude Opus 4.5: Uses `thinkingBudget` (always uses thinking variant)
293
+ - **Base URL Fallback**: Automatic fallback between sandbox and production endpoints.
294
+ - **Fair Cycle Rotation**: Enabled by default in sequential mode. Ensures all credentials cycle before reuse.
295
+ - **Custom Caps**: Configurable per-tier caps with offset cooldowns for pacing usage. See `config/defaults.py`.
296
+
297
+ ## Error Handling and Cooldowns
298
+
299
+ The client uses a sophisticated error handling mechanism:
300
+
301
+ - **Error Classification**: All exceptions from `litellm` are passed through a `classify_error` function to determine their type (`rate_limit`, `authentication`, `server_error`, `quota`, `context_length`, etc.).
302
+ - **Server Errors**: The client will retry the request with the *same key* up to `max_retries` times, using an exponential backoff strategy.
303
+ - **Key-Specific Errors (Authentication, Quota, etc.)**: The client records the failure in the `UsageManager`, which applies an escalating cooldown to the key for that specific model. The client then immediately acquires a new key and continues its attempt to complete the request.
304
+ - **Escalating Cooldown Strategy**: Consecutive failures for a key on the same model result in increasing cooldown períods:
305
+ - 1st failure: 10 seconds
306
+ - 2nd failure: 30 seconds
307
+ - 3rd failure: 60 seconds
308
+ - 4th+ failure: 120 seconds
309
+ - **Key-Level Lockouts**: If a key fails on multiple different models (3+ distinct models), the `UsageManager` applies a global 5-minute lockout for that key, removing it from rotation entirely.
310
+ - **Authentication Errors**: Immediate 5-minute global lockout (key is assumed revoked or invalid).
311
+
312
+ ### Global Timeout and Deadline-Driven Logic
313
+
314
+ To ensure predictable performance, the client now operates on a strict time budget defined by the `global_timeout` parameter.
315
+
316
+ - **Deadline Enforcement**: When a request starts, a `deadline` is set. The entire process, including all key rotations and retries, must complete before this deadline.
317
+ - **Deadline-Aware Retries**: If a retry requires a wait time that would exceed the remaining budget, the wait is skipped, and the client immediately rotates to the next key.
318
+ - **Silent Internal Errors**: Intermittent failures like provider capacity limits or temporary server errors are logged internally but are **not raised** to the caller. The client will simply rotate to the next key.
319
+
320
+ ## Extending with Provider Plugins
321
+
322
+ The library uses a dynamic plugin system. To add support for a new provider's model list, you only need to:
323
+
324
+ 1. **Create a new provider file** in `src/rotator_library/providers/` (e.g., `my_provider.py`).
325
+ 2. **Implement the `ProviderInterface`**: Inside your new file, create a class that inherits from `ProviderInterface` and implements the `get_models` method.
326
+
327
+ ```python
328
+ # src/rotator_library/providers/my_provider.py
329
+ from .provider_interface import ProviderInterface
330
+ from typing import List
331
+ import httpx
332
+
333
+ class MyProvider(ProviderInterface):
334
+ async def get_models(self, credential: str, client: httpx.AsyncClient) -> List[str]:
335
+ # Logic to fetch and return a list of model names
336
+ # The credential argument allows using the key to fetch models
337
+ pass
338
+ ```
339
+
340
+ The system will automatically discover and register your new provider.
341
+
342
+ ## Detailed Documentation
343
+
344
+ For a more in-depth technical explanation of the library's architecture, including the `UsageManager`'s concurrency model and the error classification system, please refer to the [Technical Documentation](../../DOCUMENTATION.md).
345
+
src/rotator_library/__init__.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: LGPL-3.0-only
2
+ # Copyright (c) 2026 Mirrowel
3
+
4
+ from typing import TYPE_CHECKING, Dict, Type
5
+
6
+ from .client import RotatingClient
7
+
8
+ # For type checkers (Pylint, mypy), import PROVIDER_PLUGINS statically
9
+ # At runtime, it's lazy-loaded via __getattr__
10
+ if TYPE_CHECKING:
11
+ from .providers import PROVIDER_PLUGINS
12
+ from .providers.provider_interface import ProviderInterface
13
+ from .model_info_service import ModelInfoService, ModelInfo, ModelMetadata
14
+ from . import anthropic_compat
15
+
16
+ __all__ = [
17
+ "RotatingClient",
18
+ "PROVIDER_PLUGINS",
19
+ "ModelInfoService",
20
+ "ModelInfo",
21
+ "ModelMetadata",
22
+ "anthropic_compat",
23
+ ]
24
+
25
+
26
+ def __getattr__(name):
27
+ """Lazy-load PROVIDER_PLUGINS, ModelInfoService, and anthropic_compat to speed up module import."""
28
+ if name == "PROVIDER_PLUGINS":
29
+ from .providers import PROVIDER_PLUGINS
30
+
31
+ return PROVIDER_PLUGINS
32
+ if name == "ModelInfoService":
33
+ from .model_info_service import ModelInfoService
34
+
35
+ return ModelInfoService
36
+ if name == "ModelInfo":
37
+ from .model_info_service import ModelInfo
38
+
39
+ return ModelInfo
40
+ if name == "ModelMetadata":
41
+ from .model_info_service import ModelMetadata
42
+
43
+ return ModelMetadata
44
+ if name == "anthropic_compat":
45
+ from . import anthropic_compat
46
+
47
+ return anthropic_compat
48
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
src/rotator_library/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (1.56 kB). View file
 
src/rotator_library/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (1.32 kB). View file
 
src/rotator_library/__pycache__/background_refresher.cpython-311.pyc ADDED
Binary file (14.6 kB). View file
 
src/rotator_library/__pycache__/client.cpython-311.pyc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:edc8f881bbdcc158873de4f9d619a679c2e38a0dfdfacafdb9b664340c8a4bad
3
+ size 133767
src/rotator_library/__pycache__/client.cpython-314.pyc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:78e2287189c4fa734c92541a15808197dba901d8a33dff8617180acec249d4f3
3
+ size 130115
src/rotator_library/__pycache__/cooldown_manager.cpython-311.pyc ADDED
Binary file (3.57 kB). View file
 
src/rotator_library/__pycache__/credential_manager.cpython-311.pyc ADDED
Binary file (9.8 kB). View file
 
src/rotator_library/__pycache__/credential_tool.cpython-311.pyc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6c2364484c477fa560b864a797018922635c7edf7949211a5fccf1e2204bf9a8
3
+ size 115435
src/rotator_library/__pycache__/error_handler.cpython-311.pyc ADDED
Binary file (35.5 kB). View file
 
src/rotator_library/__pycache__/failure_logger.cpython-311.pyc ADDED
Binary file (9.41 kB). View file
 
src/rotator_library/__pycache__/litellm_providers.cpython-311.pyc ADDED
Binary file (25.1 kB). View file
 
src/rotator_library/__pycache__/model_definitions.cpython-311.pyc ADDED
Binary file (7.62 kB). View file
 
src/rotator_library/__pycache__/provider_config.cpython-311.pyc ADDED
Binary file (18.4 kB). View file
 
src/rotator_library/__pycache__/provider_factory.cpython-311.pyc ADDED
Binary file (1.4 kB). View file
 
src/rotator_library/__pycache__/request_sanitizer.cpython-311.pyc ADDED
Binary file (964 Bytes). View file
 
src/rotator_library/__pycache__/timeout_config.cpython-311.pyc ADDED
Binary file (5.34 kB). View file
 
src/rotator_library/__pycache__/transaction_logger.cpython-311.pyc ADDED
Binary file (27 kB). View file
 
src/rotator_library/__pycache__/usage_manager.cpython-311.pyc ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:cb69d1b0cb42c68574e65fb88bfb8c9df3da92145cc8a2dcb5158fded655b8e8
3
+ size 156427
src/rotator_library/anthropic_compat/__init__.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: LGPL-3.0-only
2
+ # Copyright (c) 2026 Mirrowel
3
+
4
+ """
5
+ Anthropic API compatibility module for rotator_library.
6
+
7
+ This module provides format translation between Anthropic's Messages API
8
+ and OpenAI's Chat Completions API, enabling any OpenAI-compatible provider
9
+ to work with Anthropic clients like Claude Code.
10
+
11
+ Usage:
12
+ from rotator_library.anthropic_compat import (
13
+ AnthropicMessagesRequest,
14
+ AnthropicMessagesResponse,
15
+ translate_anthropic_request,
16
+ openai_to_anthropic_response,
17
+ anthropic_streaming_wrapper,
18
+ )
19
+ """
20
+
21
+ from .models import (
22
+ AnthropicTextBlock,
23
+ AnthropicImageSource,
24
+ AnthropicImageBlock,
25
+ AnthropicToolUseBlock,
26
+ AnthropicToolResultBlock,
27
+ AnthropicMessage,
28
+ AnthropicTool,
29
+ AnthropicThinkingConfig,
30
+ AnthropicMessagesRequest,
31
+ AnthropicUsage,
32
+ AnthropicMessagesResponse,
33
+ AnthropicCountTokensRequest,
34
+ AnthropicCountTokensResponse,
35
+ )
36
+
37
+ from .translator import (
38
+ anthropic_to_openai_messages,
39
+ anthropic_to_openai_tools,
40
+ anthropic_to_openai_tool_choice,
41
+ openai_to_anthropic_response,
42
+ translate_anthropic_request,
43
+ )
44
+
45
+ from .streaming import anthropic_streaming_wrapper
46
+
47
+ __all__ = [
48
+ # Models
49
+ "AnthropicTextBlock",
50
+ "AnthropicImageSource",
51
+ "AnthropicImageBlock",
52
+ "AnthropicToolUseBlock",
53
+ "AnthropicToolResultBlock",
54
+ "AnthropicMessage",
55
+ "AnthropicTool",
56
+ "AnthropicThinkingConfig",
57
+ "AnthropicMessagesRequest",
58
+ "AnthropicUsage",
59
+ "AnthropicMessagesResponse",
60
+ "AnthropicCountTokensRequest",
61
+ "AnthropicCountTokensResponse",
62
+ # Translator functions
63
+ "anthropic_to_openai_messages",
64
+ "anthropic_to_openai_tools",
65
+ "anthropic_to_openai_tool_choice",
66
+ "openai_to_anthropic_response",
67
+ "translate_anthropic_request",
68
+ # Streaming
69
+ "anthropic_streaming_wrapper",
70
+ ]
src/rotator_library/anthropic_compat/models.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: LGPL-3.0-only
2
+ # Copyright (c) 2026 Mirrowel
3
+
4
+ """
5
+ Pydantic models for the Anthropic Messages API.
6
+
7
+ These models define the request and response formats for Anthropic's Messages API,
8
+ enabling compatibility with Claude Code and other Anthropic API clients.
9
+ """
10
+
11
+ from typing import Any, List, Optional, Union
12
+ from pydantic import BaseModel
13
+
14
+
15
+ # --- Content Blocks ---
16
+ class AnthropicTextBlock(BaseModel):
17
+ """Anthropic text content block."""
18
+
19
+ type: str = "text"
20
+ text: str
21
+
22
+
23
+ class AnthropicImageSource(BaseModel):
24
+ """Anthropic image source for base64 images."""
25
+
26
+ type: str = "base64"
27
+ media_type: str
28
+ data: str
29
+
30
+
31
+ class AnthropicImageBlock(BaseModel):
32
+ """Anthropic image content block."""
33
+
34
+ type: str = "image"
35
+ source: AnthropicImageSource
36
+
37
+
38
+ class AnthropicToolUseBlock(BaseModel):
39
+ """Anthropic tool use content block."""
40
+
41
+ type: str = "tool_use"
42
+ id: str
43
+ name: str
44
+ input: dict
45
+
46
+
47
+ class AnthropicToolResultBlock(BaseModel):
48
+ """Anthropic tool result content block."""
49
+
50
+ type: str = "tool_result"
51
+ tool_use_id: str
52
+ content: Union[str, List[Any]]
53
+ is_error: Optional[bool] = None
54
+
55
+
56
+ # --- Message and Tool Definitions ---
57
+ class AnthropicMessage(BaseModel):
58
+ """Anthropic message format."""
59
+
60
+ role: str
61
+ content: Union[
62
+ str,
63
+ List[
64
+ Union[
65
+ AnthropicTextBlock,
66
+ AnthropicImageBlock,
67
+ AnthropicToolUseBlock,
68
+ AnthropicToolResultBlock,
69
+ dict,
70
+ ]
71
+ ],
72
+ ]
73
+
74
+
75
+ class AnthropicTool(BaseModel):
76
+ """Anthropic tool definition."""
77
+
78
+ name: str
79
+ description: Optional[str] = None
80
+ input_schema: dict
81
+
82
+
83
+ class AnthropicThinkingConfig(BaseModel):
84
+ """Anthropic thinking configuration."""
85
+
86
+ type: str # "enabled" or "disabled"
87
+ budget_tokens: Optional[int] = None
88
+
89
+
90
+ # --- Messages Request ---
91
+ class AnthropicMessagesRequest(BaseModel):
92
+ """Anthropic Messages API request format."""
93
+
94
+ model: str
95
+ messages: List[AnthropicMessage]
96
+ max_tokens: int
97
+ system: Optional[Union[str, List[dict]]] = None
98
+ temperature: Optional[float] = None
99
+ top_p: Optional[float] = None
100
+ top_k: Optional[int] = None
101
+ stop_sequences: Optional[List[str]] = None
102
+ stream: Optional[bool] = False
103
+ tools: Optional[List[AnthropicTool]] = None
104
+ tool_choice: Optional[dict] = None
105
+ metadata: Optional[dict] = None
106
+ thinking: Optional[AnthropicThinkingConfig] = None
107
+
108
+
109
+ # --- Messages Response ---
110
+ class AnthropicUsage(BaseModel):
111
+ """Anthropic usage statistics."""
112
+
113
+ input_tokens: int
114
+ output_tokens: int
115
+ cache_creation_input_tokens: Optional[int] = None
116
+ cache_read_input_tokens: Optional[int] = None
117
+
118
+
119
+ class AnthropicMessagesResponse(BaseModel):
120
+ """Anthropic Messages API response format."""
121
+
122
+ id: str
123
+ type: str = "message"
124
+ role: str = "assistant"
125
+ content: List[Union[AnthropicTextBlock, AnthropicToolUseBlock, dict]]
126
+ model: str
127
+ stop_reason: Optional[str] = None
128
+ stop_sequence: Optional[str] = None
129
+ usage: AnthropicUsage
130
+
131
+
132
+ # --- Count Tokens ---
133
+ class AnthropicCountTokensRequest(BaseModel):
134
+ """Anthropic count_tokens API request format."""
135
+
136
+ model: str
137
+ messages: List[AnthropicMessage]
138
+ system: Optional[Union[str, List[dict]]] = None
139
+ tools: Optional[List[AnthropicTool]] = None
140
+ tool_choice: Optional[dict] = None
141
+ thinking: Optional[AnthropicThinkingConfig] = None
142
+
143
+
144
+ class AnthropicCountTokensResponse(BaseModel):
145
+ """Anthropic count_tokens API response format."""
146
+
147
+ input_tokens: int
src/rotator_library/anthropic_compat/streaming.py ADDED
@@ -0,0 +1,433 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: LGPL-3.0-only
2
+ # Copyright (c) 2026 Mirrowel
3
+
4
+ """
5
+ Streaming wrapper for converting OpenAI streaming format to Anthropic streaming format.
6
+
7
+ This module provides a framework-agnostic streaming wrapper that converts
8
+ OpenAI SSE (Server-Sent Events) format to Anthropic's streaming format.
9
+ """
10
+
11
+ import json
12
+ import logging
13
+ import uuid
14
+ from typing import AsyncGenerator, Callable, Optional, Awaitable, Any, TYPE_CHECKING
15
+
16
+ if TYPE_CHECKING:
17
+ from ..transaction_logger import TransactionLogger
18
+
19
+ logger = logging.getLogger("rotator_library.anthropic_compat")
20
+
21
+
22
+ async def anthropic_streaming_wrapper(
23
+ openai_stream: AsyncGenerator[str, None],
24
+ original_model: str,
25
+ request_id: Optional[str] = None,
26
+ is_disconnected: Optional[Callable[[], Awaitable[bool]]] = None,
27
+ transaction_logger: Optional["TransactionLogger"] = None,
28
+ ) -> AsyncGenerator[str, None]:
29
+ """
30
+ Convert OpenAI streaming format to Anthropic streaming format.
31
+
32
+ This is a framework-agnostic wrapper that can be used with any async web framework.
33
+ Instead of taking a FastAPI Request object, it accepts an optional callback function
34
+ to check for client disconnection.
35
+
36
+ Anthropic SSE events:
37
+ - message_start: Initial message metadata
38
+ - content_block_start: Start of a content block
39
+ - content_block_delta: Content chunk
40
+ - content_block_stop: End of a content block
41
+ - message_delta: Final message metadata (stop_reason, usage)
42
+ - message_stop: End of message
43
+
44
+ Args:
45
+ openai_stream: AsyncGenerator yielding OpenAI SSE format strings
46
+ original_model: The model name to include in responses
47
+ request_id: Optional request ID (auto-generated if not provided)
48
+ is_disconnected: Optional async callback that returns True if client disconnected
49
+ transaction_logger: Optional TransactionLogger for logging the final Anthropic response
50
+
51
+ Yields:
52
+ SSE format strings in Anthropic's streaming format
53
+ """
54
+ if request_id is None:
55
+ request_id = f"msg_{uuid.uuid4().hex[:24]}"
56
+
57
+ message_started = False
58
+ content_block_started = False
59
+ thinking_block_started = False
60
+ current_block_index = 0
61
+ tool_calls_by_index = {} # Track tool calls by their index
62
+ tool_block_indices = {} # Track which block index each tool call uses
63
+ input_tokens = 0
64
+ output_tokens = 0
65
+ cached_tokens = 0 # Track cached tokens for proper Anthropic format
66
+ accumulated_text = "" # Track accumulated text for logging
67
+ accumulated_thinking = "" # Track accumulated thinking for logging
68
+ stop_reason_final = "end_turn" # Track final stop reason for logging
69
+
70
+ try:
71
+ async for chunk_str in openai_stream:
72
+ # Check for client disconnection if callback provided
73
+ if is_disconnected is not None and await is_disconnected():
74
+ break
75
+
76
+ if not chunk_str.strip() or not chunk_str.startswith("data:"):
77
+ continue
78
+
79
+ data_content = chunk_str[len("data:") :].strip()
80
+ if data_content == "[DONE]":
81
+ # CRITICAL: Send message_start if we haven't yet (e.g., empty response)
82
+ # Claude Code and other clients require message_start before message_stop
83
+ if not message_started:
84
+ # Build usage with cached tokens properly handled
85
+ usage_dict = {
86
+ "input_tokens": input_tokens - cached_tokens,
87
+ "output_tokens": 0,
88
+ }
89
+ if cached_tokens > 0:
90
+ usage_dict["cache_read_input_tokens"] = cached_tokens
91
+ usage_dict["cache_creation_input_tokens"] = 0
92
+
93
+ message_start = {
94
+ "type": "message_start",
95
+ "message": {
96
+ "id": request_id,
97
+ "type": "message",
98
+ "role": "assistant",
99
+ "content": [],
100
+ "model": original_model,
101
+ "stop_reason": None,
102
+ "stop_sequence": None,
103
+ "usage": usage_dict,
104
+ },
105
+ }
106
+ yield f"event: message_start\ndata: {json.dumps(message_start)}\n\n"
107
+ message_started = True
108
+
109
+ # Close any open thinking block
110
+ if thinking_block_started:
111
+ yield f'event: content_block_stop\ndata: {{"type": "content_block_stop", "index": {current_block_index}}}\n\n'
112
+ current_block_index += 1
113
+ thinking_block_started = False
114
+
115
+ # Close any open text block
116
+ if content_block_started:
117
+ yield f'event: content_block_stop\ndata: {{"type": "content_block_stop", "index": {current_block_index}}}\n\n'
118
+ current_block_index += 1
119
+ content_block_started = False
120
+
121
+ # Close all open tool_use blocks
122
+ for tc_index in sorted(tool_block_indices.keys()):
123
+ block_idx = tool_block_indices[tc_index]
124
+ yield f'event: content_block_stop\ndata: {{"type": "content_block_stop", "index": {block_idx}}}\n\n'
125
+
126
+ # Determine stop_reason based on whether we had tool calls
127
+ stop_reason = "tool_use" if tool_calls_by_index else "end_turn"
128
+ stop_reason_final = stop_reason
129
+
130
+ # Build final usage dict with cached tokens
131
+ final_usage = {"output_tokens": output_tokens}
132
+ if cached_tokens > 0:
133
+ final_usage["cache_read_input_tokens"] = cached_tokens
134
+ final_usage["cache_creation_input_tokens"] = 0
135
+
136
+ # Send message_delta with final info
137
+ yield f'event: message_delta\ndata: {{"type": "message_delta", "delta": {{"stop_reason": "{stop_reason}", "stop_sequence": null}}, "usage": {json.dumps(final_usage)}}}\n\n'
138
+
139
+ # Send message_stop
140
+ yield 'event: message_stop\ndata: {"type": "message_stop"}\n\n'
141
+
142
+ # Log final Anthropic response if logger provided
143
+ if transaction_logger:
144
+ # Build content blocks for logging
145
+ content_blocks = []
146
+ if accumulated_thinking:
147
+ content_blocks.append(
148
+ {
149
+ "type": "thinking",
150
+ "thinking": accumulated_thinking,
151
+ }
152
+ )
153
+ if accumulated_text:
154
+ content_blocks.append(
155
+ {
156
+ "type": "text",
157
+ "text": accumulated_text,
158
+ }
159
+ )
160
+ # Add tool use blocks
161
+ for tc_index in sorted(tool_calls_by_index.keys()):
162
+ tc = tool_calls_by_index[tc_index]
163
+ # Parse arguments JSON string to dict
164
+ try:
165
+ input_data = json.loads(tc.get("arguments", "{}"))
166
+ except json.JSONDecodeError:
167
+ input_data = {}
168
+ content_blocks.append(
169
+ {
170
+ "type": "tool_use",
171
+ "id": tc.get("id", ""),
172
+ "name": tc.get("name", ""),
173
+ "input": input_data,
174
+ }
175
+ )
176
+
177
+ # Build usage for logging
178
+ log_usage = {
179
+ "input_tokens": input_tokens - cached_tokens,
180
+ "output_tokens": output_tokens,
181
+ }
182
+ if cached_tokens > 0:
183
+ log_usage["cache_read_input_tokens"] = cached_tokens
184
+ log_usage["cache_creation_input_tokens"] = 0
185
+
186
+ anthropic_response = {
187
+ "id": request_id,
188
+ "type": "message",
189
+ "role": "assistant",
190
+ "content": content_blocks,
191
+ "model": original_model,
192
+ "stop_reason": stop_reason_final,
193
+ "stop_sequence": None,
194
+ "usage": log_usage,
195
+ }
196
+ transaction_logger.log_response(
197
+ anthropic_response,
198
+ filename="anthropic_response.json",
199
+ )
200
+
201
+ break
202
+
203
+ try:
204
+ chunk = json.loads(data_content)
205
+ except json.JSONDecodeError:
206
+ continue
207
+
208
+ # Extract usage if present
209
+ # Note: Google's promptTokenCount INCLUDES cached tokens, but Anthropic's
210
+ # input_tokens EXCLUDES cached tokens. We extract cached tokens and subtract.
211
+ if "usage" in chunk and chunk["usage"]:
212
+ usage = chunk["usage"]
213
+ input_tokens = usage.get("prompt_tokens", input_tokens)
214
+ output_tokens = usage.get("completion_tokens", output_tokens)
215
+ # Extract cached tokens from prompt_tokens_details
216
+ if usage.get("prompt_tokens_details"):
217
+ cached_tokens = usage["prompt_tokens_details"].get(
218
+ "cached_tokens", cached_tokens
219
+ )
220
+
221
+ # Send message_start on first chunk
222
+ if not message_started:
223
+ # Build usage with cached tokens properly handled for Anthropic format
224
+ usage_dict = {
225
+ "input_tokens": input_tokens - cached_tokens,
226
+ "output_tokens": 0,
227
+ }
228
+ if cached_tokens > 0:
229
+ usage_dict["cache_read_input_tokens"] = cached_tokens
230
+ usage_dict["cache_creation_input_tokens"] = 0
231
+
232
+ message_start = {
233
+ "type": "message_start",
234
+ "message": {
235
+ "id": request_id,
236
+ "type": "message",
237
+ "role": "assistant",
238
+ "content": [],
239
+ "model": original_model,
240
+ "stop_reason": None,
241
+ "stop_sequence": None,
242
+ "usage": usage_dict,
243
+ },
244
+ }
245
+ yield f"event: message_start\ndata: {json.dumps(message_start)}\n\n"
246
+ message_started = True
247
+
248
+ choices = chunk.get("choices") or []
249
+ if not choices:
250
+ continue
251
+
252
+ delta = choices[0].get("delta", {})
253
+
254
+ # Handle reasoning/thinking content (from OpenAI-style reasoning_content)
255
+ reasoning_content = delta.get("reasoning_content")
256
+ if reasoning_content:
257
+ if not thinking_block_started:
258
+ # Start a thinking content block
259
+ block_start = {
260
+ "type": "content_block_start",
261
+ "index": current_block_index,
262
+ "content_block": {"type": "thinking", "thinking": ""},
263
+ }
264
+ yield f"event: content_block_start\ndata: {json.dumps(block_start)}\n\n"
265
+ thinking_block_started = True
266
+
267
+ # Send thinking delta
268
+ block_delta = {
269
+ "type": "content_block_delta",
270
+ "index": current_block_index,
271
+ "delta": {"type": "thinking_delta", "thinking": reasoning_content},
272
+ }
273
+ yield f"event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n"
274
+ # Accumulate thinking for logging
275
+ accumulated_thinking += reasoning_content
276
+
277
+ # Handle text content
278
+ content = delta.get("content")
279
+ if content:
280
+ # If we were in a thinking block, close it first
281
+ if thinking_block_started and not content_block_started:
282
+ yield f'event: content_block_stop\ndata: {{"type": "content_block_stop", "index": {current_block_index}}}\n\n'
283
+ current_block_index += 1
284
+ thinking_block_started = False
285
+
286
+ if not content_block_started:
287
+ # Start a text content block
288
+ block_start = {
289
+ "type": "content_block_start",
290
+ "index": current_block_index,
291
+ "content_block": {"type": "text", "text": ""},
292
+ }
293
+ yield f"event: content_block_start\ndata: {json.dumps(block_start)}\n\n"
294
+ content_block_started = True
295
+
296
+ # Send content delta
297
+ block_delta = {
298
+ "type": "content_block_delta",
299
+ "index": current_block_index,
300
+ "delta": {"type": "text_delta", "text": content},
301
+ }
302
+ yield f"event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n"
303
+ # Accumulate text for logging
304
+ accumulated_text += content
305
+
306
+ # Handle tool calls
307
+ # Use `or []` to handle providers that send "tool_calls": null
308
+ tool_calls = delta.get("tool_calls") or []
309
+ for tc in tool_calls:
310
+ tc_index = tc.get("index", 0)
311
+
312
+ if tc_index not in tool_calls_by_index:
313
+ # Close previous thinking block if open
314
+ if thinking_block_started:
315
+ yield f'event: content_block_stop\ndata: {{"type": "content_block_stop", "index": {current_block_index}}}\n\n'
316
+ current_block_index += 1
317
+ thinking_block_started = False
318
+
319
+ # Close previous text block if open
320
+ if content_block_started:
321
+ yield f'event: content_block_stop\ndata: {{"type": "content_block_stop", "index": {current_block_index}}}\n\n'
322
+ current_block_index += 1
323
+ content_block_started = False
324
+
325
+ # Start new tool use block
326
+ tool_calls_by_index[tc_index] = {
327
+ "id": tc.get("id", f"toolu_{uuid.uuid4().hex[:12]}"),
328
+ "name": tc.get("function", {}).get("name", ""),
329
+ "arguments": "",
330
+ }
331
+ # Track which block index this tool call uses
332
+ tool_block_indices[tc_index] = current_block_index
333
+
334
+ block_start = {
335
+ "type": "content_block_start",
336
+ "index": current_block_index,
337
+ "content_block": {
338
+ "type": "tool_use",
339
+ "id": tool_calls_by_index[tc_index]["id"],
340
+ "name": tool_calls_by_index[tc_index]["name"],
341
+ "input": {},
342
+ },
343
+ }
344
+ yield f"event: content_block_start\ndata: {json.dumps(block_start)}\n\n"
345
+ # Increment for the next block
346
+ current_block_index += 1
347
+
348
+ # Accumulate arguments
349
+ func = tc.get("function", {})
350
+ if func.get("name"):
351
+ tool_calls_by_index[tc_index]["name"] = func["name"]
352
+ if func.get("arguments"):
353
+ tool_calls_by_index[tc_index]["arguments"] += func["arguments"]
354
+
355
+ # Send partial JSON delta using the correct block index for this tool
356
+ block_delta = {
357
+ "type": "content_block_delta",
358
+ "index": tool_block_indices[tc_index],
359
+ "delta": {
360
+ "type": "input_json_delta",
361
+ "partial_json": func["arguments"],
362
+ },
363
+ }
364
+ yield f"event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n"
365
+
366
+ # Note: We intentionally ignore finish_reason here.
367
+ # Block closing is handled when we receive [DONE] to avoid
368
+ # premature closes with providers that send finish_reason on each chunk.
369
+
370
+ except Exception as e:
371
+ logger.error(f"Error in Anthropic streaming wrapper: {e}")
372
+
373
+ # If we haven't sent message_start yet, send it now so the client can display the error
374
+ # Claude Code and other clients may ignore events that come before message_start
375
+ if not message_started:
376
+ # Build usage with cached tokens properly handled
377
+ usage_dict = {
378
+ "input_tokens": input_tokens - cached_tokens,
379
+ "output_tokens": 0,
380
+ }
381
+ if cached_tokens > 0:
382
+ usage_dict["cache_read_input_tokens"] = cached_tokens
383
+ usage_dict["cache_creation_input_tokens"] = 0
384
+
385
+ message_start = {
386
+ "type": "message_start",
387
+ "message": {
388
+ "id": request_id,
389
+ "type": "message",
390
+ "role": "assistant",
391
+ "content": [],
392
+ "model": original_model,
393
+ "stop_reason": None,
394
+ "stop_sequence": None,
395
+ "usage": usage_dict,
396
+ },
397
+ }
398
+ yield f"event: message_start\ndata: {json.dumps(message_start)}\n\n"
399
+
400
+ # Send the error as a text content block so it's visible to the user
401
+ error_message = f"Error: {str(e)}"
402
+ error_block_start = {
403
+ "type": "content_block_start",
404
+ "index": current_block_index,
405
+ "content_block": {"type": "text", "text": ""},
406
+ }
407
+ yield f"event: content_block_start\ndata: {json.dumps(error_block_start)}\n\n"
408
+
409
+ error_block_delta = {
410
+ "type": "content_block_delta",
411
+ "index": current_block_index,
412
+ "delta": {"type": "text_delta", "text": error_message},
413
+ }
414
+ yield f"event: content_block_delta\ndata: {json.dumps(error_block_delta)}\n\n"
415
+
416
+ yield f'event: content_block_stop\ndata: {{"type": "content_block_stop", "index": {current_block_index}}}\n\n'
417
+
418
+ # Build final usage with cached tokens
419
+ final_usage = {"output_tokens": 0}
420
+ if cached_tokens > 0:
421
+ final_usage["cache_read_input_tokens"] = cached_tokens
422
+ final_usage["cache_creation_input_tokens"] = 0
423
+
424
+ # Send message_delta and message_stop to properly close the stream
425
+ yield f'event: message_delta\ndata: {{"type": "message_delta", "delta": {{"stop_reason": "end_turn", "stop_sequence": null}}, "usage": {json.dumps(final_usage)}}}\n\n'
426
+ yield 'event: message_stop\ndata: {"type": "message_stop"}\n\n'
427
+
428
+ # Also send the formal error event for clients that handle it
429
+ error_event = {
430
+ "type": "error",
431
+ "error": {"type": "api_error", "message": str(e)},
432
+ }
433
+ yield f"event: error\ndata: {json.dumps(error_event)}\n\n"
src/rotator_library/anthropic_compat/translator.py ADDED
@@ -0,0 +1,629 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: LGPL-3.0-only
2
+ # Copyright (c) 2026 Mirrowel
3
+
4
+ """
5
+ Format translation functions between Anthropic and OpenAI API formats.
6
+
7
+ This module provides functions to convert requests and responses between
8
+ Anthropic's Messages API format and OpenAI's Chat Completions API format.
9
+ This enables any OpenAI-compatible provider to work with Anthropic clients.
10
+ """
11
+
12
+ import json
13
+ import uuid
14
+ from typing import Any, Dict, List, Optional, Union
15
+
16
+ from .models import AnthropicMessagesRequest
17
+
18
+ MIN_THINKING_SIGNATURE_LENGTH = 100
19
+
20
+ # =============================================================================
21
+ # THINKING BUDGET TO REASONING EFFORT MAPPING
22
+ # =============================================================================
23
+
24
+ # Budget thresholds for reasoning effort levels (based on token counts)
25
+ # These map Anthropic's budget_tokens to OpenAI-style reasoning_effort levels
26
+ THINKING_BUDGET_THRESHOLDS = {
27
+ "minimal": 4096,
28
+ "low": 8192,
29
+ "low_medium": 12288,
30
+ "medium": 16384,
31
+ "medium_high": 24576,
32
+ "high": 32768,
33
+ }
34
+
35
+ # Providers that support granular reasoning effort levels (low_medium, medium_high, etc.)
36
+ # Other providers will receive simplified levels (low, medium, high)
37
+ GRANULAR_REASONING_PROVIDERS = {"antigravity"}
38
+
39
+
40
+ def _budget_to_reasoning_effort(budget_tokens: int, model: str) -> str:
41
+ """
42
+ Map Anthropic thinking budget_tokens to a reasoning_effort level.
43
+
44
+ Args:
45
+ budget_tokens: The thinking budget in tokens from the Anthropic request
46
+ model: The model name (used to determine if provider supports granular levels)
47
+
48
+ Returns:
49
+ A reasoning_effort level string (e.g., "low", "medium", "high")
50
+ """
51
+ # Determine granular level based on budget
52
+ if budget_tokens <= THINKING_BUDGET_THRESHOLDS["minimal"]:
53
+ granular_level = "minimal"
54
+ elif budget_tokens <= THINKING_BUDGET_THRESHOLDS["low"]:
55
+ granular_level = "low"
56
+ elif budget_tokens <= THINKING_BUDGET_THRESHOLDS["low_medium"]:
57
+ granular_level = "low_medium"
58
+ elif budget_tokens <= THINKING_BUDGET_THRESHOLDS["medium"]:
59
+ granular_level = "medium"
60
+ elif budget_tokens <= THINKING_BUDGET_THRESHOLDS["medium_high"]:
61
+ granular_level = "medium_high"
62
+ else:
63
+ granular_level = "high"
64
+
65
+ # Check if provider supports granular levels
66
+ provider = model.split("/")[0].lower() if "/" in model else ""
67
+ if provider in GRANULAR_REASONING_PROVIDERS:
68
+ return granular_level
69
+
70
+ # Simplify to basic levels for non-granular providers
71
+ simplify_map = {
72
+ "minimal": "low",
73
+ "low": "low",
74
+ "low_medium": "medium",
75
+ "medium": "medium",
76
+ "medium_high": "high",
77
+ "high": "high",
78
+ }
79
+ return simplify_map.get(granular_level, "medium")
80
+
81
+
82
+ def _reorder_assistant_content(content: List[dict]) -> List[dict]:
83
+ """
84
+ Reorder assistant message content blocks to ensure correct order:
85
+ 1. Thinking blocks come first (required when thinking is enabled)
86
+ 2. Text blocks come in the middle (filtering out empty ones)
87
+ 3. Tool_use blocks come at the end (required before tool_result)
88
+
89
+ This matches Anthropic's expected ordering and prevents API errors.
90
+ """
91
+ if not isinstance(content, list) or len(content) <= 1:
92
+ return content
93
+
94
+ thinking_blocks = []
95
+ text_blocks = []
96
+ tool_use_blocks = []
97
+ other_blocks = []
98
+
99
+ for block in content:
100
+ if not isinstance(block, dict):
101
+ other_blocks.append(block)
102
+ continue
103
+
104
+ block_type = block.get("type", "")
105
+
106
+ if block_type in ("thinking", "redacted_thinking"):
107
+ # Sanitize thinking blocks - remove cache_control and other extra fields
108
+ sanitized = {
109
+ "type": block_type,
110
+ "thinking": block.get("thinking", ""),
111
+ }
112
+ if block.get("signature"):
113
+ sanitized["signature"] = block["signature"]
114
+ thinking_blocks.append(sanitized)
115
+
116
+ elif block_type == "tool_use":
117
+ tool_use_blocks.append(block)
118
+
119
+ elif block_type == "text":
120
+ # Only keep text blocks with meaningful content
121
+ text = block.get("text", "")
122
+ if text and text.strip():
123
+ text_blocks.append(block)
124
+
125
+ else:
126
+ # Other block types (images, documents, etc.) go in the text position
127
+ other_blocks.append(block)
128
+
129
+ # Reorder: thinking → other → text → tool_use
130
+ return thinking_blocks + other_blocks + text_blocks + tool_use_blocks
131
+
132
+
133
+ def anthropic_to_openai_messages(
134
+ anthropic_messages: List[dict], system: Optional[Union[str, List[dict]]] = None
135
+ ) -> List[dict]:
136
+ """
137
+ Convert Anthropic message format to OpenAI format.
138
+
139
+ Key differences:
140
+ - Anthropic: system is a separate field, content can be string or list of blocks
141
+ - OpenAI: system is a message with role="system", content is usually string
142
+
143
+ Args:
144
+ anthropic_messages: List of messages in Anthropic format
145
+ system: Optional system message (string or list of text blocks)
146
+
147
+ Returns:
148
+ List of messages in OpenAI format
149
+ """
150
+ openai_messages = []
151
+
152
+ # Handle system message
153
+ if system:
154
+ if isinstance(system, str):
155
+ openai_messages.append({"role": "system", "content": system})
156
+ elif isinstance(system, list):
157
+ # System can be list of text blocks in Anthropic format
158
+ system_text = " ".join(
159
+ block.get("text", "")
160
+ for block in system
161
+ if isinstance(block, dict) and block.get("type") == "text"
162
+ )
163
+ if system_text:
164
+ openai_messages.append({"role": "system", "content": system_text})
165
+
166
+ for msg in anthropic_messages:
167
+ role = msg.get("role", "user")
168
+ content = msg.get("content", "")
169
+
170
+ if isinstance(content, str):
171
+ openai_messages.append({"role": role, "content": content})
172
+ elif isinstance(content, list):
173
+ # Reorder assistant content blocks to ensure correct order:
174
+ # thinking → text → tool_use
175
+ if role == "assistant":
176
+ content = _reorder_assistant_content(content)
177
+
178
+ # Handle content blocks
179
+ openai_content = []
180
+ tool_calls = []
181
+ reasoning_content = ""
182
+ thinking_signature = ""
183
+
184
+ for block in content:
185
+ if isinstance(block, dict):
186
+ block_type = block.get("type", "text")
187
+
188
+ if block_type == "text":
189
+ openai_content.append(
190
+ {"type": "text", "text": block.get("text", "")}
191
+ )
192
+ elif block_type == "image":
193
+ # Convert Anthropic image format to OpenAI
194
+ source = block.get("source", {})
195
+ if source.get("type") == "base64":
196
+ openai_content.append(
197
+ {
198
+ "type": "image_url",
199
+ "image_url": {
200
+ "url": f"data:{source.get('media_type', 'image/png')};base64,{source.get('data', '')}"
201
+ },
202
+ }
203
+ )
204
+ elif source.get("type") == "url":
205
+ openai_content.append(
206
+ {
207
+ "type": "image_url",
208
+ "image_url": {"url": source.get("url", "")},
209
+ }
210
+ )
211
+ elif block_type == "document":
212
+ # Convert Anthropic document format (e.g. PDF) to OpenAI
213
+ # Documents are treated similarly to images with appropriate mime type
214
+ source = block.get("source", {})
215
+ if source.get("type") == "base64":
216
+ openai_content.append(
217
+ {
218
+ "type": "image_url",
219
+ "image_url": {
220
+ "url": f"data:{source.get('media_type', 'application/pdf')};base64,{source.get('data', '')}"
221
+ },
222
+ }
223
+ )
224
+ elif source.get("type") == "url":
225
+ openai_content.append(
226
+ {
227
+ "type": "image_url",
228
+ "image_url": {"url": source.get("url", "")},
229
+ }
230
+ )
231
+ elif block_type == "thinking":
232
+ signature = block.get("signature", "")
233
+ if (
234
+ signature
235
+ and len(signature) >= MIN_THINKING_SIGNATURE_LENGTH
236
+ ):
237
+ thinking_text = block.get("thinking", "")
238
+ if thinking_text:
239
+ reasoning_content += thinking_text
240
+ thinking_signature = signature
241
+ elif block_type == "redacted_thinking":
242
+ signature = block.get("signature", "")
243
+ if (
244
+ signature
245
+ and len(signature) >= MIN_THINKING_SIGNATURE_LENGTH
246
+ ):
247
+ thinking_signature = signature
248
+ elif block_type == "tool_use":
249
+ # Anthropic tool_use -> OpenAI tool_calls
250
+ tool_calls.append(
251
+ {
252
+ "id": block.get("id", ""),
253
+ "type": "function",
254
+ "function": {
255
+ "name": block.get("name", ""),
256
+ "arguments": json.dumps(block.get("input", {})),
257
+ },
258
+ }
259
+ )
260
+ elif block_type == "tool_result":
261
+ # Tool results become separate messages in OpenAI format
262
+ # Content can be string, or list of text/image blocks
263
+ tool_content = block.get("content", "")
264
+ if isinstance(tool_content, str):
265
+ # Simple string content
266
+ openai_messages.append(
267
+ {
268
+ "role": "tool",
269
+ "tool_call_id": block.get("tool_use_id", ""),
270
+ "content": tool_content,
271
+ }
272
+ )
273
+ elif isinstance(tool_content, list):
274
+ # List of content blocks - may include text and images
275
+ tool_content_parts = []
276
+ for b in tool_content:
277
+ if not isinstance(b, dict):
278
+ continue
279
+ b_type = b.get("type", "")
280
+ if b_type == "text":
281
+ tool_content_parts.append(
282
+ {"type": "text", "text": b.get("text", "")}
283
+ )
284
+ elif b_type == "image":
285
+ # Convert Anthropic image format to OpenAI format
286
+ source = b.get("source", {})
287
+ if source.get("type") == "base64":
288
+ tool_content_parts.append(
289
+ {
290
+ "type": "image_url",
291
+ "image_url": {
292
+ "url": f"data:{source.get('media_type', 'image/png')};base64,{source.get('data', '')}"
293
+ },
294
+ }
295
+ )
296
+ elif source.get("type") == "url":
297
+ tool_content_parts.append(
298
+ {
299
+ "type": "image_url",
300
+ "image_url": {
301
+ "url": source.get("url", "")
302
+ },
303
+ }
304
+ )
305
+
306
+ # If we only have text parts, join them as a string for compatibility
307
+ # Otherwise use the array format for multimodal content
308
+ if all(p.get("type") == "text" for p in tool_content_parts):
309
+ combined_text = " ".join(
310
+ p.get("text", "") for p in tool_content_parts
311
+ )
312
+ openai_messages.append(
313
+ {
314
+ "role": "tool",
315
+ "tool_call_id": block.get("tool_use_id", ""),
316
+ "content": combined_text,
317
+ }
318
+ )
319
+ elif tool_content_parts:
320
+ # Multimodal content (includes images)
321
+ openai_messages.append(
322
+ {
323
+ "role": "tool",
324
+ "tool_call_id": block.get("tool_use_id", ""),
325
+ "content": tool_content_parts,
326
+ }
327
+ )
328
+ else:
329
+ # Empty content
330
+ openai_messages.append(
331
+ {
332
+ "role": "tool",
333
+ "tool_call_id": block.get("tool_use_id", ""),
334
+ "content": "",
335
+ }
336
+ )
337
+ else:
338
+ # Fallback for unexpected content type
339
+ openai_messages.append(
340
+ {
341
+ "role": "tool",
342
+ "tool_call_id": block.get("tool_use_id", ""),
343
+ "content": str(tool_content)
344
+ if tool_content
345
+ else "",
346
+ }
347
+ )
348
+ continue # Don't add to current message
349
+
350
+ # Build the message
351
+ if tool_calls:
352
+ # Assistant message with tool calls
353
+ msg_dict = {"role": role}
354
+ if openai_content:
355
+ # If there's text content alongside tool calls
356
+ text_parts = [
357
+ c.get("text", "")
358
+ for c in openai_content
359
+ if c.get("type") == "text"
360
+ ]
361
+ msg_dict["content"] = " ".join(text_parts) if text_parts else None
362
+ else:
363
+ msg_dict["content"] = None
364
+ if reasoning_content:
365
+ msg_dict["reasoning_content"] = reasoning_content
366
+ if thinking_signature:
367
+ msg_dict["thinking_signature"] = thinking_signature
368
+ msg_dict["tool_calls"] = tool_calls
369
+ openai_messages.append(msg_dict)
370
+ elif openai_content:
371
+ # Check if it's just text or mixed content
372
+ if len(openai_content) == 1 and openai_content[0].get("type") == "text":
373
+ msg_dict = {
374
+ "role": role,
375
+ "content": openai_content[0].get("text", ""),
376
+ }
377
+ if reasoning_content:
378
+ msg_dict["reasoning_content"] = reasoning_content
379
+ if thinking_signature:
380
+ msg_dict["thinking_signature"] = thinking_signature
381
+ openai_messages.append(msg_dict)
382
+ else:
383
+ msg_dict = {"role": role, "content": openai_content}
384
+ if reasoning_content:
385
+ msg_dict["reasoning_content"] = reasoning_content
386
+ if thinking_signature:
387
+ msg_dict["thinking_signature"] = thinking_signature
388
+ openai_messages.append(msg_dict)
389
+ elif reasoning_content:
390
+ msg_dict = {"role": role, "content": ""}
391
+ msg_dict["reasoning_content"] = reasoning_content
392
+ if thinking_signature:
393
+ msg_dict["thinking_signature"] = thinking_signature
394
+ openai_messages.append(msg_dict)
395
+
396
+ return openai_messages
397
+
398
+
399
+ def anthropic_to_openai_tools(
400
+ anthropic_tools: Optional[List[dict]],
401
+ ) -> Optional[List[dict]]:
402
+ """
403
+ Convert Anthropic tool definitions to OpenAI format.
404
+
405
+ Args:
406
+ anthropic_tools: List of tools in Anthropic format
407
+
408
+ Returns:
409
+ List of tools in OpenAI format, or None if no tools provided
410
+ """
411
+ if not anthropic_tools:
412
+ return None
413
+
414
+ openai_tools = []
415
+ for tool in anthropic_tools:
416
+ openai_tools.append(
417
+ {
418
+ "type": "function",
419
+ "function": {
420
+ "name": tool.get("name", ""),
421
+ "description": tool.get("description", ""),
422
+ "parameters": tool.get("input_schema", {}),
423
+ },
424
+ }
425
+ )
426
+ return openai_tools
427
+
428
+
429
+ def anthropic_to_openai_tool_choice(
430
+ anthropic_tool_choice: Optional[dict],
431
+ ) -> Optional[Union[str, dict]]:
432
+ """
433
+ Convert Anthropic tool_choice to OpenAI format.
434
+
435
+ Args:
436
+ anthropic_tool_choice: Tool choice in Anthropic format
437
+
438
+ Returns:
439
+ Tool choice in OpenAI format
440
+ """
441
+ if not anthropic_tool_choice:
442
+ return None
443
+
444
+ choice_type = anthropic_tool_choice.get("type", "auto")
445
+
446
+ if choice_type == "auto":
447
+ return "auto"
448
+ elif choice_type == "any":
449
+ return "required"
450
+ elif choice_type == "tool":
451
+ return {
452
+ "type": "function",
453
+ "function": {"name": anthropic_tool_choice.get("name", "")},
454
+ }
455
+ elif choice_type == "none":
456
+ return "none"
457
+
458
+ return "auto"
459
+
460
+
461
+ def openai_to_anthropic_response(openai_response: dict, original_model: str) -> dict:
462
+ """
463
+ Convert OpenAI chat completion response to Anthropic Messages format.
464
+
465
+ Args:
466
+ openai_response: Response from OpenAI-compatible API
467
+ original_model: The model name requested by the client
468
+
469
+ Returns:
470
+ Response in Anthropic Messages format
471
+ """
472
+ choice = openai_response.get("choices", [{}])[0]
473
+ message = choice.get("message", {})
474
+ usage = openai_response.get("usage", {})
475
+
476
+ # Build content blocks
477
+ content_blocks = []
478
+
479
+ # Add thinking content block if reasoning_content is present
480
+ reasoning_content = message.get("reasoning_content")
481
+ if reasoning_content:
482
+ thinking_signature = message.get("thinking_signature", "")
483
+ signature = (
484
+ thinking_signature
485
+ if thinking_signature
486
+ and len(thinking_signature) >= MIN_THINKING_SIGNATURE_LENGTH
487
+ else ""
488
+ )
489
+ content_blocks.append(
490
+ {
491
+ "type": "thinking",
492
+ "thinking": reasoning_content,
493
+ "signature": signature,
494
+ }
495
+ )
496
+
497
+ # Add text content if present
498
+ text_content = message.get("content")
499
+ if text_content:
500
+ content_blocks.append({"type": "text", "text": text_content})
501
+
502
+ # Add tool use blocks if present
503
+ tool_calls = message.get("tool_calls") or []
504
+ for tc in tool_calls:
505
+ func = tc.get("function", {})
506
+ try:
507
+ input_data = json.loads(func.get("arguments", "{}"))
508
+ except json.JSONDecodeError:
509
+ input_data = {}
510
+
511
+ content_blocks.append(
512
+ {
513
+ "type": "tool_use",
514
+ "id": tc.get("id", f"toolu_{uuid.uuid4().hex[:12]}"),
515
+ "name": func.get("name", ""),
516
+ "input": input_data,
517
+ }
518
+ )
519
+
520
+ # Map finish_reason to stop_reason
521
+ finish_reason = choice.get("finish_reason", "end_turn")
522
+ stop_reason_map = {
523
+ "stop": "end_turn",
524
+ "length": "max_tokens",
525
+ "tool_calls": "tool_use",
526
+ "content_filter": "end_turn",
527
+ "function_call": "tool_use",
528
+ }
529
+ stop_reason = stop_reason_map.get(finish_reason, "end_turn")
530
+
531
+ # Build usage
532
+ # Note: Google's promptTokenCount INCLUDES cached tokens, but Anthropic's
533
+ # input_tokens EXCLUDES cached tokens. We need to subtract cached tokens.
534
+ prompt_tokens = usage.get("prompt_tokens", 0)
535
+ cached_tokens = 0
536
+
537
+ # Extract cached tokens if present
538
+ if usage.get("prompt_tokens_details"):
539
+ details = usage["prompt_tokens_details"]
540
+ cached_tokens = details.get("cached_tokens", 0)
541
+
542
+ anthropic_usage = {
543
+ "input_tokens": prompt_tokens - cached_tokens, # Subtract cached tokens
544
+ "output_tokens": usage.get("completion_tokens", 0),
545
+ }
546
+
547
+ # Add cache tokens if present
548
+ if cached_tokens > 0:
549
+ anthropic_usage["cache_read_input_tokens"] = cached_tokens
550
+ anthropic_usage["cache_creation_input_tokens"] = 0
551
+
552
+ return {
553
+ "id": openai_response.get("id", f"msg_{uuid.uuid4().hex[:24]}"),
554
+ "type": "message",
555
+ "role": "assistant",
556
+ "content": content_blocks,
557
+ "model": original_model,
558
+ "stop_reason": stop_reason,
559
+ "stop_sequence": None,
560
+ "usage": anthropic_usage,
561
+ }
562
+
563
+
564
+ def translate_anthropic_request(request: AnthropicMessagesRequest) -> Dict[str, Any]:
565
+ """
566
+ Translate a complete Anthropic Messages API request to OpenAI format.
567
+
568
+ This is a high-level function that handles all aspects of request translation,
569
+ including messages, tools, tool_choice, and thinking configuration.
570
+
571
+ Args:
572
+ request: An AnthropicMessagesRequest object
573
+
574
+ Returns:
575
+ Dictionary containing the OpenAI-compatible request parameters
576
+ """
577
+ anthropic_request = request.model_dump(exclude_none=True)
578
+
579
+ messages = anthropic_request.get("messages", [])
580
+ openai_messages = anthropic_to_openai_messages(
581
+ messages, anthropic_request.get("system")
582
+ )
583
+
584
+ openai_tools = anthropic_to_openai_tools(anthropic_request.get("tools"))
585
+ openai_tool_choice = anthropic_to_openai_tool_choice(
586
+ anthropic_request.get("tool_choice")
587
+ )
588
+
589
+ # Build OpenAI-compatible request
590
+ openai_request = {
591
+ "model": request.model,
592
+ "messages": openai_messages,
593
+ "max_tokens": request.max_tokens,
594
+ "stream": request.stream or False,
595
+ }
596
+
597
+ if request.temperature is not None:
598
+ openai_request["temperature"] = request.temperature
599
+ if request.top_p is not None:
600
+ openai_request["top_p"] = request.top_p
601
+ if request.top_k is not None:
602
+ openai_request["top_k"] = request.top_k
603
+ if request.stop_sequences:
604
+ openai_request["stop"] = request.stop_sequences
605
+ if openai_tools:
606
+ openai_request["tools"] = openai_tools
607
+ if openai_tool_choice:
608
+ openai_request["tool_choice"] = openai_tool_choice
609
+
610
+ # Note: request.metadata is intentionally not mapped.
611
+ # OpenAI's API doesn't have an equivalent field for client-side metadata.
612
+ # The metadata is typically used by Anthropic clients for tracking purposes
613
+ # and doesn't affect the model's behavior.
614
+
615
+ # Handle Anthropic thinking config -> reasoning_effort translation
616
+ # Only set reasoning_effort if thinking is explicitly configured
617
+ if request.thinking:
618
+ if request.thinking.type == "enabled":
619
+ # Only set reasoning_effort if budget_tokens was specified
620
+ if request.thinking.budget_tokens is not None:
621
+ openai_request["reasoning_effort"] = _budget_to_reasoning_effort(
622
+ request.thinking.budget_tokens, request.model
623
+ )
624
+ # If thinking enabled but no budget specified, don't set anything
625
+ # Let the provider decide the default
626
+ elif request.thinking.type == "disabled":
627
+ openai_request["reasoning_effort"] = "disable"
628
+
629
+ return openai_request
src/rotator_library/background_refresher.py ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: LGPL-3.0-only
2
+ # Copyright (c) 2026 Mirrowel
3
+
4
+ # src/rotator_library/background_refresher.py
5
+
6
+ import os
7
+ import asyncio
8
+ import logging
9
+ from typing import TYPE_CHECKING, Optional, Dict, Any, List
10
+
11
+ if TYPE_CHECKING:
12
+ from .client import RotatingClient
13
+
14
+ lib_logger = logging.getLogger("rotator_library")
15
+
16
+ # =============================================================================
17
+ # CONFIGURATION DEFAULTS
18
+ # =============================================================================
19
+ # These can be overridden via environment variables.
20
+
21
+ # OAuth token refresh interval in seconds
22
+ # Override: OAUTH_REFRESH_INTERVAL=<seconds>
23
+ DEFAULT_OAUTH_REFRESH_INTERVAL: int = 600 # 10 minutes
24
+
25
+ # Default interval for provider background jobs (quota refresh, etc.)
26
+ # Individual providers can override this in their get_background_job_config()
27
+ DEFAULT_BACKGROUND_JOB_INTERVAL: int = 300 # 5 minutes
28
+
29
+ # Whether to run background jobs immediately on start (before first interval)
30
+ DEFAULT_BACKGROUND_JOB_RUN_ON_START: bool = True
31
+
32
+
33
+ class BackgroundRefresher:
34
+ """
35
+ A background task manager that handles:
36
+ 1. Periodic OAuth token refresh for all providers
37
+ 2. Provider-specific background jobs (e.g., quota refresh) with independent timers
38
+
39
+ Each provider can define its own background job via get_background_job_config()
40
+ and run_background_job(). These run on their own schedules, independent of the
41
+ OAuth refresh interval.
42
+ """
43
+
44
+ def __init__(self, client: "RotatingClient"):
45
+ self._client = client
46
+ self._task: Optional[asyncio.Task] = None
47
+ self._provider_job_tasks: Dict[str, asyncio.Task] = {} # provider -> task
48
+ self._initialized = False
49
+ try:
50
+ interval_str = os.getenv(
51
+ "OAUTH_REFRESH_INTERVAL", str(DEFAULT_OAUTH_REFRESH_INTERVAL)
52
+ )
53
+ self._interval = int(interval_str)
54
+ except ValueError:
55
+ lib_logger.warning(
56
+ f"Invalid OAUTH_REFRESH_INTERVAL '{interval_str}'. "
57
+ f"Falling back to {DEFAULT_OAUTH_REFRESH_INTERVAL}s."
58
+ )
59
+ self._interval = DEFAULT_OAUTH_REFRESH_INTERVAL
60
+
61
+ def start(self):
62
+ """Starts the background refresh task."""
63
+ if self._task is None:
64
+ self._task = asyncio.create_task(self._run())
65
+ lib_logger.info(
66
+ f"Background token refresher started. Check interval: {self._interval} seconds."
67
+ )
68
+
69
+ async def stop(self):
70
+ """Stops all background tasks (main loop + provider jobs)."""
71
+ # Cancel provider job tasks first
72
+ for provider, task in self._provider_job_tasks.items():
73
+ if task and not task.done():
74
+ task.cancel()
75
+ try:
76
+ await task
77
+ except asyncio.CancelledError:
78
+ pass
79
+ lib_logger.debug(f"Stopped background job for '{provider}'")
80
+
81
+ self._provider_job_tasks.clear()
82
+
83
+ # Cancel main task
84
+ if self._task:
85
+ self._task.cancel()
86
+ try:
87
+ await self._task
88
+ except asyncio.CancelledError:
89
+ pass
90
+ lib_logger.info("Background token refresher stopped.")
91
+
92
+ async def _initialize_credentials(self):
93
+ """
94
+ Initialize all providers by loading credentials and persisted tier data.
95
+ Called once before the main refresh loop starts.
96
+ """
97
+ if self._initialized:
98
+ return
99
+
100
+ api_summary = {} # provider -> count
101
+ oauth_summary = {} # provider -> {"count": N, "tiers": {tier: count}}
102
+
103
+ all_credentials = self._client.all_credentials
104
+ oauth_providers = self._client.oauth_providers
105
+
106
+ for provider, credentials in all_credentials.items():
107
+ if not credentials:
108
+ continue
109
+
110
+ provider_plugin = self._client._get_provider_instance(provider)
111
+
112
+ # Call initialize_credentials if provider supports it
113
+ if provider_plugin and hasattr(provider_plugin, "initialize_credentials"):
114
+ try:
115
+ await provider_plugin.initialize_credentials(credentials)
116
+ except Exception as e:
117
+ lib_logger.error(
118
+ f"Error initializing credentials for provider '{provider}': {e}"
119
+ )
120
+
121
+ # Build summary based on provider type
122
+ if provider in oauth_providers:
123
+ tier_breakdown = {}
124
+ if provider_plugin and hasattr(
125
+ provider_plugin, "get_credential_tier_name"
126
+ ):
127
+ for cred in credentials:
128
+ tier = provider_plugin.get_credential_tier_name(cred)
129
+ if tier:
130
+ tier_breakdown[tier] = tier_breakdown.get(tier, 0) + 1
131
+ oauth_summary[provider] = {
132
+ "count": len(credentials),
133
+ "tiers": tier_breakdown,
134
+ }
135
+ else:
136
+ api_summary[provider] = len(credentials)
137
+
138
+ # Log 3-line summary
139
+ total_providers = len(api_summary) + len(oauth_summary)
140
+ total_credentials = sum(api_summary.values()) + sum(
141
+ d["count"] for d in oauth_summary.values()
142
+ )
143
+
144
+ if total_providers > 0:
145
+ lib_logger.info(
146
+ f"Providers initialized: {total_providers} providers, {total_credentials} credentials"
147
+ )
148
+
149
+ # API providers line
150
+ if api_summary:
151
+ api_parts = [f"{p}:{c}" for p, c in sorted(api_summary.items())]
152
+ lib_logger.info(f" API: {', '.join(api_parts)}")
153
+
154
+ # OAuth providers line with tier breakdown
155
+ if oauth_summary:
156
+ oauth_parts = []
157
+ for provider, data in sorted(oauth_summary.items()):
158
+ if data["tiers"]:
159
+ tier_str = ", ".join(
160
+ f"{t}:{c}" for t, c in sorted(data["tiers"].items())
161
+ )
162
+ oauth_parts.append(f"{provider}:{data['count']} ({tier_str})")
163
+ else:
164
+ oauth_parts.append(f"{provider}:{data['count']}")
165
+ lib_logger.info(f" OAuth: {', '.join(oauth_parts)}")
166
+
167
+ self._initialized = True
168
+
169
+ def _start_provider_background_jobs(self):
170
+ """
171
+ Start independent background job tasks for providers that define them.
172
+
173
+ Each provider with a get_background_job_config() that returns a config
174
+ gets its own asyncio task running on its own schedule.
175
+ """
176
+ all_credentials = self._client.all_credentials
177
+
178
+ for provider, credentials in all_credentials.items():
179
+ if not credentials:
180
+ lib_logger.debug(f"Skipping {provider} background job: no credentials")
181
+ continue
182
+
183
+ provider_plugin = self._client._get_provider_instance(provider)
184
+ if not provider_plugin:
185
+ lib_logger.debug(
186
+ f"Skipping {provider} background job: no provider instance"
187
+ )
188
+ continue
189
+
190
+ # Check if provider has a background job
191
+ if not hasattr(provider_plugin, "get_background_job_config"):
192
+ lib_logger.debug(
193
+ f"Skipping {provider} background job: no get_background_job_config method"
194
+ )
195
+ continue
196
+
197
+ config = provider_plugin.get_background_job_config()
198
+ if not config:
199
+ lib_logger.debug(f"Skipping {provider} background job: config is None")
200
+ continue
201
+
202
+ # Start the provider's background job task
203
+ task = asyncio.create_task(
204
+ self._run_provider_background_job(
205
+ provider, provider_plugin, credentials, config
206
+ )
207
+ )
208
+ self._provider_job_tasks[provider] = task
209
+
210
+ job_name = config.get("name", "background_job")
211
+ interval = config.get("interval", DEFAULT_BACKGROUND_JOB_INTERVAL)
212
+ lib_logger.info(f"Started {provider} {job_name} (interval: {interval}s)")
213
+
214
+ async def _run_provider_background_job(
215
+ self,
216
+ provider_name: str,
217
+ provider: Any,
218
+ credentials: List[str],
219
+ config: Dict[str, Any],
220
+ ) -> None:
221
+ """
222
+ Independent loop for a single provider's background job.
223
+
224
+ Args:
225
+ provider_name: Name of the provider (for logging)
226
+ provider: Provider plugin instance
227
+ credentials: List of credential paths for this provider
228
+ config: Background job configuration from get_background_job_config()
229
+ """
230
+ interval = config.get("interval", DEFAULT_BACKGROUND_JOB_INTERVAL)
231
+ job_name = config.get("name", "background_job")
232
+ run_on_start = config.get("run_on_start", DEFAULT_BACKGROUND_JOB_RUN_ON_START)
233
+
234
+ # Run immediately on start if configured
235
+ if run_on_start:
236
+ try:
237
+ await provider.run_background_job(
238
+ self._client.usage_manager, credentials
239
+ )
240
+ lib_logger.debug(f"{provider_name} {job_name}: initial run complete")
241
+ except Exception as e:
242
+ lib_logger.error(
243
+ f"Error in {provider_name} {job_name} (initial run): {e}"
244
+ )
245
+
246
+ # Main loop
247
+ while True:
248
+ try:
249
+ await asyncio.sleep(interval)
250
+ await provider.run_background_job(
251
+ self._client.usage_manager, credentials
252
+ )
253
+ lib_logger.debug(f"{provider_name} {job_name}: periodic run complete")
254
+ except asyncio.CancelledError:
255
+ lib_logger.debug(f"{provider_name} {job_name}: cancelled")
256
+ break
257
+ except Exception as e:
258
+ lib_logger.error(f"Error in {provider_name} {job_name}: {e}")
259
+
260
+ async def _run(self):
261
+ """The main loop for OAuth token refresh."""
262
+ # Initialize credentials (load persisted tiers) before starting
263
+ await self._initialize_credentials()
264
+
265
+ # Start provider-specific background jobs with their own timers
266
+ self._start_provider_background_jobs()
267
+
268
+ # Main OAuth refresh loop
269
+ while True:
270
+ try:
271
+ oauth_configs = self._client.get_oauth_credentials()
272
+ for provider, paths in oauth_configs.items():
273
+ provider_plugin = self._client._get_provider_instance(provider)
274
+ if provider_plugin and hasattr(
275
+ provider_plugin, "proactively_refresh"
276
+ ):
277
+ for path in paths:
278
+ try:
279
+ await provider_plugin.proactively_refresh(path)
280
+ except Exception as e:
281
+ lib_logger.error(
282
+ f"Error during proactive refresh for '{path}': {e}"
283
+ )
284
+
285
+ await asyncio.sleep(self._interval)
286
+ except asyncio.CancelledError:
287
+ break
288
+ except Exception as e:
289
+ lib_logger.error(f"Unexpected error in background refresher loop: {e}")
src/rotator_library/client.py ADDED
The diff for this file is too large to render. See raw diff
 
src/rotator_library/config/__init__.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: LGPL-3.0-only
2
+ # Copyright (c) 2026 Mirrowel
3
+
4
+ """
5
+ Configuration module for the rotator library.
6
+
7
+ Exports all centralized defaults for use across the library.
8
+ """
9
+
10
+ from .defaults import (
11
+ # Rotation & Selection
12
+ DEFAULT_ROTATION_MODE,
13
+ DEFAULT_ROTATION_TOLERANCE,
14
+ DEFAULT_MAX_RETRIES,
15
+ DEFAULT_GLOBAL_TIMEOUT,
16
+ # Tier & Priority
17
+ DEFAULT_TIER_PRIORITY,
18
+ DEFAULT_SEQUENTIAL_FALLBACK_MULTIPLIER,
19
+ # Fair Cycle Rotation
20
+ DEFAULT_FAIR_CYCLE_ENABLED,
21
+ DEFAULT_FAIR_CYCLE_TRACKING_MODE,
22
+ DEFAULT_FAIR_CYCLE_CROSS_TIER,
23
+ DEFAULT_FAIR_CYCLE_DURATION,
24
+ DEFAULT_EXHAUSTION_COOLDOWN_THRESHOLD,
25
+ # Custom Caps
26
+ DEFAULT_CUSTOM_CAP_COOLDOWN_MODE,
27
+ DEFAULT_CUSTOM_CAP_COOLDOWN_VALUE,
28
+ # Cooldown & Backoff
29
+ COOLDOWN_BACKOFF_TIERS,
30
+ COOLDOWN_BACKOFF_MAX,
31
+ COOLDOWN_AUTH_ERROR,
32
+ COOLDOWN_TRANSIENT_ERROR,
33
+ COOLDOWN_RATE_LIMIT_DEFAULT,
34
+ )
35
+
36
+ __all__ = [
37
+ # Rotation & Selection
38
+ "DEFAULT_ROTATION_MODE",
39
+ "DEFAULT_ROTATION_TOLERANCE",
40
+ "DEFAULT_MAX_RETRIES",
41
+ "DEFAULT_GLOBAL_TIMEOUT",
42
+ # Tier & Priority
43
+ "DEFAULT_TIER_PRIORITY",
44
+ "DEFAULT_SEQUENTIAL_FALLBACK_MULTIPLIER",
45
+ # Fair Cycle Rotation
46
+ "DEFAULT_FAIR_CYCLE_ENABLED",
47
+ "DEFAULT_FAIR_CYCLE_TRACKING_MODE",
48
+ "DEFAULT_FAIR_CYCLE_CROSS_TIER",
49
+ "DEFAULT_FAIR_CYCLE_DURATION",
50
+ "DEFAULT_EXHAUSTION_COOLDOWN_THRESHOLD",
51
+ # Custom Caps
52
+ "DEFAULT_CUSTOM_CAP_COOLDOWN_MODE",
53
+ "DEFAULT_CUSTOM_CAP_COOLDOWN_VALUE",
54
+ # Cooldown & Backoff
55
+ "COOLDOWN_BACKOFF_TIERS",
56
+ "COOLDOWN_BACKOFF_MAX",
57
+ "COOLDOWN_AUTH_ERROR",
58
+ "COOLDOWN_TRANSIENT_ERROR",
59
+ "COOLDOWN_RATE_LIMIT_DEFAULT",
60
+ ]
src/rotator_library/config/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (1.23 kB). View file