Spaces:
Sleeping
Sleeping
Upload 144 files
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitattributes +5 -0
- Dockerfile +49 -0
- logs/failures.log +10 -0
- logs/proxy.log +315 -0
- logs/proxy_debug.log +679 -0
- requirements.txt +27 -0
- src/batch_auth.py +40 -0
- src/proxy_app/LICENSE +21 -0
- src/proxy_app/__init__.py +3 -0
- src/proxy_app/batch_manager.py +84 -0
- src/proxy_app/build.py +95 -0
- src/proxy_app/detailed_logger.py +187 -0
- src/proxy_app/launcher_tui.py +1084 -0
- src/proxy_app/main.py +1731 -0
- src/proxy_app/model_filter_gui.py +0 -0
- src/proxy_app/provider_urls.py +76 -0
- src/proxy_app/quota_viewer.py +1596 -0
- src/proxy_app/quota_viewer_config.py +300 -0
- src/proxy_app/request_logger.py +34 -0
- src/proxy_app/settings_tool.py +0 -0
- src/rotator_library/COPYING +674 -0
- src/rotator_library/COPYING.LESSER +165 -0
- src/rotator_library/README.md +345 -0
- src/rotator_library/__init__.py +48 -0
- src/rotator_library/__pycache__/__init__.cpython-311.pyc +0 -0
- src/rotator_library/__pycache__/__init__.cpython-314.pyc +0 -0
- src/rotator_library/__pycache__/background_refresher.cpython-311.pyc +0 -0
- src/rotator_library/__pycache__/client.cpython-311.pyc +3 -0
- src/rotator_library/__pycache__/client.cpython-314.pyc +3 -0
- src/rotator_library/__pycache__/cooldown_manager.cpython-311.pyc +0 -0
- src/rotator_library/__pycache__/credential_manager.cpython-311.pyc +0 -0
- src/rotator_library/__pycache__/credential_tool.cpython-311.pyc +3 -0
- src/rotator_library/__pycache__/error_handler.cpython-311.pyc +0 -0
- src/rotator_library/__pycache__/failure_logger.cpython-311.pyc +0 -0
- src/rotator_library/__pycache__/litellm_providers.cpython-311.pyc +0 -0
- src/rotator_library/__pycache__/model_definitions.cpython-311.pyc +0 -0
- src/rotator_library/__pycache__/provider_config.cpython-311.pyc +0 -0
- src/rotator_library/__pycache__/provider_factory.cpython-311.pyc +0 -0
- src/rotator_library/__pycache__/request_sanitizer.cpython-311.pyc +0 -0
- src/rotator_library/__pycache__/timeout_config.cpython-311.pyc +0 -0
- src/rotator_library/__pycache__/transaction_logger.cpython-311.pyc +0 -0
- src/rotator_library/__pycache__/usage_manager.cpython-311.pyc +3 -0
- src/rotator_library/anthropic_compat/__init__.py +70 -0
- src/rotator_library/anthropic_compat/models.py +147 -0
- src/rotator_library/anthropic_compat/streaming.py +433 -0
- src/rotator_library/anthropic_compat/translator.py +629 -0
- src/rotator_library/background_refresher.py +289 -0
- src/rotator_library/client.py +0 -0
- src/rotator_library/config/__init__.py +60 -0
- 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
|
|
|