diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..fd3edcf17e04f05f95914caa30e3ecc66360159c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +src/rotator_library/__pycache__/client.cpython-311.pyc filter=lfs diff=lfs merge=lfs -text +src/rotator_library/__pycache__/client.cpython-314.pyc filter=lfs diff=lfs merge=lfs -text +src/rotator_library/__pycache__/credential_tool.cpython-311.pyc filter=lfs diff=lfs merge=lfs -text +src/rotator_library/__pycache__/usage_manager.cpython-311.pyc filter=lfs diff=lfs merge=lfs -text +src/rotator_library/providers/__pycache__/antigravity_provider.cpython-311.pyc filter=lfs diff=lfs merge=lfs -text diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..d2eeaefdb3db86535fdcad3d983b0768fe3b7cd5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +# Build stage +FROM python:3.11-slim AS builder + +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Set PATH for user-installed packages in builder stage +ENV PATH=/root/.local/bin:$PATH + +# Copy requirements first for better caching +COPY requirements.txt . + +# Copy the local rotator_library for editable install +COPY src/rotator_library ./src/rotator_library + +# Install dependencies +RUN pip install --no-cache-dir --user -r requirements.txt + +# Production stage +FROM python:3.11-slim + +WORKDIR /app + +# Copy installed packages from builder +COPY --from=builder /root/.local /root/.local + +# Make sure scripts in .local are usable +ENV PATH=/root/.local/bin:$PATH + +# Copy application code +COPY src/ ./src/ + +# Create directories for logs and oauth credentials +RUN mkdir -p logs oauth_creds + +# Expose the default Hugging Face port +EXPOSE 7860 + +# Set environment variables +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONPATH=/app/src + +# Default command - runs proxy on HF's expected port +CMD ["python", "src/proxy_app/main.py", "--host", "0.0.0.0", "--port", "7860"] diff --git a/logs/failures.log b/logs/failures.log new file mode 100644 index 0000000000000000000000000000000000000000..0d752b4bf2040bc2fda8604bff21294e304ec760 --- /dev/null +++ b/logs/failures.log @@ -0,0 +1,10 @@ +{"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"}]} +{"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"}]} +{"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"}]} +{"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"}]} +{"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"}]} +{"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"}]} +{"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"}]} +{"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"}]} +{"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"}]} +{"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"}]} diff --git a/logs/proxy.log b/logs/proxy.log new file mode 100644 index 0000000000000000000000000000000000000000..bdc9fe975cc85c093b2f52a2d7cb8f45b952d31e --- /dev/null +++ b/logs/proxy.log @@ -0,0 +1,315 @@ +2026-01-23 15:44:10,808 - rotator_library - INFO - Provider 'antigravity' using rotation mode: sequential +2026-01-23 15:44:10,809 - rotator_library - INFO - Provider 'antigravity' priority multipliers: {1: 5, 2: 3} +2026-01-23 15:44:10,809 - rotator_library - INFO - Provider 'antigravity' sequential fallback multiplier: 2x +2026-01-23 15:44:10,819 - rotator_library - INFO - Background token refresher started. Check interval: 600 seconds. +2026-01-23 15:44:10,819 - root - INFO - RotatingClient initialized (EmbeddingBatcher disabled). +2026-01-23 15:44:10,820 - rotator_library.model_info_service - INFO - ModelRegistry started (refresh every 21600s) +2026-01-23 15:44:10,820 - root - INFO - Model info service started (fetching pricing data in background). +2026-01-23 15:44:10,822 - rotator_library - INFO - Providers initialized: 1 providers, 2 credentials +2026-01-23 15:44:10,823 - rotator_library - INFO - OAuth: antigravity:2 (standard-tier:2) +2026-01-23 15:44:10,823 - rotator_library - INFO - Started antigravity antigravity_quota_refresh (interval: 300s) +2026-01-23 15:44:10,825 - rotator_library - INFO - antigravity: Fetching initial quota baselines for 2 credentials... +2026-01-23 15:44:11,651 - rotator_library.model_info_service - INFO - OpenRouter: 345 models loaded +2026-01-23 15:44:11,651 - rotator_library.model_info_service - INFO - Models.dev: 2255 models loaded +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 +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) +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) +2026-01-23 15:44:28,318 - rotator_library - INFO - Attempting call with credential antigravity_oauth_1.json (Attempt 1/2) +2026-01-23 15:44:29,652 - rotator_library - INFO - Switching to fallback URL: https://daily-cloudcode-pa.googleapis.com/v1internal +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' +For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404 +2026-01-23 15:44:30,953 - rotator_library - INFO - Switching to fallback URL: https://cloudcode-pa.googleapis.com/v1internal +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' +For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404 +2026-01-23 15:44:31,958 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 1/6. Retrying... +2026-01-23 15:44:35,483 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 2/6. Retrying... +2026-01-23 15:44:39,025 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 3/6. Retrying... +2026-01-23 15:44:42,173 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 4/6. Retrying... +2026-01-23 15:44:45,306 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 5/6. Retrying... +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. +2026-01-23 15:44:48,445 - rotator_library - WARNING - Cred antigravity_oauth_1.json server_error (HTTP 503). +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 +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) +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) +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) +2026-01-23 15:44:48,448 - rotator_library - INFO - Attempting call with credential antigravity_oauth_2.json (Attempt 1/2) +2026-01-23 15:44:49,270 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 1/6. Retrying... +2026-01-23 15:44:52,782 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 2/6. Retrying... +2026-01-23 15:44:56,295 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 3/6. Retrying... +2026-01-23 15:44:59,436 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 4/6. Retrying... +2026-01-23 15:45:02,593 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 5/6. Retrying... +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. +2026-01-23 15:45:05,740 - rotator_library - WARNING - Cred antigravity_oauth_2.json server_error (HTTP 503). +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 +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) +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 +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 +2026-01-23 15:45:21,584 - rotator_library - INFO - Acquiring key for model antigravity/gemini-1.5-pro. Tried keys: 0/2(2) +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) +2026-01-23 15:45:21,586 - rotator_library - INFO - Attempting call with credential antigravity_oauth_1.json (Attempt 1/2) +2026-01-23 15:45:22,152 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-1.5-pro, attempt 1/6. Retrying... +2026-01-23 15:45:25,281 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-1.5-pro, attempt 2/6. Retrying... +2026-01-23 15:45:28,411 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-1.5-pro, attempt 3/6. Retrying... +2026-01-23 15:45:31,562 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-1.5-pro, attempt 4/6. Retrying... +2026-01-23 15:45:34,708 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-1.5-pro, attempt 5/6. Retrying... +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. +2026-01-23 15:45:37,862 - rotator_library - WARNING - Cred antigravity_oauth_1.json server_error (HTTP 503). +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 +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) +2026-01-23 15:45:37,864 - rotator_library - INFO - Acquiring key for model antigravity/gemini-1.5-pro. Tried keys: 1/1(2) +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) +2026-01-23 15:45:37,866 - rotator_library - INFO - Attempting call with credential antigravity_oauth_2.json (Attempt 1/2) +2026-01-23 15:45:38,007 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-1.5-pro, attempt 1/6. Retrying... +2026-01-23 15:45:41,153 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-1.5-pro, attempt 2/6. Retrying... +2026-01-23 15:45:44,308 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-1.5-pro, attempt 3/6. Retrying... +2026-01-23 15:45:47,477 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-1.5-pro, attempt 4/6. Retrying... +2026-01-23 15:45:50,630 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-1.5-pro, attempt 5/6. Retrying... +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. +2026-01-23 15:45:54,038 - rotator_library - WARNING - Cred antigravity_oauth_2.json server_error (HTTP 503). +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 +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) +2026-01-23 15:45:54,041 - rotator_library - ERROR - TIMEOUT: 2 creds tried for antigravity/gemini-1.5-pro | Normal: 2 server_error +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 +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) +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) +2026-01-23 15:56:23,677 - rotator_library - INFO - Attempting call with credential antigravity_oauth_1.json (Attempt 1/2) +2026-01-23 15:56:24,692 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 1/6. Retrying... +2026-01-23 15:56:28,214 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 2/6. Retrying... +2026-01-23 15:56:31,724 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 3/6. Retrying... +2026-01-23 15:56:34,859 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 4/6. Retrying... +2026-01-23 15:56:38,005 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 5/6. Retrying... +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. +2026-01-23 15:56:41,161 - rotator_library - WARNING - Cred antigravity_oauth_1.json server_error (HTTP 503). +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 +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) +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) +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) +2026-01-23 15:56:41,164 - rotator_library - INFO - Attempting call with credential antigravity_oauth_2.json (Attempt 1/2) +2026-01-23 15:56:41,758 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 1/6. Retrying... +2026-01-23 15:56:45,275 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 2/6. Retrying... +2026-01-23 15:56:48,810 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 3/6. Retrying... +2026-01-23 15:56:51,963 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 4/6. Retrying... +2026-01-23 15:56:55,088 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.0-flash-exp, attempt 5/6. Retrying... +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. +2026-01-23 15:56:58,234 - rotator_library - WARNING - Cred antigravity_oauth_2.json server_error (HTTP 503). +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 +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) +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 +2026-01-23 15:58:31,358 - rotator_library - INFO - Getting all available models... +2026-01-23 15:58:31,358 - rotator_library - INFO - Getting available models for provider: antigravity +2026-01-23 15:58:31,359 - rotator_library - INFO - Got 6 models for provider: antigravity +2026-01-23 15:58:31,360 - rotator_library - INFO - Finished getting all available models. +2026-01-23 15:59:07,976 - root - INFO - 15:59 - 172.17.0.1:41760 - provider: antigravity, model: gemini-3-flash - N/A +2026-01-23 15:59:07,976 - rotator_library - INFO - Acquiring key for model antigravity/gemini-3-flash. Tried keys: 0/2(2) +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%]) +2026-01-23 15:59:07,978 - rotator_library - INFO - Attempting call with credential antigravity_oauth_2.json (Attempt 1/2) +2026-01-23 15:59:08,623 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-3-flash, attempt 1/6. Retrying... +2026-01-23 15:59:11,803 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-3-flash, attempt 2/6. Retrying... +2026-01-23 15:59:15,012 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-3-flash, attempt 3/6. Retrying... +2026-01-23 15:59:18,150 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-3-flash, attempt 4/6. Retrying... +2026-01-23 15:59:21,293 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-3-flash, attempt 5/6. Retrying... +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. +2026-01-23 15:59:24,449 - rotator_library - WARNING - Cred antigravity_oauth_2.json server_error (HTTP 503). +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 +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) +2026-01-23 15:59:24,452 - rotator_library - INFO - Acquiring key for model antigravity/gemini-3-flash. Tried keys: 1/1(2) +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%]) +2026-01-23 15:59:24,453 - rotator_library - INFO - Attempting call with credential antigravity_oauth_1.json (Attempt 1/2) +2026-01-23 15:59:24,658 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-3-flash, attempt 1/6. Retrying... +2026-01-23 15:59:27,819 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-3-flash, attempt 2/6. Retrying... +2026-01-23 15:59:31,009 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-3-flash, attempt 3/6. Retrying... +2026-01-23 15:59:34,150 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-3-flash, attempt 4/6. Retrying... +2026-01-23 15:59:37,303 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-3-flash, attempt 5/6. Retrying... +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. +2026-01-23 15:59:40,447 - rotator_library - WARNING - Cred antigravity_oauth_1.json server_error (HTTP 503). +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 +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) +2026-01-23 15:59:40,450 - rotator_library - ERROR - TIMEOUT: 2 creds tried for antigravity/gemini-3-flash | Normal: 2 server_error +2026-01-23 16:00:10,954 - rotator_library - INFO - Getting all available models... +2026-01-23 16:00:10,954 - rotator_library - INFO - Getting available models for provider: antigravity +2026-01-23 16:00:10,955 - rotator_library - INFO - Finished getting all available models. +2026-01-23 16:00:21,200 - rotator_library - INFO - Getting all available models... +2026-01-23 16:00:21,201 - rotator_library - INFO - Getting available models for provider: antigravity +2026-01-23 16:00:21,201 - rotator_library - INFO - Finished getting all available models. +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 +2026-01-23 16:01:08,174 - rotator_library - INFO - Acquiring key for model antigravity/gemini-2.5-flash. Tried keys: 0/2(2) +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%]) +2026-01-23 16:01:08,176 - rotator_library - INFO - Attempting call with credential antigravity_oauth_2.json (Attempt 1/2) +2026-01-23 16:01:08,892 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.5-flash, attempt 1/6. Retrying... +2026-01-23 16:01:12,675 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.5-flash, attempt 2/6. Retrying... +2026-01-23 16:01:15,849 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.5-flash, attempt 3/6. Retrying... +2026-01-23 16:01:19,004 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.5-flash, attempt 4/6. Retrying... +2026-01-23 16:01:22,584 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.5-flash, attempt 5/6. Retrying... +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. +2026-01-23 16:01:26,089 - rotator_library - WARNING - Cred antigravity_oauth_2.json server_error (HTTP 503). +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 +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) +2026-01-23 16:01:26,091 - rotator_library - INFO - Acquiring key for model antigravity/gemini-2.5-flash. Tried keys: 1/1(2) +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%]) +2026-01-23 16:01:26,092 - rotator_library - INFO - Attempting call with credential antigravity_oauth_1.json (Attempt 1/2) +2026-01-23 16:01:26,606 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.5-flash, attempt 1/6. Retrying... +2026-01-23 16:01:30,115 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.5-flash, attempt 2/6. Retrying... +2026-01-23 16:01:33,311 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.5-flash, attempt 3/6. Retrying... +2026-01-23 16:01:36,840 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.5-flash, attempt 4/6. Retrying... +2026-01-23 16:01:39,989 - rotator_library - WARNING - [Antigravity] Bare 429 from gemini-2.5-flash, attempt 5/6. Retrying... +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. +2026-01-23 16:01:43,156 - rotator_library - WARNING - Cred antigravity_oauth_1.json server_error (HTTP 503). +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 +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) +2026-01-23 16:01:43,157 - rotator_library - ERROR - TIMEOUT: 2 creds tried for antigravity/gemini-2.5-flash | Normal: 2 server_error +2026-01-23 16:02:46,116 - rotator_library - INFO - Background token refresher stopped. +2026-01-23 16:02:46,116 - rotator_library.model_info_service - INFO - ModelRegistry stopped +2026-01-23 16:02:46,116 - root - INFO - RotatingClient closed. +2026-01-23 16:03:05,733 - rotator_library - INFO - Provider 'antigravity' using rotation mode: sequential +2026-01-23 16:03:05,734 - rotator_library - INFO - Provider 'antigravity' priority multipliers: {1: 5, 2: 3} +2026-01-23 16:03:05,734 - rotator_library - INFO - Provider 'antigravity' sequential fallback multiplier: 2x +2026-01-23 16:03:05,745 - rotator_library - INFO - Background token refresher started. Check interval: 600 seconds. +2026-01-23 16:03:05,745 - root - INFO - RotatingClient initialized (EmbeddingBatcher disabled). +2026-01-23 16:03:05,745 - rotator_library.model_info_service - INFO - ModelRegistry started (refresh every 21600s) +2026-01-23 16:03:05,746 - root - INFO - Model info service started (fetching pricing data in background). +2026-01-23 16:03:05,748 - rotator_library - INFO - Providers initialized: 1 providers, 2 credentials +2026-01-23 16:03:05,748 - rotator_library - INFO - OAuth: antigravity:2 (standard-tier:2) +2026-01-23 16:03:05,748 - rotator_library - INFO - Started antigravity antigravity_quota_refresh (interval: 300s) +2026-01-23 16:03:05,751 - rotator_library - INFO - antigravity: Fetching initial quota baselines for 2 credentials... +2026-01-23 16:03:06,489 - rotator_library.model_info_service - INFO - OpenRouter: 345 models loaded +2026-01-23 16:03:06,489 - rotator_library.model_info_service - INFO - Models.dev: 2255 models loaded +2026-01-23 16:04:06,556 - rotator_library - INFO - Getting all available models... +2026-01-23 16:04:06,560 - rotator_library - INFO - Getting available models for provider: antigravity +2026-01-23 16:04:06,564 - rotator_library - INFO - Got 6 models for provider: antigravity +2026-01-23 16:04:06,565 - rotator_library - INFO - Finished getting all available models. +2026-01-23 16:04:22,953 - root - INFO - 16:04 - 172.17.0.1:57468 - provider: antigravity, model: gemini-3-flash - N/A +2026-01-23 16:04:22,955 - rotator_library - INFO - Acquiring key for model antigravity/gemini-3-flash. Tried keys: 0/2(2) +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%]) +2026-01-23 16:04:22,958 - rotator_library - INFO - Attempting call with credential antigravity_oauth_2.json (Attempt 1/2) +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 +2026-01-23 16:04:25,936 - rotator_library - INFO - Recorded usage from response object for key antigravity_oauth_2.json +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) +2026-01-23 16:12:00,819 - rotator_library - INFO - Background token refresher stopped. +2026-01-23 16:12:00,820 - rotator_library.model_info_service - INFO - ModelRegistry stopped +2026-01-23 16:12:00,820 - root - INFO - RotatingClient closed. +2026-01-23 16:12:06,391 - rotator_library - INFO - Provider 'antigravity' using rotation mode: sequential +2026-01-23 16:12:06,392 - rotator_library - INFO - Provider 'antigravity' priority multipliers: {1: 5, 2: 3} +2026-01-23 16:12:06,392 - rotator_library - INFO - Provider 'antigravity' sequential fallback multiplier: 2x +2026-01-23 16:12:06,402 - rotator_library - INFO - Background token refresher started. Check interval: 600 seconds. +2026-01-23 16:12:06,402 - root - INFO - RotatingClient initialized (EmbeddingBatcher disabled). +2026-01-23 16:12:06,403 - rotator_library.model_info_service - INFO - ModelRegistry started (refresh every 21600s) +2026-01-23 16:12:06,403 - root - INFO - Model info service started (fetching pricing data in background). +2026-01-23 16:12:06,404 - rotator_library - INFO - Providers initialized: 1 providers, 1 credentials +2026-01-23 16:12:06,405 - rotator_library - INFO - OAuth: antigravity:1 (standard-tier:1) +2026-01-23 16:12:06,405 - rotator_library - INFO - Started antigravity antigravity_quota_refresh (interval: 300s) +2026-01-23 16:12:06,407 - rotator_library - INFO - antigravity: Fetching initial quota baselines for 1 credentials... +2026-01-23 16:12:07,541 - rotator_library.model_info_service - INFO - OpenRouter: 345 models loaded +2026-01-23 16:12:07,541 - rotator_library.model_info_service - INFO - Models.dev: 2255 models loaded +2026-01-23 16:38:58,754 - root - INFO - 16:38 - 172.17.0.1:50620 - provider: antigravity, model: gemini-3-flash - N/A +2026-01-23 16:38:58,755 - rotator_library - INFO - Acquiring key for model antigravity/gemini-3-flash. Tried keys: 0/1(1) +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%]) +2026-01-23 16:38:58,758 - rotator_library - INFO - Attempting call with credential antigravity_oauth_1.json (Attempt 1/2) +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 +2026-01-23 16:39:02,593 - rotator_library - INFO - Recorded usage from response object for key antigravity_oauth_1.json +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) +2026-01-24 11:18:44,039 - rotator_library - INFO - Getting all available models... +2026-01-24 11:18:44,058 - rotator_library - INFO - Getting available models for provider: antigravity +2026-01-24 11:18:44,059 - rotator_library - INFO - Got 6 models for provider: antigravity +2026-01-24 11:18:44,060 - rotator_library - INFO - Finished getting all available models. +2026-01-24 11:23:14,658 - rotator_library - INFO - Getting all available models... +2026-01-24 11:23:14,659 - rotator_library - INFO - Getting available models for provider: antigravity +2026-01-24 11:23:14,659 - rotator_library - INFO - Finished getting all available models. +2026-01-24 11:32:50,410 - root - INFO - 11:32 - 172.17.0.1:45252 - provider: antigravity, model: gemini-3-flash - N/A +2026-01-24 11:32:50,411 - rotator_library - INFO - Acquiring credential for model antigravity/gemini-3-flash. Tried credentials: 0/1(1) +2026-01-24 11:32:50,413 - rotator_library - INFO - Reset model group 'g3-flash' (1 models) for antigravity_oauth_1.json +2026-01-24 11:32:50,524 - rotator_library - INFO - Reset model group 'g3-flash' (1 models) for antigravity_oauth_2.json +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%]) +2026-01-24 11:32:50,881 - rotator_library - INFO - Attempting stream with credential antigravity_oauth_1.json (Attempt 1/2) +2026-01-24 11:32:51,096 - rotator_library - INFO - Stream connection established for credential antigravity_oauth_1.json. Processing response. +2026-01-24 11:32:51,590 - root - INFO - 11:32 - 172.17.0.1:45266 - provider: antigravity, model: gemini-3-flash - N/A +2026-01-24 11:32:51,597 - rotator_library - INFO - Acquiring credential for model antigravity/gemini-3-flash. Tried credentials: 0/1(1) +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%]) +2026-01-24 11:32:51,599 - rotator_library - INFO - Attempting stream with credential antigravity_oauth_1.json (Attempt 1/2) +2026-01-24 11:32:51,599 - rotator_library - INFO - Stream connection established for credential antigravity_oauth_1.json. Processing response. +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 +2026-01-24 11:32:51,608 - rotator_library - INFO - Acquiring credential for model antigravity/claude-opus-4.5. Tried credentials: 0/1(1) +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%]) +2026-01-24 11:32:51,610 - rotator_library - INFO - Attempting stream with credential antigravity_oauth_1.json (Attempt 1/2) +2026-01-24 11:32:51,614 - rotator_library - INFO - Stream connection established for credential antigravity_oauth_1.json. Processing response. +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 +2026-01-24 11:32:54,552 - rotator_library - INFO - Recorded usage from response object for key antigravity_oauth_1.json +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) +2026-01-24 11:32:54,554 - rotator_library - INFO - STREAM FINISHED and lock released for credential antigravity_oauth_1.json. +2026-01-24 11:32:56,228 - rotator_library - INFO - Recorded usage from response object for key antigravity_oauth_1.json +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) +2026-01-24 11:32:56,230 - rotator_library - INFO - STREAM FINISHED and lock released for credential antigravity_oauth_1.json. +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 +2026-01-24 11:32:57,435 - rotator_library - INFO - Recorded usage from response object for key antigravity_oauth_1.json +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) +2026-01-24 11:32:57,437 - rotator_library - INFO - STREAM FINISHED and lock released for credential antigravity_oauth_1.json. +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 +2026-01-24 11:34:30,524 - rotator_library - INFO - Acquiring credential for model antigravity/claude-opus-4.5. Tried credentials: 0/1(1) +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%]) +2026-01-24 11:34:30,526 - rotator_library - INFO - Attempting stream with credential antigravity_oauth_1.json (Attempt 1/2) +2026-01-24 11:34:30,645 - rotator_library - INFO - Stream connection established for credential antigravity_oauth_1.json. Processing response. +2026-01-24 11:34:30,651 - root - INFO - 11:34 - 172.17.0.1:40538 - provider: antigravity, model: gemini-3-flash - N/A +2026-01-24 11:34:30,652 - rotator_library - INFO - Acquiring credential for model antigravity/gemini-3-flash. Tried credentials: 0/1(1) +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%]) +2026-01-24 11:34:30,654 - rotator_library - INFO - Attempting stream with credential antigravity_oauth_1.json (Attempt 1/2) +2026-01-24 11:34:30,655 - rotator_library - INFO - Stream connection established for credential antigravity_oauth_1.json. Processing response. +2026-01-24 11:34:33,407 - rotator_library - INFO - Recorded usage from response object for key antigravity_oauth_1.json +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) +2026-01-24 11:34:33,410 - rotator_library - INFO - STREAM FINISHED and lock released for credential antigravity_oauth_1.json. +2026-01-24 11:34:36,075 - rotator_library - INFO - Recorded usage from response object for key antigravity_oauth_1.json +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) +2026-01-24 11:34:36,077 - rotator_library - INFO - STREAM FINISHED and lock released for credential antigravity_oauth_1.json. +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 +2026-01-24 11:34:38,582 - rotator_library - INFO - Acquiring credential for model antigravity/claude-opus-4.5. Tried credentials: 0/1(1) +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%]) +2026-01-24 11:34:38,584 - rotator_library - INFO - Attempting stream with credential antigravity_oauth_1.json (Attempt 1/2) +2026-01-24 11:34:38,585 - rotator_library - INFO - [Thinking Sanitization] Closing tool loop - turn has no thinking at start +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. +2026-01-24 11:34:38,589 - rotator_library - INFO - Stream connection established for credential antigravity_oauth_1.json. Processing response. +2026-01-24 11:34:44,008 - rotator_library - INFO - Recorded usage from response object for key antigravity_oauth_1.json +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) +2026-01-24 11:34:44,010 - rotator_library - INFO - STREAM FINISHED and lock released for credential antigravity_oauth_1.json. +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 +2026-01-24 11:38:54,076 - rotator_library - INFO - Acquiring credential for model antigravity/claude-opus-4.5. Tried credentials: 0/1(1) +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%]) +2026-01-24 11:38:54,081 - rotator_library - INFO - Attempting stream with credential antigravity_oauth_1.json (Attempt 1/2) +2026-01-24 11:38:54,091 - rotator_library - INFO - Stream connection established for credential antigravity_oauth_1.json. Processing response. +2026-01-24 11:38:54,149 - root - INFO - 11:38 - 172.17.0.1:37094 - provider: antigravity, model: gemini-3-flash - N/A +2026-01-24 11:38:54,150 - rotator_library - INFO - Acquiring credential for model antigravity/gemini-3-flash. Tried credentials: 0/1(1) +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%]) +2026-01-24 11:38:54,152 - rotator_library - INFO - Attempting stream with credential antigravity_oauth_1.json (Attempt 1/2) +2026-01-24 11:38:54,153 - rotator_library - INFO - Stream connection established for credential antigravity_oauth_1.json. Processing response. +2026-01-24 11:38:56,999 - rotator_library - INFO - Recorded usage from response object for key antigravity_oauth_1.json +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) +2026-01-24 11:38:57,002 - rotator_library - INFO - STREAM FINISHED and lock released for credential antigravity_oauth_1.json. +2026-01-24 11:38:58,817 - rotator_library - INFO - Recorded usage from response object for key antigravity_oauth_1.json +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) +2026-01-24 11:38:58,820 - rotator_library - INFO - STREAM FINISHED and lock released for credential antigravity_oauth_1.json. +2026-01-24 11:39:23,428 - root - INFO - 11:39 - 172.17.0.1:50878 - provider: antigravity, model: gemini-3-flash - N/A +2026-01-24 11:39:23,428 - rotator_library - INFO - Acquiring credential for model antigravity/gemini-3-flash. Tried credentials: 0/1(1) +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%]) +2026-01-24 11:39:23,430 - rotator_library - INFO - Attempting stream with credential antigravity_oauth_1.json (Attempt 1/2) +2026-01-24 11:39:23,443 - rotator_library - INFO - Stream connection established for credential antigravity_oauth_1.json. Processing response. +2026-01-24 11:39:23,450 - root - INFO - 11:39 - 172.17.0.1:50886 - provider: antigravity, model: gemini-3-flash - N/A +2026-01-24 11:39:23,451 - rotator_library - INFO - Acquiring credential for model antigravity/gemini-3-flash. Tried credentials: 0/1(1) +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%]) +2026-01-24 11:39:23,453 - rotator_library - INFO - Attempting stream with credential antigravity_oauth_1.json (Attempt 1/2) +2026-01-24 11:39:23,454 - rotator_library - INFO - Stream connection established for credential antigravity_oauth_1.json. Processing response. +2026-01-24 11:39:25,862 - rotator_library - INFO - Recorded usage from response object for key antigravity_oauth_1.json +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) +2026-01-24 11:39:25,865 - rotator_library - INFO - STREAM FINISHED and lock released for credential antigravity_oauth_1.json. +2026-01-24 11:39:27,283 - rotator_library - INFO - Recorded usage from response object for key antigravity_oauth_1.json +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) +2026-01-24 11:39:27,285 - rotator_library - INFO - STREAM FINISHED and lock released for credential antigravity_oauth_1.json. +2026-01-24 11:45:15,687 - rotator_library.model_info_service - INFO - Scheduled registry refresh... +2026-01-24 11:45:17,015 - rotator_library.model_info_service - INFO - OpenRouter: 345 models loaded +2026-01-24 11:45:17,018 - rotator_library.model_info_service - INFO - Models.dev: 2253 models loaded +2026-01-24 11:45:17,035 - rotator_library.model_info_service - INFO - Registry refresh complete +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 +2026-01-24 17:05:34,907 - rotator_library - WARNING - Refresh timeout (15s) for 'antigravity_oauth_1.json' +2026-01-24 17:05:34,908 - rotator_library - WARNING - Refresh failed for 'antigravity_oauth_1.json' (timeout). Retry 1/3, back of queue. +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 +2026-01-24 17:06:19,910 - rotator_library - WARNING - Refresh timeout (15s) for 'antigravity_oauth_1.json' +2026-01-24 17:06:19,910 - rotator_library - WARNING - Refresh failed for 'antigravity_oauth_1.json' (timeout). Retry 2/3, back of queue. +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 +2026-01-24 17:07:04,912 - rotator_library - WARNING - Refresh timeout (15s) for 'antigravity_oauth_1.json' +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. diff --git a/logs/proxy_debug.log b/logs/proxy_debug.log new file mode 100644 index 0000000000000000000000000000000000000000..1f36497a486c88f9d836b463d3ca35b29a82b708 --- /dev/null +++ b/logs/proxy_debug.log @@ -0,0 +1,679 @@ +2026-01-23 15:44:10,821 - rotator_library - DEBUG - ProviderCache[gemini3_signatures]: Disk enabled (memory_ttl=3600s, disk_ttl=86400s) +2026-01-23 15:44:10,821 - rotator_library - DEBUG - ProviderCache[claude_thinking]: Disk enabled (memory_ttl=3600s, disk_ttl=86400s) +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 +2026-01-23 15:44:10,822 - rotator_library - DEBUG - Loaded persisted tier 'standard-tier' for credential: antigravity_oauth_1.json +2026-01-23 15:44:10,822 - rotator_library - DEBUG - Loaded persisted tier 'standard-tier' for credential: antigravity_oauth_2.json +2026-01-23 15:44:10,822 - rotator_library - DEBUG - antigravity: Loaded 2 credential tiers from disk: standard-tier=2 +2026-01-23 15:44:10,823 - rotator_library - DEBUG - Loading ANTIGRAVITY credentials from file: /app/oauth_creds/antigravity_oauth_1.json +2026-01-23 15:44:10,823 - rotator_library - DEBUG - Loading ANTIGRAVITY credentials from file: /app/oauth_creds/antigravity_oauth_2.json +2026-01-23 15:44:10,825 - rotator_library - DEBUG - ProviderCache[gemini3_signatures]: Started background tasks +2026-01-23 15:44:10,825 - rotator_library - DEBUG - ProviderCache[claude_thinking]: Started background tasks +2026-01-23 15:44:10,826 - rotator_library - DEBUG - Fetching quota baselines for 2 credentials... +2026-01-23 15:44:13,440 - rotator_library - DEBUG - Baseline fetch complete: 2/2 successful +2026-01-23 15:44:13,441 - rotator_library - DEBUG - ProviderCache[gemini3_signatures]: Disk enabled (memory_ttl=3600s, disk_ttl=86400s) +2026-01-23 15:44:13,441 - rotator_library - DEBUG - ProviderCache[claude_thinking]: Disk enabled (memory_ttl=3600s, disk_ttl=86400s) +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +2026-01-23 15:44:14,170 - rotator_library - DEBUG - Antigravity quota baseline refresh: no cooldowns needed +2026-01-23 15:44:14,170 - rotator_library - DEBUG - antigravity quota refresh: updated 12 model baselines +2026-01-23 15:44:14,170 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: initial run complete +2026-01-23 15:44:14,170 - rotator_library - DEBUG - ProviderCache[gemini3_signatures]: Started background tasks +2026-01-23 15:44:14,170 - rotator_library - DEBUG - ProviderCache[claude_thinking]: Started background tasks +2026-01-23 15:44:28,311 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/gemini-2.0-flash-exp, reasoning_effort=None +2026-01-23 15:44:28,312 - rotator_library - DEBUG - Credential priorities for antigravity: P2=2 +2026-01-23 15:44:28,312 - rotator_library - DEBUG - Lazy-loaded tier 'standard-tier' for credential: antigravity_oauth_1.json +2026-01-23 15:44:28,313 - rotator_library - DEBUG - Lazy-loaded tier 'standard-tier' for credential: antigravity_oauth_2.json +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) +2026-01-23 15:44:28,317 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call. +2026-01-23 15:44:28,318 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json +2026-01-23 15:44:28,318 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46 +2026-01-23 15:44:48,448 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call. +2026-01-23 15:44:48,449 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_2.json +2026-01-23 15:44:48,449 - rotator_library - DEBUG - Using cached project ID: bold-throne-g2hmx +2026-01-23 15:45:21,582 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/gemini-1.5-pro, reasoning_effort=None +2026-01-23 15:45:21,583 - rotator_library - DEBUG - Credential priorities for antigravity: P2=2 +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) +2026-01-23 15:45:21,586 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call. +2026-01-23 15:45:21,587 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json +2026-01-23 15:45:21,587 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46 +2026-01-23 15:45:37,865 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call. +2026-01-23 15:45:37,866 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_2.json +2026-01-23 15:45:37,866 - rotator_library - DEBUG - Using cached project ID: bold-throne-g2hmx +2026-01-23 15:49:14,171 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-23 15:49:14,171 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-23 15:54:14,172 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-23 15:54:14,172 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-23 15:56:23,673 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/gemini-2.0-flash-exp, reasoning_effort=None +2026-01-23 15:56:23,674 - rotator_library - DEBUG - Credential priorities for antigravity: P2=2 +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) +2026-01-23 15:56:23,677 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call. +2026-01-23 15:56:23,678 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json +2026-01-23 15:56:23,678 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46 +2026-01-23 15:56:41,164 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call. +2026-01-23 15:56:41,164 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_2.json +2026-01-23 15:56:41,164 - rotator_library - DEBUG - Using cached project ID: bold-throne-g2hmx +2026-01-23 15:58:31,359 - rotator_library - DEBUG - Attempting to get models for antigravity with credential antigravity_oauth_2.json +2026-01-23 15:58:31,359 - rotator_library - DEBUG - Using hardcoded model list +2026-01-23 15:59:07,975 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/gemini-3-flash, reasoning_effort=None +2026-01-23 15:59:07,976 - rotator_library - DEBUG - Credential priorities for antigravity: P2=2 +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) +2026-01-23 15:59:07,978 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call. +2026-01-23 15:59:07,979 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_2.json +2026-01-23 15:59:07,979 - rotator_library - DEBUG - Using cached project ID: bold-throne-g2hmx +2026-01-23 15:59:14,173 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-23 15:59:14,173 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-23 15:59:24,453 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call. +2026-01-23 15:59:24,453 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json +2026-01-23 15:59:24,454 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46 +2026-01-23 16:00:10,954 - rotator_library - DEBUG - Returning cached models for provider: antigravity +2026-01-23 16:00:21,201 - rotator_library - DEBUG - Returning cached models for provider: antigravity +2026-01-23 16:01:08,173 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/gemini-2.5-flash, reasoning_effort=None +2026-01-23 16:01:08,173 - rotator_library - DEBUG - Credential priorities for antigravity: P2=2 +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) +2026-01-23 16:01:08,176 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call. +2026-01-23 16:01:08,176 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_2.json +2026-01-23 16:01:08,176 - rotator_library - DEBUG - Using cached project ID: bold-throne-g2hmx +2026-01-23 16:01:26,091 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call. +2026-01-23 16:01:26,092 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json +2026-01-23 16:01:26,092 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46 +2026-01-23 16:02:46,115 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: cancelled +2026-01-23 16:02:46,115 - rotator_library - DEBUG - Stopped background job for 'antigravity' +2026-01-23 16:03:05,746 - rotator_library - DEBUG - ProviderCache[gemini3_signatures]: Disk enabled (memory_ttl=3600s, disk_ttl=86400s) +2026-01-23 16:03:05,747 - rotator_library - DEBUG - ProviderCache[claude_thinking]: Disk enabled (memory_ttl=3600s, disk_ttl=86400s) +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 +2026-01-23 16:03:05,747 - rotator_library - DEBUG - Loaded persisted tier 'standard-tier' for credential: antigravity_oauth_1.json +2026-01-23 16:03:05,748 - rotator_library - DEBUG - Loaded persisted tier 'standard-tier' for credential: antigravity_oauth_2.json +2026-01-23 16:03:05,748 - rotator_library - DEBUG - antigravity: Loaded 2 credential tiers from disk: standard-tier=2 +2026-01-23 16:03:05,749 - rotator_library - DEBUG - Loading ANTIGRAVITY credentials from file: /app/oauth_creds/antigravity_oauth_1.json +2026-01-23 16:03:05,749 - rotator_library - DEBUG - Loading ANTIGRAVITY credentials from file: /app/oauth_creds/antigravity_oauth_2.json +2026-01-23 16:03:05,751 - rotator_library - DEBUG - ProviderCache[gemini3_signatures]: Started background tasks +2026-01-23 16:03:05,751 - rotator_library - DEBUG - ProviderCache[claude_thinking]: Started background tasks +2026-01-23 16:03:05,751 - rotator_library - DEBUG - Fetching quota baselines for 2 credentials... +2026-01-23 16:03:08,351 - rotator_library - DEBUG - Baseline fetch complete: 2/2 successful +2026-01-23 16:03:08,353 - rotator_library - DEBUG - ProviderCache[gemini3_signatures]: Disk enabled (memory_ttl=3600s, disk_ttl=86400s) +2026-01-23 16:03:08,354 - rotator_library - DEBUG - ProviderCache[claude_thinking]: Disk enabled (memory_ttl=3600s, disk_ttl=86400s) +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 +2026-01-23 16:03:08,355 - rotator_library - DEBUG - Lazy-loaded tier 'standard-tier' for credential: antigravity_oauth_1.json +2026-01-23 16:03:08,356 - rotator_library - DEBUG - Lazy-loaded tier 'standard-tier' for credential: antigravity_oauth_2.json +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +2026-01-23 16:03:08,370 - rotator_library - DEBUG - Antigravity quota baseline refresh: no cooldowns needed +2026-01-23 16:03:08,370 - rotator_library - DEBUG - antigravity quota refresh: updated 12 model baselines +2026-01-23 16:03:08,370 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: initial run complete +2026-01-23 16:03:08,370 - rotator_library - DEBUG - ProviderCache[gemini3_signatures]: Started background tasks +2026-01-23 16:03:08,370 - rotator_library - DEBUG - ProviderCache[claude_thinking]: Started background tasks +2026-01-23 16:04:06,561 - rotator_library - DEBUG - Attempting to get models for antigravity with credential antigravity_oauth_1.json +2026-01-23 16:04:06,564 - rotator_library - DEBUG - Using hardcoded model list +2026-01-23 16:04:22,953 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/gemini-3-flash, reasoning_effort=None +2026-01-23 16:04:22,954 - rotator_library - DEBUG - Credential priorities for antigravity: P2=2 +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) +2026-01-23 16:04:22,958 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call. +2026-01-23 16:04:22,958 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_2.json +2026-01-23 16:04:22,959 - rotator_library - DEBUG - Using cached project ID: bold-throne-g2hmx +2026-01-23 16:04:25,936 - rotator_library - DEBUG - Skipping cost calculation for provider 'antigravity' (custom provider). +2026-01-23 16:08:08,371 - rotator_library - DEBUG - Refreshing quota baselines for 1 recently active credentials +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 +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 +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 +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 +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 +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 +2026-01-23 16:08:10,418 - rotator_library - DEBUG - Antigravity quota baseline refresh: no cooldowns needed +2026-01-23 16:08:10,418 - rotator_library - DEBUG - antigravity quota refresh: updated 6 model baselines +2026-01-23 16:08:10,418 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-23 16:12:00,819 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: cancelled +2026-01-23 16:12:00,819 - rotator_library - DEBUG - Stopped background job for 'antigravity' +2026-01-23 16:12:06,403 - rotator_library - DEBUG - ProviderCache[gemini3_signatures]: Disk enabled (memory_ttl=3600s, disk_ttl=86400s) +2026-01-23 16:12:06,404 - rotator_library - DEBUG - ProviderCache[claude_thinking]: Disk enabled (memory_ttl=3600s, disk_ttl=86400s) +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 +2026-01-23 16:12:06,404 - rotator_library - DEBUG - Loaded persisted tier 'standard-tier' for credential: antigravity_oauth_1.json +2026-01-23 16:12:06,404 - rotator_library - DEBUG - antigravity: Loaded 1 credential tiers from disk: standard-tier=1 +2026-01-23 16:12:06,405 - rotator_library - DEBUG - Loading ANTIGRAVITY credentials from file: /app/oauth_creds/antigravity_oauth_1.json +2026-01-23 16:12:06,407 - rotator_library - DEBUG - ProviderCache[gemini3_signatures]: Started background tasks +2026-01-23 16:12:06,407 - rotator_library - DEBUG - ProviderCache[claude_thinking]: Started background tasks +2026-01-23 16:12:06,407 - rotator_library - DEBUG - Fetching quota baselines for 1 credentials... +2026-01-23 16:12:09,041 - rotator_library - DEBUG - Baseline fetch complete: 1/1 successful +2026-01-23 16:12:09,043 - rotator_library - DEBUG - ProviderCache[gemini3_signatures]: Disk enabled (memory_ttl=3600s, disk_ttl=86400s) +2026-01-23 16:12:09,043 - rotator_library - DEBUG - ProviderCache[claude_thinking]: Disk enabled (memory_ttl=3600s, disk_ttl=86400s) +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 +2026-01-23 16:12:09,044 - rotator_library - DEBUG - Lazy-loaded tier 'standard-tier' for credential: antigravity_oauth_1.json +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' +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 +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 +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 +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 +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 +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 +2026-01-23 16:12:09,973 - rotator_library - DEBUG - Antigravity quota baseline refresh: no cooldowns needed +2026-01-23 16:12:09,973 - rotator_library - DEBUG - antigravity quota refresh: updated 6 model baselines +2026-01-23 16:12:09,973 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: initial run complete +2026-01-23 16:12:09,974 - rotator_library - DEBUG - ProviderCache[gemini3_signatures]: Started background tasks +2026-01-23 16:12:09,974 - rotator_library - DEBUG - ProviderCache[claude_thinking]: Started background tasks +2026-01-23 16:17:09,974 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-23 16:17:09,974 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-23 16:22:06,407 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)... +2026-01-23 16:22:07,224 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json' +2026-01-23 16:22:07,226 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'. +2026-01-23 16:22:07,226 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'. +2026-01-23 16:22:09,975 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-23 16:22:09,976 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-23 16:27:09,977 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-23 16:27:09,977 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-23 16:32:09,978 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-23 16:32:09,979 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-23 16:37:09,980 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-23 16:37:09,980 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-23 16:38:58,754 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/gemini-3-flash, reasoning_effort=None +2026-01-23 16:38:58,755 - rotator_library - DEBUG - Credential priorities for antigravity: P2=1 +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' +2026-01-23 16:38:58,757 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call. +2026-01-23 16:38:58,758 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json +2026-01-23 16:38:58,758 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46 +2026-01-23 16:39:02,594 - rotator_library - DEBUG - Skipping cost calculation for provider 'antigravity' (custom provider). +2026-01-23 16:42:09,981 - rotator_library - DEBUG - Refreshing quota baselines for 1 recently active credentials +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 +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 +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 +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 +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 +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 +2026-01-23 16:42:12,450 - rotator_library - DEBUG - Antigravity quota baseline refresh: no cooldowns needed +2026-01-23 16:42:12,450 - rotator_library - DEBUG - antigravity quota refresh: updated 6 model baselines +2026-01-23 16:42:12,451 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-23 16:47:12,452 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-23 16:47:12,452 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-23 16:52:06,409 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)... +2026-01-23 16:52:07,231 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json' +2026-01-23 16:52:07,233 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'. +2026-01-23 16:52:07,233 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'. +2026-01-23 16:52:12,453 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-23 16:52:12,454 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 03:36:39,099 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 03:36:39,099 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 03:41:33,054 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)... +2026-01-24 03:41:33,814 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json' +2026-01-24 03:41:33,816 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'. +2026-01-24 03:41:33,816 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'. +2026-01-24 03:41:39,102 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 03:41:39,102 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 03:46:39,103 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 03:46:39,103 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 03:51:39,104 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 03:51:39,104 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 03:56:39,105 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 03:56:39,105 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 04:01:39,107 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 04:01:39,107 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 04:06:39,108 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 04:06:39,108 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 07:04:03,801 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)... +2026-01-24 07:04:04,545 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json' +2026-01-24 07:04:04,547 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'. +2026-01-24 07:04:04,547 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'. +2026-01-24 07:04:09,855 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 07:04:09,855 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 07:09:09,856 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 07:09:09,856 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 07:14:09,857 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 07:14:09,857 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 07:19:15,130 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 07:19:15,130 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 07:24:15,131 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 07:24:15,131 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 07:29:15,132 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 07:29:15,132 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 07:35:14,550 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)... +2026-01-24 07:35:15,430 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json' +2026-01-24 07:35:15,432 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'. +2026-01-24 07:35:15,432 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'. +2026-01-24 07:35:20,607 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 07:35:20,608 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 07:40:20,610 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 07:40:20,610 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 07:45:20,612 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 07:45:20,612 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 07:50:20,613 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 07:50:20,613 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 07:55:20,615 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 07:55:20,615 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 08:00:20,616 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 08:00:20,616 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 08:05:14,554 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)... +2026-01-24 08:05:15,298 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json' +2026-01-24 08:05:15,300 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'. +2026-01-24 08:05:15,300 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'. +2026-01-24 08:05:20,617 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 08:05:20,618 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 08:10:20,619 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 08:10:20,619 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 08:15:20,620 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 08:15:20,621 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 08:20:20,621 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 08:20:20,622 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 08:25:20,622 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 08:25:20,623 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 08:30:20,623 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 08:30:20,624 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 08:35:14,557 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)... +2026-01-24 08:35:15,319 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json' +2026-01-24 08:35:15,541 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'. +2026-01-24 08:35:15,541 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'. +2026-01-24 08:35:20,624 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 08:35:20,624 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 08:40:20,625 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 08:40:20,625 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 08:45:20,626 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 08:45:20,627 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 08:50:20,627 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 08:50:21,005 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 08:55:21,005 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 08:55:21,006 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 09:00:21,006 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 09:00:21,008 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 09:05:14,560 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)... +2026-01-24 09:05:15,874 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json' +2026-01-24 09:05:16,123 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'. +2026-01-24 09:05:16,123 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'. +2026-01-24 09:05:21,009 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 09:05:21,009 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 09:10:21,010 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 09:10:21,010 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 09:15:21,011 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 09:15:21,011 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 09:20:21,012 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 09:20:21,012 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 09:25:21,013 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 09:25:21,014 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 09:30:21,015 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 09:30:21,015 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 09:35:14,562 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)... +2026-01-24 09:35:15,530 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json' +2026-01-24 09:35:15,619 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'. +2026-01-24 09:35:15,619 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'. +2026-01-24 09:35:21,016 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 09:35:21,016 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 09:40:21,087 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 09:40:21,088 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 09:45:21,088 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 09:45:21,091 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 09:50:21,091 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 09:50:21,091 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 09:55:21,093 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 09:55:21,093 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 10:00:21,094 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 10:00:21,094 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 10:05:14,568 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)... +2026-01-24 10:05:15,410 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json' +2026-01-24 10:05:15,460 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'. +2026-01-24 10:05:15,460 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'. +2026-01-24 10:05:21,095 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 10:05:21,096 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 10:10:21,097 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 10:10:21,097 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 10:15:21,101 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 10:15:21,101 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 10:20:21,102 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 10:20:21,102 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 10:25:21,103 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 10:25:21,103 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 10:30:21,104 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 10:30:21,105 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 10:35:14,571 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)... +2026-01-24 10:35:15,333 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json' +2026-01-24 10:35:15,531 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'. +2026-01-24 10:35:15,531 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'. +2026-01-24 10:35:21,106 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 10:35:21,107 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 10:40:21,108 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 10:40:21,108 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 10:45:21,109 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 10:45:21,109 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 10:50:21,110 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 10:50:21,110 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 10:55:21,110 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 10:55:21,111 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 11:00:21,113 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 11:00:21,115 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 11:05:14,575 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)... +2026-01-24 11:05:15,395 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json' +2026-01-24 11:05:15,397 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'. +2026-01-24 11:05:15,397 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'. +2026-01-24 11:05:21,116 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 11:05:21,116 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 11:10:21,117 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 11:10:21,118 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 11:15:21,119 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 11:15:21,120 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 11:18:44,059 - rotator_library - DEBUG - Attempting to get models for antigravity with credential antigravity_oauth_1.json +2026-01-24 11:18:44,059 - rotator_library - DEBUG - Using hardcoded model list +2026-01-24 11:20:21,121 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 11:20:21,121 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 11:23:14,659 - rotator_library - DEBUG - Returning cached models for provider: antigravity +2026-01-24 11:25:21,121 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 11:25:21,122 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 11:30:21,122 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 11:30:21,123 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 11:32:50,245 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/gemini-3-flash, reasoning_effort=None +2026-01-24 11:32:50,411 - rotator_library - DEBUG - Credential priorities for antigravity: P2=1 +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' +2026-01-24 11:32:50,881 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call. +2026-01-24 11:32:50,882 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json +2026-01-24 11:32:50,883 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46 +2026-01-24 11:32:51,589 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/gemini-3-flash, reasoning_effort=None +2026-01-24 11:32:51,591 - rotator_library - DEBUG - Credential priorities for antigravity: P2=1 +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' +2026-01-24 11:32:51,599 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call. +2026-01-24 11:32:51,599 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json +2026-01-24 11:32:51,599 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46 +2026-01-24 11:32:51,607 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/claude-opus-4.5, reasoning_effort=None +2026-01-24 11:32:51,608 - rotator_library - DEBUG - Credential priorities for antigravity: P2=1 +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' +2026-01-24 11:32:51,610 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call. +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 +2026-01-24 11:32:51,610 - rotator_library - DEBUG - [Interleaved Thinking] Injected reminder to user message at index 0 +2026-01-24 11:32:51,611 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name) +2026-01-24 11:32:51,611 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name) +2026-01-24 11:32:51,611 - rotator_library - DEBUG - [Schema] Preserving property 'format' (matches validation keyword name) +2026-01-24 11:32:51,612 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name) +2026-01-24 11:32:51,612 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name) +2026-01-24 11:32:51,612 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json +2026-01-24 11:32:51,612 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46 +2026-01-24 11:32:54,552 - rotator_library - DEBUG - Skipping cost calculation for provider 'antigravity' (custom provider). +2026-01-24 11:32:56,228 - rotator_library - DEBUG - Skipping cost calculation for provider 'antigravity' (custom provider). +2026-01-24 11:32:57,435 - rotator_library - DEBUG - Skipping cost calculation for provider 'antigravity' (custom provider). +2026-01-24 11:34:30,522 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/claude-opus-4.5, reasoning_effort=None +2026-01-24 11:34:30,523 - rotator_library - DEBUG - Credential priorities for antigravity: P2=1 +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' +2026-01-24 11:34:30,526 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call. +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 +2026-01-24 11:34:30,641 - rotator_library - DEBUG - [Interleaved Thinking] Injected reminder to user message at index 2 +2026-01-24 11:34:30,642 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name) +2026-01-24 11:34:30,642 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name) +2026-01-24 11:34:30,642 - rotator_library - DEBUG - [Schema] Preserving property 'format' (matches validation keyword name) +2026-01-24 11:34:30,642 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name) +2026-01-24 11:34:30,643 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name) +2026-01-24 11:34:30,643 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json +2026-01-24 11:34:30,644 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46 +2026-01-24 11:34:30,650 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/gemini-3-flash, reasoning_effort=None +2026-01-24 11:34:30,652 - rotator_library - DEBUG - Credential priorities for antigravity: P2=1 +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' +2026-01-24 11:34:30,654 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call. +2026-01-24 11:34:30,654 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json +2026-01-24 11:34:30,654 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46 +2026-01-24 11:34:33,408 - rotator_library - DEBUG - Skipping cost calculation for provider 'antigravity' (custom provider). +2026-01-24 11:34:36,075 - rotator_library - DEBUG - Skipping cost calculation for provider 'antigravity' (custom provider). +2026-01-24 11:34:38,581 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/claude-opus-4.5, reasoning_effort=None +2026-01-24 11:34:38,582 - rotator_library - DEBUG - Credential priorities for antigravity: P2=1 +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' +2026-01-24 11:34:38,584 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call. +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 +2026-01-24 11:34:38,585 - rotator_library - DEBUG - [Interleaved Thinking] Injected reminder to user message at index 6 +2026-01-24 11:34:38,586 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name) +2026-01-24 11:34:38,586 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name) +2026-01-24 11:34:38,586 - rotator_library - DEBUG - [Schema] Preserving property 'format' (matches validation keyword name) +2026-01-24 11:34:38,586 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name) +2026-01-24 11:34:38,587 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name) +2026-01-24 11:34:38,587 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json +2026-01-24 11:34:38,588 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46 +2026-01-24 11:34:44,008 - rotator_library - DEBUG - Skipping cost calculation for provider 'antigravity' (custom provider). +2026-01-24 11:35:14,577 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)... +2026-01-24 11:35:15,337 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json' +2026-01-24 11:35:15,448 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'. +2026-01-24 11:35:15,449 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'. +2026-01-24 11:35:21,124 - rotator_library - DEBUG - Refreshing quota baselines for 1 recently active credentials +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 +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 +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 +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 +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 +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 +2026-01-24 11:35:21,814 - rotator_library - DEBUG - Antigravity quota baseline refresh: no cooldowns needed +2026-01-24 11:35:21,815 - rotator_library - DEBUG - antigravity quota refresh: updated 6 model baselines +2026-01-24 11:35:21,815 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 11:38:54,075 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/claude-opus-4.5, reasoning_effort=None +2026-01-24 11:38:54,076 - rotator_library - DEBUG - Credential priorities for antigravity: P2=1 +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' +2026-01-24 11:38:54,080 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call. +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 +2026-01-24 11:38:54,081 - rotator_library - DEBUG - [Interleaved Thinking] Injected reminder to user message at index 6 +2026-01-24 11:38:54,082 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name) +2026-01-24 11:38:54,087 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name) +2026-01-24 11:38:54,087 - rotator_library - DEBUG - [Schema] Preserving property 'format' (matches validation keyword name) +2026-01-24 11:38:54,088 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name) +2026-01-24 11:38:54,088 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name) +2026-01-24 11:38:54,088 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json +2026-01-24 11:38:54,089 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46 +2026-01-24 11:38:54,148 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/gemini-3-flash, reasoning_effort=None +2026-01-24 11:38:54,150 - rotator_library - DEBUG - Credential priorities for antigravity: P2=1 +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' +2026-01-24 11:38:54,152 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call. +2026-01-24 11:38:54,153 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json +2026-01-24 11:38:54,153 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46 +2026-01-24 11:38:57,000 - rotator_library - DEBUG - Skipping cost calculation for provider 'antigravity' (custom provider). +2026-01-24 11:38:58,818 - rotator_library - DEBUG - Skipping cost calculation for provider 'antigravity' (custom provider). +2026-01-24 11:39:23,427 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/gemini-3-flash, reasoning_effort=None +2026-01-24 11:39:23,428 - rotator_library - DEBUG - Credential priorities for antigravity: P2=1 +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' +2026-01-24 11:39:23,430 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call. +2026-01-24 11:39:23,431 - rotator_library - DEBUG - Missing thoughtSignature for first func call toolu_vrtx_01AxDghXwH4PMXNES8HtfNv9, using bypass +2026-01-24 11:39:23,431 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name) +2026-01-24 11:39:23,431 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name) +2026-01-24 11:39:23,431 - rotator_library - DEBUG - [Schema] Preserving property 'format' (matches validation keyword name) +2026-01-24 11:39:23,432 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name) +2026-01-24 11:39:23,432 - rotator_library - DEBUG - [Schema] Preserving property 'pattern' (matches validation keyword name) +2026-01-24 11:39:23,440 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json +2026-01-24 11:39:23,441 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46 +2026-01-24 11:39:23,450 - rotator_library - DEBUG - Handling reasoning parameters: model=antigravity/gemini-3-flash, reasoning_effort=None +2026-01-24 11:39:23,451 - rotator_library - DEBUG - Credential priorities for antigravity: P2=1 +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' +2026-01-24 11:39:23,453 - rotator_library - DEBUG - Provider 'antigravity' has custom logic. Delegating call. +2026-01-24 11:39:23,454 - rotator_library - DEBUG - Starting Antigravity project discovery for credential: /app/oauth_creds/antigravity_oauth_1.json +2026-01-24 11:39:23,454 - rotator_library - DEBUG - Using cached project ID: hopeful-inkwell-mwz46 +2026-01-24 11:39:25,863 - rotator_library - DEBUG - Skipping cost calculation for provider 'antigravity' (custom provider). +2026-01-24 11:39:27,283 - rotator_library - DEBUG - Skipping cost calculation for provider 'antigravity' (custom provider). +2026-01-24 11:40:21,816 - rotator_library - DEBUG - Refreshing quota baselines for 1 recently active credentials +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 +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 +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 +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 +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 +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 +2026-01-24 11:40:22,612 - rotator_library - DEBUG - Antigravity quota baseline refresh: no cooldowns needed +2026-01-24 11:40:22,613 - rotator_library - DEBUG - antigravity quota refresh: updated 6 model baselines +2026-01-24 11:40:22,613 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 11:45:22,613 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 11:45:22,613 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 11:50:22,617 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 11:50:22,617 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 11:55:22,618 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 11:55:22,620 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 12:00:22,621 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 12:00:22,621 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 12:05:14,583 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)... +2026-01-24 12:05:15,498 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json' +2026-01-24 12:05:15,500 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'. +2026-01-24 12:05:15,500 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'. +2026-01-24 12:05:22,621 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 12:05:22,622 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 12:10:22,622 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 12:10:22,623 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 12:15:22,624 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 12:15:22,624 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 12:20:22,625 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 12:20:22,625 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 12:25:22,626 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 12:25:22,627 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 12:30:22,627 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 12:30:22,627 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 12:35:14,587 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)... +2026-01-24 12:35:15,520 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json' +2026-01-24 12:35:15,521 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'. +2026-01-24 12:35:15,522 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'. +2026-01-24 12:35:22,628 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 12:35:22,628 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 12:40:22,630 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 12:40:22,630 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 12:45:22,631 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 12:45:22,632 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 12:50:22,632 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 12:50:22,632 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 12:55:22,634 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 12:55:22,636 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 13:00:22,637 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 13:00:22,638 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 13:05:14,590 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)... +2026-01-24 13:05:15,435 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json' +2026-01-24 13:05:15,437 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'. +2026-01-24 13:05:15,437 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'. +2026-01-24 13:05:22,638 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 13:05:22,639 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 13:10:22,640 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 13:10:22,640 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 13:15:22,641 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 13:15:22,642 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 13:20:27,937 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 13:20:27,937 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 13:25:27,941 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 13:25:27,941 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 13:30:27,941 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 13:30:27,941 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 13:35:19,887 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)... +2026-01-24 13:35:20,806 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json' +2026-01-24 13:35:20,807 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'. +2026-01-24 13:35:20,807 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'. +2026-01-24 13:35:27,942 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 13:35:27,942 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 13:40:27,943 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 13:40:27,944 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 13:45:27,945 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 13:45:27,946 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 13:50:27,946 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 13:50:27,951 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 13:55:27,951 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 13:55:27,951 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 14:00:27,952 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 14:00:27,952 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 14:05:19,891 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)... +2026-01-24 14:05:20,652 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json' +2026-01-24 14:05:20,655 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'. +2026-01-24 14:05:20,655 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'. +2026-01-24 14:05:27,952 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 14:05:27,952 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 14:10:27,953 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 14:10:27,953 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 14:15:27,954 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 14:15:27,957 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 14:20:27,958 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 14:20:27,959 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 14:25:27,960 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 14:25:27,960 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 14:30:27,961 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 14:30:27,961 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 14:35:19,895 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)... +2026-01-24 14:35:20,722 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json' +2026-01-24 14:35:20,723 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'. +2026-01-24 14:35:20,723 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'. +2026-01-24 14:35:27,962 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 14:35:27,964 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 14:40:27,964 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 14:40:27,965 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 14:45:27,966 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 14:45:27,966 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 14:50:27,967 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 14:50:27,970 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 14:55:27,971 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 14:55:27,971 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 15:00:27,972 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 15:00:27,972 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 15:05:19,897 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)... +2026-01-24 15:05:20,696 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json' +2026-01-24 15:05:20,697 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'. +2026-01-24 15:05:20,697 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'. +2026-01-24 15:05:27,973 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 15:05:27,973 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 15:10:27,974 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 15:10:27,974 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 15:15:27,975 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 15:15:27,977 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 15:20:27,977 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 15:20:27,977 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 15:25:27,978 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 15:25:27,978 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 15:30:27,979 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 15:30:27,980 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 15:35:19,899 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)... +2026-01-24 15:35:20,685 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json' +2026-01-24 15:35:20,686 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'. +2026-01-24 15:35:20,686 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'. +2026-01-24 15:35:27,982 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 15:35:27,982 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 15:40:27,983 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 15:40:27,983 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 15:45:27,984 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 15:45:27,984 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 15:50:27,985 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 15:50:27,987 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 15:55:27,988 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 15:55:27,988 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 16:00:27,989 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 16:00:27,989 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 16:05:19,902 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)... +2026-01-24 16:05:20,610 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json' +2026-01-24 16:05:20,613 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'. +2026-01-24 16:05:20,613 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'. +2026-01-24 16:05:27,990 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 16:05:27,990 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 16:10:27,991 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 16:10:27,992 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 16:15:27,993 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 16:15:27,997 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 16:20:27,999 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 16:20:27,999 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 16:25:28,000 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 16:25:28,000 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 16:30:28,001 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 16:30:28,001 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 16:35:19,905 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)... +2026-01-24 16:35:20,764 - rotator_library - DEBUG - Token validation successful for 'antigravity_oauth_1.json' +2026-01-24 16:35:20,765 - rotator_library - DEBUG - Saved updated ANTIGRAVITY OAuth credentials to '/app/oauth_creds/antigravity_oauth_1.json'. +2026-01-24 16:35:20,766 - rotator_library - DEBUG - Successfully refreshed ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json'. +2026-01-24 16:35:28,003 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 16:35:28,004 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 16:40:28,005 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 16:40:28,005 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 16:45:28,006 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 16:45:28,006 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 16:50:28,007 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 16:50:28,008 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 16:55:28,008 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 16:55:28,008 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 17:00:28,009 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 17:00:28,009 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 17:05:19,906 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)... +2026-01-24 17:05:28,010 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 17:05:28,014 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete +2026-01-24 17:06:04,909 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)... +2026-01-24 17:06:49,911 - rotator_library - DEBUG - Refreshing ANTIGRAVITY OAuth token for 'antigravity_oauth_1.json' (forced: False)... +2026-01-24 17:10:28,015 - rotator_library - DEBUG - No recently active credentials to refresh quota baselines +2026-01-24 17:10:28,016 - rotator_library - DEBUG - antigravity antigravity_quota_refresh: periodic run complete diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..1f5d49859a1c9eceacff9be6392e24f4d621afef --- /dev/null +++ b/requirements.txt @@ -0,0 +1,27 @@ +# FastAPI framework for building the proxy server +fastapi +# ASGI server for running the FastAPI application +uvicorn +# For loading environment variables from a .env file +python-dotenv + +# Installs the local rotator_library in editable mode +-e src/rotator_library + +# A library for calling LLM APIs with a consistent format +litellm + +filelock +httpx +aiofiles +aiohttp + +colorlog + +rich + +# GUI for model filter configuration +customtkinter + +# For building the executable +pyinstaller diff --git a/src/batch_auth.py b/src/batch_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..8a7c19a32aa8a147148228d81ab31616edd31b9d --- /dev/null +++ b/src/batch_auth.py @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: LGPL-3.0-only +# Copyright (c) 2026 Mirrowel + +import asyncio +import sys +import argparse +from pathlib import Path + +# Add the 'src' directory to the Python path +sys.path.append(str(Path(__file__).resolve().parent)) + +from rotator_library import provider_factory + +async def main(): + parser = argparse.ArgumentParser(description="Batch authorize multiple Google OAuth accounts.") + parser.add_argument("emails", nargs="+", help="List of Gmail addresses to authorize.") + parser.add_argument("--provider", default="antigravity", help="Provider to authorize (default: antigravity).") + args = parser.parse_args() + + auth_class = provider_factory.get_provider_auth_class(args.provider) + auth_instance = auth_class() + + print(f"🚀 Starting batch authorization for {len(args.emails)} accounts on {args.provider}...") + + for email in args.emails: + print(f"\n🔑 Setting up: {email}") + result = await auth_instance.setup_credential(login_hint=email) + + if result.success: + print(f"✅ Success! Saved to: {Path(result.file_path).name}") + if result.is_update: + print(f"ℹ️ Updated existing credential for {result.email}") + else: + print(f"❌ Failed: {result.error}") + + print("\n✨ Batch authorization complete!") + print("👉 Now run 'python -m rotator_library.credential_tool' and choose 'Export to .env' to get your tokens.") + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/proxy_app/LICENSE b/src/proxy_app/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..2810a890fb98b6ec752ebdcef6236a733195cb74 --- /dev/null +++ b/src/proxy_app/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Mirrowel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/proxy_app/__init__.py b/src/proxy_app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9befbd667ee648988fde943b82c2ebafd0f18e6f --- /dev/null +++ b/src/proxy_app/__init__.py @@ -0,0 +1,3 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 Mirrowel + diff --git a/src/proxy_app/batch_manager.py b/src/proxy_app/batch_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..3176d61b3956dd288027eb53c24943004c01a7df --- /dev/null +++ b/src/proxy_app/batch_manager.py @@ -0,0 +1,84 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 Mirrowel + +import asyncio +from typing import List, Dict, Any, Tuple +import time +from rotator_library import RotatingClient + +class EmbeddingBatcher: + def __init__(self, client: RotatingClient, batch_size: int = 64, timeout: float = 0.1): + self.client = client + self.batch_size = batch_size + self.timeout = timeout + self.queue = asyncio.Queue() + self.worker_task = asyncio.create_task(self._batch_worker()) + + async def add_request(self, request_data: Dict[str, Any]) -> Any: + future = asyncio.Future() + await self.queue.put((request_data, future)) + return await future + + async def _batch_worker(self): + while True: + batch, futures = await self._gather_batch() + if not batch: + continue + + try: + # Assume all requests in a batch use the same model and other settings + model = batch[0]["model"] + inputs = [item["input"][0] for item in batch] # Extract single string input + + batched_request = { + "model": model, + "input": inputs + } + + # Pass through any other relevant parameters from the first request + for key in ["input_type", "dimensions", "user"]: + if key in batch[0]: + batched_request[key] = batch[0][key] + + response = await self.client.aembedding(**batched_request) + + # Distribute results back to the original requesters + for i, future in enumerate(futures): + # Create a new response object for each item in the batch + single_response_data = { + "object": response.object, + "model": response.model, + "data": [response.data[i]], + "usage": response.usage # Usage is for the whole batch + } + future.set_result(single_response_data) + + except Exception as e: + for future in futures: + future.set_exception(e) + + async def _gather_batch(self) -> Tuple[List[Dict[str, Any]], List[asyncio.Future]]: + batch = [] + futures = [] + start_time = time.time() + + while len(batch) < self.batch_size and (time.time() - start_time) < self.timeout: + try: + # Wait for an item with a timeout + timeout = self.timeout - (time.time() - start_time) + if timeout <= 0: + break + request, future = await asyncio.wait_for(self.queue.get(), timeout=timeout) + batch.append(request) + futures.append(future) + except asyncio.TimeoutError: + break + + return batch, futures + + async def stop(self): + self.worker_task.cancel() + try: + await self.worker_task + except asyncio.CancelledError: + pass \ No newline at end of file diff --git a/src/proxy_app/build.py b/src/proxy_app/build.py new file mode 100644 index 0000000000000000000000000000000000000000..a4c5dc074659caa4947f319b47d18279aa7cc227 --- /dev/null +++ b/src/proxy_app/build.py @@ -0,0 +1,95 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 Mirrowel + +import os +import sys +import platform +import subprocess + + +def get_providers(): + """ + Scans the 'src/rotator_library/providers' directory to find all provider modules. + Returns a list of hidden import arguments for PyInstaller. + """ + hidden_imports = [] + # Get the absolute path to the directory containing this script + script_dir = os.path.dirname(os.path.abspath(__file__)) + # Construct the path to the providers directory relative to this script's location + providers_path = os.path.join(script_dir, "..", "rotator_library", "providers") + + if not os.path.isdir(providers_path): + print(f"Error: Directory not found at '{os.path.abspath(providers_path)}'") + return [] + + for filename in os.listdir(providers_path): + if filename.endswith("_provider.py") and filename != "__init__.py": + module_name = f"rotator_library.providers.{filename[:-3]}" + hidden_imports.append(f"--hidden-import={module_name}") + return hidden_imports + + +def main(): + """ + Constructs and runs the PyInstaller command to build the executable. + """ + # Base PyInstaller command with optimizations + command = [ + sys.executable, + "-m", + "PyInstaller", + "--onefile", + "--name", + "proxy_app", + "--paths", + "../", + "--paths", + ".", + # Core imports + "--hidden-import=rotator_library", + "--hidden-import=tiktoken_ext.openai_public", + "--hidden-import=tiktoken_ext", + "--collect-data", + "litellm", + # Optimization: Exclude unused heavy modules + "--exclude-module=matplotlib", + "--exclude-module=IPython", + "--exclude-module=jupyter", + "--exclude-module=notebook", + "--exclude-module=PIL.ImageTk", + # Optimization: Enable UPX compression (if available) + "--upx-dir=upx" + if platform.system() != "Darwin" + else "--noupx", # macOS has issues with UPX + # Optimization: Strip debug symbols (smaller binary) + "--strip" + if platform.system() != "Windows" + else "--console", # Windows gets clean console + ] + + # Add hidden imports for providers + provider_imports = get_providers() + if not provider_imports: + print( + "Warning: No providers found. The build might not include any LLM providers." + ) + command.extend(provider_imports) + + # Add the main script + command.append("main.py") + + # Execute the command + print(f"Running command: {' '.join(command)}") + try: + # Run PyInstaller from the script's directory to ensure relative paths are correct + script_dir = os.path.dirname(os.path.abspath(__file__)) + subprocess.run(command, check=True, cwd=script_dir) + print("Build successful!") + except subprocess.CalledProcessError as e: + print(f"Build failed with error: {e}") + except FileNotFoundError: + print("Error: PyInstaller is not installed or not in the system's PATH.") + + +if __name__ == "__main__": + main() diff --git a/src/proxy_app/detailed_logger.py b/src/proxy_app/detailed_logger.py new file mode 100644 index 0000000000000000000000000000000000000000..07d346ffb8108501d6640948291e6e2a7d0037bf --- /dev/null +++ b/src/proxy_app/detailed_logger.py @@ -0,0 +1,187 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 Mirrowel + +# src/proxy_app/detailed_logger.py +""" +Raw I/O Logger for the Proxy Layer. + +This logger captures the UNMODIFIED HTTP request and response at the proxy boundary. +It is disabled by default and should only be enabled for debugging the proxy itself. + +Use this when you need to: +- Verify that requests/responses are not being corrupted +- Debug HTTP-level issues between the client and proxy +- Capture exact payloads as received/sent by the proxy + +For normal request/response logging with provider correlation, use the +TransactionLogger in the rotator_library instead (enabled via --enable-request-logging). + +Directory structure: + logs/raw_io/{YYYYMMDD_HHMMSS}_{request_id}/ + request.json # Unmodified incoming HTTP request + streaming_chunks.jsonl # If streaming mode + final_response.json # Unmodified outgoing HTTP response + metadata.json # Summary metadata +""" + +import json +import time +import uuid +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Optional +import logging + +from rotator_library.utils.resilient_io import ( + safe_write_json, + safe_log_write, + safe_mkdir, +) +from rotator_library.utils.paths import get_logs_dir + + +def _get_raw_io_logs_dir() -> Path: + """Get the raw I/O logs directory, creating it if needed.""" + logs_dir = get_logs_dir() + raw_io_dir = logs_dir / "raw_io" + raw_io_dir.mkdir(parents=True, exist_ok=True) + return raw_io_dir + + +class RawIOLogger: + """ + Logs raw HTTP request/response at the proxy boundary. + + This captures the EXACT data as received from and sent to the client, + without any transformations. Useful for debugging the proxy itself. + + DISABLED by default. Enable with --enable-raw-logging flag. + + Uses fire-and-forget logging - if disk writes fail, logs are dropped (not buffered) + to prevent memory issues, especially with streaming responses. + """ + + def __init__(self): + """ + Initializes the logger for a single request, creating a unique directory + to store all related log files. + """ + self.start_time = time.time() + self.request_id = str(uuid.uuid4()) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + self.log_dir = _get_raw_io_logs_dir() / f"{timestamp}_{self.request_id}" + self.streaming = False + self._dir_available = safe_mkdir(self.log_dir, logging) + + def _write_json(self, filename: str, data: Dict[str, Any]): + """Helper to write data to a JSON file in the log directory.""" + if not self._dir_available: + # Try to create directory again in case it was recreated + self._dir_available = safe_mkdir(self.log_dir, logging) + if not self._dir_available: + return + + safe_write_json( + self.log_dir / filename, + data, + logging, + atomic=False, + indent=4, + ensure_ascii=False, + ) + + def log_request(self, headers: Dict[str, Any], body: Dict[str, Any]): + """Logs the raw incoming request details.""" + self.streaming = body.get("stream", False) + request_data = { + "request_id": self.request_id, + "timestamp_utc": datetime.utcnow().isoformat(), + "headers": dict(headers), + "body": body, + } + self._write_json("request.json", request_data) + + def log_stream_chunk(self, chunk: Dict[str, Any]): + """Logs an individual chunk from a streaming response to a JSON Lines file.""" + if not self._dir_available: + return + + log_entry = {"timestamp_utc": datetime.utcnow().isoformat(), "chunk": chunk} + content = json.dumps(log_entry, ensure_ascii=False) + "\n" + safe_log_write(self.log_dir / "streaming_chunks.jsonl", content, logging) + + def log_final_response( + self, status_code: int, headers: Optional[Dict[str, Any]], body: Dict[str, Any] + ): + """Logs the raw outgoing response.""" + end_time = time.time() + duration_ms = (end_time - self.start_time) * 1000 + + response_data = { + "request_id": self.request_id, + "timestamp_utc": datetime.utcnow().isoformat(), + "status_code": status_code, + "duration_ms": round(duration_ms), + "headers": dict(headers) if headers else None, + "body": body, + } + self._write_json("final_response.json", response_data) + self._log_metadata(response_data) + + def _extract_reasoning(self, response_body: Dict[str, Any]) -> Optional[str]: + """Recursively searches for and extracts 'reasoning' fields from the response body.""" + if not isinstance(response_body, dict): + return None + + if "reasoning" in response_body: + return response_body["reasoning"] + + if "choices" in response_body and response_body["choices"]: + message = response_body["choices"][0].get("message", {}) + if "reasoning" in message: + return message["reasoning"] + if "reasoning_content" in message: + return message["reasoning_content"] + + return None + + def _log_metadata(self, response_data: Dict[str, Any]): + """Logs a summary of the transaction for quick analysis.""" + usage = response_data.get("body", {}).get("usage") or {} + model = response_data.get("body", {}).get("model", "N/A") + finish_reason = "N/A" + if ( + "choices" in response_data.get("body", {}) + and response_data["body"]["choices"] + ): + finish_reason = response_data["body"]["choices"][0].get( + "finish_reason", "N/A" + ) + + metadata = { + "request_id": self.request_id, + "timestamp_utc": response_data["timestamp_utc"], + "duration_ms": response_data["duration_ms"], + "status_code": response_data["status_code"], + "model": model, + "streaming": self.streaming, + "usage": { + "prompt_tokens": usage.get("prompt_tokens"), + "completion_tokens": usage.get("completion_tokens"), + "total_tokens": usage.get("total_tokens"), + }, + "finish_reason": finish_reason, + "reasoning_found": False, + "reasoning_content": None, + } + + reasoning = self._extract_reasoning(response_data.get("body", {})) + if reasoning: + metadata["reasoning_found"] = True + metadata["reasoning_content"] = reasoning + + self._write_json("metadata.json", metadata) + + +# Backward compatibility alias +DetailedLogger = RawIOLogger diff --git a/src/proxy_app/launcher_tui.py b/src/proxy_app/launcher_tui.py new file mode 100644 index 0000000000000000000000000000000000000000..68338b020d1e8ffc4f6a1e77050e4a7a9017ee33 --- /dev/null +++ b/src/proxy_app/launcher_tui.py @@ -0,0 +1,1084 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 Mirrowel + +""" +Interactive TUI launcher for the LLM API Key Proxy. +Provides a beautiful Rich-based interface for configuration and execution. +""" + +import json +import os +import sys +from pathlib import Path +from rich.console import Console +from rich.prompt import IntPrompt, Prompt +from rich.panel import Panel +from rich.text import Text +from dotenv import load_dotenv, set_key + +console = Console() + + +def _get_env_file() -> Path: + """ + Get .env file path (lightweight - no heavy imports). + + Returns: + Path to .env file - EXE directory if frozen, else current working directory + """ + if getattr(sys, "frozen", False): + # Running as PyInstaller EXE - use EXE's directory + return Path(sys.executable).parent / ".env" + # Running as script - use current working directory + return Path.cwd() / ".env" + + +def clear_screen(subtitle: str = ""): + """ + Cross-platform terminal clear with optional header. + + Uses native OS commands instead of ANSI escape sequences: + - Windows (conhost & Windows Terminal): cls + - Unix-like systems (Linux, Mac): clear + + Args: + subtitle: If provided, displays a header panel with this subtitle. + If empty/None, just clears the screen. + """ + os.system("cls" if os.name == "nt" else "clear") + if subtitle: + console.print( + Panel( + f"[bold cyan]{subtitle}[/bold cyan]", + title="--- API Key Proxy ---", + ) + ) + + +class LauncherConfig: + """Manages launcher_config.json (host, port, logging only)""" + + def __init__(self, config_path: Path = Path("launcher_config.json")): + self.config_path = config_path + self.defaults = { + "host": "127.0.0.1", + "port": 8000, + "enable_request_logging": False, + "enable_raw_logging": False, + } + self.config = self.load() + + def load(self) -> dict: + """Load config from file or create with defaults.""" + if self.config_path.exists(): + try: + with open(self.config_path, "r") as f: + config = json.load(f) + # Merge with defaults for any missing keys + for key, value in self.defaults.items(): + if key not in config: + config[key] = value + return config + except (json.JSONDecodeError, IOError): + return self.defaults.copy() + return self.defaults.copy() + + def save(self): + """Save current config to file.""" + import datetime + + self.config["last_updated"] = datetime.datetime.now().isoformat() + try: + with open(self.config_path, "w") as f: + json.dump(self.config, f, indent=2) + except IOError as e: + console.print(f"[red]Error saving config: {e}[/red]") + + def update(self, **kwargs): + """Update config values.""" + self.config.update(kwargs) + self.save() + + @staticmethod + def update_proxy_api_key(new_key: str): + """Update PROXY_API_KEY in .env only""" + env_file = _get_env_file() + set_key(str(env_file), "PROXY_API_KEY", new_key) + load_dotenv(dotenv_path=env_file, override=True) + + +class SettingsDetector: + """Detects settings from .env for display""" + + @staticmethod + def _load_local_env() -> dict: + """Load environment variables from local .env file only""" + env_file = _get_env_file() + env_dict = {} + if not env_file.exists(): + return env_dict + try: + with open(env_file, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" in line: + key, _, value = line.partition("=") + key, value = key.strip(), value.strip() + if value and value[0] in ('"', "'") and value[-1] == value[0]: + value = value[1:-1] + env_dict[key] = value + except (IOError, OSError): + pass + return env_dict + + @staticmethod + def get_all_settings() -> dict: + """Returns comprehensive settings overview (includes provider_settings which triggers heavy imports)""" + return { + "credentials": SettingsDetector.detect_credentials(), + "custom_bases": SettingsDetector.detect_custom_api_bases(), + "model_definitions": SettingsDetector.detect_model_definitions(), + "concurrency_limits": SettingsDetector.detect_concurrency_limits(), + "model_filters": SettingsDetector.detect_model_filters(), + "provider_settings": SettingsDetector.detect_provider_settings(), + } + + @staticmethod + def get_basic_settings() -> dict: + """Returns basic settings overview without provider_settings (avoids heavy imports)""" + return { + "credentials": SettingsDetector.detect_credentials(), + "custom_bases": SettingsDetector.detect_custom_api_bases(), + "model_definitions": SettingsDetector.detect_model_definitions(), + "concurrency_limits": SettingsDetector.detect_concurrency_limits(), + "model_filters": SettingsDetector.detect_model_filters(), + } + + @staticmethod + def detect_credentials() -> dict: + """Detect API keys and OAuth credentials""" + import re + from pathlib import Path + + providers = {} + + # Scan for API keys + env_vars = SettingsDetector._load_local_env() + for key, value in env_vars.items(): + if "_API_KEY" in key and key != "PROXY_API_KEY": + provider = key.split("_API_KEY")[0].lower() + if provider not in providers: + providers[provider] = {"api_keys": 0, "oauth": 0, "custom": False} + providers[provider]["api_keys"] += 1 + + # Scan for file-based OAuth credentials + oauth_dir = Path("oauth_creds") + if oauth_dir.exists(): + for file in oauth_dir.glob("*_oauth_*.json"): + provider = file.name.split("_oauth_")[0] + if provider not in providers: + providers[provider] = {"api_keys": 0, "oauth": 0, "custom": False} + providers[provider]["oauth"] += 1 + + # Scan for env-based OAuth credentials + # Maps provider name to the ENV_PREFIX used by the provider + # (duplicated from credential_manager to avoid heavy imports) + env_oauth_providers = { + "gemini_cli": "GEMINI_CLI", + "antigravity": "ANTIGRAVITY", + "qwen_code": "QWEN_CODE", + "iflow": "IFLOW", + } + + for provider, env_prefix in env_oauth_providers.items(): + oauth_count = 0 + + # Check numbered credentials (PROVIDER_N_ACCESS_TOKEN pattern) + numbered_pattern = re.compile(rf"^{env_prefix}_(\d+)_ACCESS_TOKEN$") + for key in env_vars.keys(): + match = numbered_pattern.match(key) + if match: + index = match.group(1) + refresh_key = f"{env_prefix}_{index}_REFRESH_TOKEN" + if refresh_key in env_vars and env_vars[refresh_key]: + oauth_count += 1 + + # Check legacy single credential (if no numbered found) + if oauth_count == 0: + access_key = f"{env_prefix}_ACCESS_TOKEN" + refresh_key = f"{env_prefix}_REFRESH_TOKEN" + if env_vars.get(access_key) and env_vars.get(refresh_key): + oauth_count = 1 + + if oauth_count > 0: + if provider not in providers: + providers[provider] = {"api_keys": 0, "oauth": 0, "custom": False} + providers[provider]["oauth"] += oauth_count + + # Mark custom providers (have API_BASE set) + for provider in providers: + if os.getenv(f"{provider.upper()}_API_BASE"): + providers[provider]["custom"] = True + + return providers + + @staticmethod + def detect_custom_api_bases() -> dict: + """Detect custom API base URLs (not in hardcoded map)""" + from proxy_app.provider_urls import PROVIDER_URL_MAP + + bases = {} + env_vars = SettingsDetector._load_local_env() + for key, value in env_vars.items(): + if key.endswith("_API_BASE"): + provider = key.replace("_API_BASE", "").lower() + # Only include if NOT in hardcoded map + if provider not in PROVIDER_URL_MAP: + bases[provider] = value + return bases + + @staticmethod + def detect_model_definitions() -> dict: + """Detect provider model definitions""" + models = {} + env_vars = SettingsDetector._load_local_env() + for key, value in env_vars.items(): + if key.endswith("_MODELS"): + provider = key.replace("_MODELS", "").lower() + try: + parsed = json.loads(value) + if isinstance(parsed, dict): + models[provider] = len(parsed) + elif isinstance(parsed, list): + models[provider] = len(parsed) + except (json.JSONDecodeError, ValueError): + pass + return models + + @staticmethod + def detect_concurrency_limits() -> dict: + """Detect max concurrent requests per key""" + limits = {} + env_vars = SettingsDetector._load_local_env() + for key, value in env_vars.items(): + if key.startswith("MAX_CONCURRENT_REQUESTS_PER_KEY_"): + provider = key.replace("MAX_CONCURRENT_REQUESTS_PER_KEY_", "").lower() + try: + limits[provider] = int(value) + except (json.JSONDecodeError, ValueError): + pass + return limits + + @staticmethod + def detect_model_filters() -> dict: + """Detect active model filters (basic info only: defined or not)""" + filters = {} + env_vars = SettingsDetector._load_local_env() + for key, value in env_vars.items(): + if key.startswith("IGNORE_MODELS_") or key.startswith("WHITELIST_MODELS_"): + filter_type = "ignore" if key.startswith("IGNORE") else "whitelist" + provider = key.replace(f"{filter_type.upper()}_MODELS_", "").lower() + if provider not in filters: + filters[provider] = {"has_ignore": False, "has_whitelist": False} + if filter_type == "ignore": + filters[provider]["has_ignore"] = True + else: + filters[provider]["has_whitelist"] = True + return filters + + @staticmethod + def detect_provider_settings() -> dict: + """Detect provider-specific settings (Antigravity, Gemini CLI)""" + try: + from proxy_app.settings_tool import PROVIDER_SETTINGS_MAP + except ImportError: + # Fallback for direct execution or testing + from .settings_tool import PROVIDER_SETTINGS_MAP + + provider_settings = {} + env_vars = SettingsDetector._load_local_env() + + for provider, definitions in PROVIDER_SETTINGS_MAP.items(): + modified_count = 0 + for key, definition in definitions.items(): + env_value = env_vars.get(key) + if env_value is not None: + # Check if value differs from default + default = definition.get("default") + setting_type = definition.get("type", "str") + + try: + if setting_type == "bool": + current = env_value.lower() in ("true", "1", "yes") + elif setting_type == "int": + current = int(env_value) + else: + current = env_value + + if current != default: + modified_count += 1 + except (ValueError, AttributeError): + pass + + if modified_count > 0: + provider_settings[provider] = modified_count + + return provider_settings + + +class LauncherTUI: + """Main launcher interface""" + + def __init__(self): + self.console = Console() + self.config = LauncherConfig() + self.running = True + self.env_file = _get_env_file() + # Load .env file to ensure environment variables are available + load_dotenv(dotenv_path=self.env_file, override=True) + + def needs_onboarding(self) -> bool: + """Check if onboarding is needed""" + return not self.env_file.exists() or not os.getenv("PROXY_API_KEY") + + def run(self): + """Main TUI loop""" + while self.running: + self.show_main_menu() + + def show_main_menu(self): + """Display main menu and handle selection""" + clear_screen() + + # Detect basic settings (excludes provider_settings to avoid heavy imports) + settings = SettingsDetector.get_basic_settings() + credentials = settings["credentials"] + custom_bases = settings["custom_bases"] + + # Check if setup is needed + show_warning = self.needs_onboarding() + + # Build title with GitHub link + self.console.print( + Panel.fit( + "[bold cyan]🚀 LLM API Key Proxy - Interactive Launcher[/bold cyan]", + border_style="cyan", + ) + ) + self.console.print( + "[dim]GitHub: [blue underline]https://github.com/Mirrowel/LLM-API-Key-Proxy[/blue underline][/dim]" + ) + + # Show warning if .env file doesn't exist + if show_warning: + self.console.print() + self.console.print( + Panel( + Text.from_markup( + "⚠️ [bold yellow]INITIAL SETUP REQUIRED[/bold yellow]\n\n" + "The proxy needs initial configuration:\n" + " ❌ No .env file found\n\n" + "Why this matters:\n" + " • The .env file stores your credentials and settings\n" + " • PROXY_API_KEY protects your proxy from unauthorized access\n" + " • Provider API keys enable LLM access\n\n" + "What to do:\n" + ' 1. Select option "3. Manage Credentials" to launch the credential tool\n' + " 2. The tool will create .env and set up PROXY_API_KEY automatically\n" + " 3. You can add provider credentials (API keys or OAuth)\n\n" + "⚠️ Note: The credential tool adds PROXY_API_KEY by default.\n" + " You can remove it later if you want an unsecured proxy." + ), + border_style="yellow", + expand=False, + ) + ) + # Show security warning if PROXY_API_KEY is missing (but .env exists) + elif not os.getenv("PROXY_API_KEY"): + self.console.print() + self.console.print( + Panel( + Text.from_markup( + "⚠️ [bold red]SECURITY WARNING: PROXY_API_KEY Not Set[/bold red]\n\n" + "Your proxy is currently UNSECURED!\n" + "Anyone can access it without authentication.\n\n" + "This is a serious security risk if your proxy is accessible\n" + "from the internet or untrusted networks.\n\n" + "👉 [bold]Recommended:[/bold] Set PROXY_API_KEY in .env file\n" + ' Use option "2. Configure Proxy Settings" → "3. Set Proxy API Key"\n' + ' or option "3. Manage Credentials"' + ), + border_style="red", + expand=False, + ) + ) + + # Show config + self.console.print() + self.console.print("[bold]📋 Proxy Configuration[/bold]") + self.console.print("━" * 70) + self.console.print(f" Host: {self.config.config['host']}") + self.console.print(f" Port: {self.config.config['port']}") + self.console.print( + f" Transaction Logging: {'✅ Enabled' if self.config.config['enable_request_logging'] else '❌ Disabled'}" + ) + self.console.print( + f" Raw I/O Logging: {'✅ Enabled' if self.config.config.get('enable_raw_logging', False) else '❌ Disabled'}" + ) + + # Show actual API key value + proxy_key = os.getenv("PROXY_API_KEY") + if proxy_key: + self.console.print(f" Proxy API Key: {proxy_key}") + else: + self.console.print(" Proxy API Key: [red]Not Set (INSECURE!)[/red]") + + # Show status summary + self.console.print() + self.console.print("[bold]📊 Status Summary[/bold]") + self.console.print("━" * 70) + provider_count = len(credentials) + custom_count = len(custom_bases) + + self.console.print(f" Providers: {provider_count} configured") + self.console.print(f" Custom Providers: {custom_count} configured") + # Note: provider_settings detection is deferred to avoid heavy imports on startup + has_advanced = bool( + settings["model_definitions"] + or settings["concurrency_limits"] + or settings["model_filters"] + ) + self.console.print( + f" Advanced Settings: {'Active (view in menu 4)' if has_advanced else 'None (view menu 4 for details)'}" + ) + + # Show menu + self.console.print() + self.console.print("━" * 70) + self.console.print() + self.console.print("[bold]🎯 Main Menu[/bold]") + self.console.print() + if show_warning: + self.console.print(" 1. ▶️ Run Proxy Server") + self.console.print(" 2. ⚙️ Configure Proxy Settings") + self.console.print( + " 3. 🔑 Manage Credentials ⬅️ [bold yellow]Start here![/bold yellow]" + ) + else: + self.console.print(" 1. ▶️ Run Proxy Server") + self.console.print(" 2. ⚙️ Configure Proxy Settings") + self.console.print(" 3. 🔑 Manage Credentials") + + self.console.print(" 4. 📊 View Provider & Advanced Settings") + self.console.print(" 5. 📈 View Quota & Usage Stats (Alpha)") + self.console.print(" 6. 🔄 Reload Configuration") + self.console.print(" 7. ℹ️ About") + self.console.print(" 8. 🚪 Exit") + + self.console.print() + self.console.print("━" * 70) + self.console.print() + + choice = Prompt.ask( + "Select option", + choices=["1", "2", "3", "4", "5", "6", "7", "8"], + show_choices=False, + ) + + if choice == "1": + self.run_proxy() + elif choice == "2": + self.show_config_menu() + elif choice == "3": + self.launch_credential_tool() + elif choice == "4": + self.show_provider_settings_menu() + elif choice == "5": + self.launch_quota_viewer() + elif choice == "6": + load_dotenv(dotenv_path=_get_env_file(), override=True) + self.config = LauncherConfig() # Reload config + self.console.print("\n[green]✅ Configuration reloaded![/green]") + elif choice == "7": + self.show_about() + elif choice == "8": + self.running = False + sys.exit(0) + + def confirm_setting_change(self, setting_name: str, warning_lines: list) -> bool: + """ + Display a warning and require Y/N (case-sensitive) confirmation. + Re-prompts until user enters exactly 'Y' or 'N'. + Returns True only if user enters 'Y'. + """ + clear_screen() + self.console.print() + self.console.print( + Panel( + Text.from_markup( + f"[bold yellow]⚠️ WARNING: You are about to change the {setting_name}[/bold yellow]\n\n" + + "\n".join(warning_lines) + + "\n\n[bold]If you are not sure about changing this - don't.[/bold]" + ), + border_style="yellow", + expand=False, + ) + ) + + while True: + response = Prompt.ask( + "Enter [bold]Y[/bold] to confirm, [bold]N[/bold] to cancel (case-sensitive)" + ) + if response == "Y": + return True + elif response == "N": + self.console.print("\n[dim]Operation cancelled.[/dim]") + return False + else: + self.console.print( + "[red]Please enter exactly 'Y' or 'N' (case-sensitive)[/red]" + ) + + def show_config_menu(self): + """Display configuration sub-menu""" + while True: + clear_screen() + + self.console.print( + Panel.fit( + "[bold cyan]⚙️ Proxy Configuration[/bold cyan]", border_style="cyan" + ) + ) + + self.console.print() + self.console.print("[bold]📋 Current Settings[/bold]") + self.console.print("━" * 70) + self.console.print(f" Host: {self.config.config['host']}") + self.console.print(f" Port: {self.config.config['port']}") + self.console.print( + f" Transaction Logging: {'✅ Enabled' if self.config.config['enable_request_logging'] else '❌ Disabled'}" + ) + self.console.print( + f" Raw I/O Logging: {'✅ Enabled' if self.config.config.get('enable_raw_logging', False) else '❌ Disabled'}" + ) + self.console.print( + f" Proxy API Key: {'✅ Set' if os.getenv('PROXY_API_KEY') else '❌ Not Set'}" + ) + + self.console.print() + self.console.print("━" * 70) + self.console.print() + self.console.print("[bold]⚙️ Configuration Options[/bold]") + self.console.print() + self.console.print(" 1. 🌐 Set Host IP") + self.console.print(" 2. 🔌 Set Port") + self.console.print(" 3. 🔑 Set Proxy API Key") + self.console.print(" 4. 📝 Toggle Transaction Logging") + self.console.print(" 5. 📋 Toggle Raw I/O Logging") + self.console.print(" 6. 🔄 Reset to Default Settings") + self.console.print(" 7. ↩️ Back to Main Menu") + + self.console.print() + self.console.print("━" * 70) + self.console.print() + + choice = Prompt.ask( + "Select option", + choices=["1", "2", "3", "4", "5", "6", "7"], + show_choices=False, + ) + + if choice == "1": + # Show warning and require confirmation + confirmed = self.confirm_setting_change( + "Host IP", + [ + "Changing the host IP affects which network interfaces the proxy listens on:", + " • [cyan]127.0.0.1[/cyan] = Local access only (recommended for development)", + " • [cyan]0.0.0.0[/cyan] = Accessible from all network interfaces", + "", + "Applications configured to connect to the old host may fail to connect.", + ], + ) + if not confirmed: + continue + + new_host = Prompt.ask( + "Enter new host IP", default=self.config.config["host"] + ) + self.config.update(host=new_host) + self.console.print(f"\n[green]✅ Host updated to: {new_host}[/green]") + elif choice == "2": + # Show warning and require confirmation + confirmed = self.confirm_setting_change( + "Port", + [ + "Changing the port will affect all applications currently configured", + "to connect to your proxy on the existing port.", + "", + "Applications using the old port will fail to connect.", + ], + ) + if not confirmed: + continue + + new_port = IntPrompt.ask( + "Enter new port", default=self.config.config["port"] + ) + if 1 <= new_port <= 65535: + self.config.update(port=new_port) + self.console.print( + f"\n[green]✅ Port updated to: {new_port}[/green]" + ) + else: + self.console.print("\n[red]❌ Port must be between 1-65535[/red]") + elif choice == "3": + # Show warning and require confirmation + confirmed = self.confirm_setting_change( + "Proxy API Key", + [ + "This is the authentication key that applications use to access your proxy.", + "", + "[bold red]⚠️ Changing this will BREAK all applications currently configured", + " with the existing API key![/bold red]", + "", + "[bold cyan]💡 If you want to add provider API keys (OpenAI, Gemini, etc.),", + ' go to "3. 🔑 Manage Credentials" in the main menu instead.[/bold cyan]', + ], + ) + if not confirmed: + continue + + current = os.getenv("PROXY_API_KEY", "") + new_key = Prompt.ask( + "Enter new Proxy API Key (leave empty to disable authentication)", + default=current, + ) + + if new_key != current: + # If setting to empty, show additional warning + if not new_key: + self.console.print( + "\n[bold red]⚠️ Authentication will be DISABLED - anyone can access your proxy![/bold red]" + ) + Prompt.ask("Press Enter to continue", default="") + + LauncherConfig.update_proxy_api_key(new_key) + + if new_key: + self.console.print( + "\n[green]✅ Proxy API Key updated successfully![/green]" + ) + self.console.print(" Updated in .env file") + else: + self.console.print( + "\n[yellow]⚠️ Proxy API Key cleared - authentication disabled![/yellow]" + ) + self.console.print(" Updated in .env file") + else: + self.console.print("\n[yellow]No changes made[/yellow]") + elif choice == "4": + current = self.config.config["enable_request_logging"] + self.config.update(enable_request_logging=not current) + self.console.print( + f"\n[green]✅ Transaction Logging {'enabled' if not current else 'disabled'}![/green]" + ) + elif choice == "5": + current = self.config.config.get("enable_raw_logging", False) + self.config.update(enable_raw_logging=not current) + self.console.print( + f"\n[green]✅ Raw I/O Logging {'enabled' if not current else 'disabled'}![/green]" + ) + elif choice == "6": + # Reset to Default Settings + # Define defaults + default_host = "127.0.0.1" + default_port = 8000 + default_logging = False + default_raw_logging = False + default_api_key = "VerysecretKey" + + # Get current values + current_host = self.config.config["host"] + current_port = self.config.config["port"] + current_logging = self.config.config["enable_request_logging"] + current_raw_logging = self.config.config.get( + "enable_raw_logging", False + ) + current_api_key = os.getenv("PROXY_API_KEY", "") + + # Build comparison table + warning_lines = [ + "This will reset ALL proxy settings to their defaults:", + "", + "[bold] Setting Current Value → Default Value[/bold]", + " " + "─" * 62, + f" Host IP {current_host:20} → {default_host}", + f" Port {str(current_port):20} → {default_port}", + f" Transaction Logging {'Enabled':20} → Disabled" + if current_logging + else f" Transaction Logging {'Disabled':20} → Disabled", + f" Raw I/O Logging {'Enabled':20} → Disabled" + if current_raw_logging + else f" Raw I/O Logging {'Disabled':20} → Disabled", + f" Proxy API Key {current_api_key[:20]:20} → {default_api_key}", + "", + "[bold red]⚠️ This may break applications configured with current settings![/bold red]", + ] + + confirmed = self.confirm_setting_change( + "Settings (Reset to Defaults)", warning_lines + ) + if not confirmed: + continue + + # Apply defaults + self.config.update( + host=default_host, + port=default_port, + enable_request_logging=default_logging, + enable_raw_logging=default_raw_logging, + ) + LauncherConfig.update_proxy_api_key(default_api_key) + + self.console.print( + "\n[green]✅ All settings have been reset to defaults![/green]" + ) + self.console.print(f" Host: {default_host}") + self.console.print(f" Port: {default_port}") + self.console.print(f" Transaction Logging: Disabled") + self.console.print(f" Raw I/O Logging: Disabled") + self.console.print(f" Proxy API Key: {default_api_key}") + elif choice == "7": + break + + def show_provider_settings_menu(self): + """Display provider/advanced settings (read-only + launch tool)""" + clear_screen() + + # Use basic settings to avoid heavy imports - provider_settings deferred to Settings Tool + settings = SettingsDetector.get_basic_settings() + + credentials = settings["credentials"] + custom_bases = settings["custom_bases"] + model_defs = settings["model_definitions"] + concurrency = settings["concurrency_limits"] + filters = settings["model_filters"] + + self.console.print( + Panel.fit( + "[bold cyan]📊 Provider & Advanced Settings[/bold cyan]", + border_style="cyan", + ) + ) + + # Configured Providers + self.console.print() + self.console.print("[bold]📊 Configured Providers[/bold]") + self.console.print("━" * 70) + if credentials: + for provider, info in credentials.items(): + provider_name = provider.title() + parts = [] + if info["api_keys"] > 0: + parts.append( + f"{info['api_keys']} API key{'s' if info['api_keys'] > 1 else ''}" + ) + if info["oauth"] > 0: + parts.append( + f"{info['oauth']} OAuth credential{'s' if info['oauth'] > 1 else ''}" + ) + + display = " + ".join(parts) + if info["custom"]: + display += " (Custom)" + + self.console.print(f" ✅ {provider_name:20} {display}") + else: + self.console.print(" [dim]No providers configured[/dim]") + + # Custom API Bases + if custom_bases: + self.console.print() + self.console.print("[bold]🌐 Custom API Bases[/bold]") + self.console.print("━" * 70) + for provider, base in custom_bases.items(): + self.console.print(f" • {provider:15} {base}") + + # Model Definitions + if model_defs: + self.console.print() + self.console.print("[bold]📦 Provider Model Definitions[/bold]") + self.console.print("━" * 70) + for provider, count in model_defs.items(): + self.console.print( + f" • {provider:15} {count} model{'s' if count > 1 else ''} configured" + ) + + # Concurrency Limits + if concurrency: + self.console.print() + self.console.print("[bold]⚡ Concurrency Limits[/bold]") + self.console.print("━" * 70) + for provider, limit in concurrency.items(): + self.console.print(f" • {provider:15} {limit} requests/key") + self.console.print(" • Default: 1 request/key (all others)") + + # Model Filters (basic info only) + if filters: + self.console.print() + self.console.print("[bold]🎯 Model Filters[/bold]") + self.console.print("━" * 70) + for provider, filter_info in filters.items(): + status_parts = [] + if filter_info["has_whitelist"]: + status_parts.append("Whitelist") + if filter_info["has_ignore"]: + status_parts.append("Ignore list") + status = " + ".join(status_parts) if status_parts else "None" + self.console.print(f" • {provider:15} ✅ {status}") + + # Provider-Specific Settings (deferred to Settings Tool to avoid heavy imports) + self.console.print() + self.console.print("[bold]🔬 Provider-Specific Settings[/bold]") + self.console.print("━" * 70) + self.console.print( + " [dim]Launch Settings Tool to view/configure provider-specific settings[/dim]" + ) + + # Actions + self.console.print() + self.console.print("━" * 70) + self.console.print() + self.console.print("[bold]💡 Actions[/bold]") + self.console.print() + self.console.print( + " 1. 🔧 Launch Settings Tool (configure advanced settings)" + ) + self.console.print(" 2. ↩️ Back to Main Menu") + + self.console.print() + self.console.print("━" * 70) + self.console.print( + "[dim]ℹ️ Advanced settings are stored in .env file.\n Use the Settings Tool to configure them interactively.[/dim]" + ) + self.console.print() + self.console.print( + "[dim]⚠️ Note: Settings Tool supports only common configuration types.\n For complex settings, edit .env directly.[/dim]" + ) + self.console.print() + + choice = Prompt.ask("Select option", choices=["1", "2"], show_choices=False) + + if choice == "1": + self.launch_settings_tool() + # choice == "2" returns to main menu + + def launch_credential_tool(self): + """Launch credential management tool""" + import time + + # CRITICAL: Show full loading UI to replace the 6-7 second blank wait + clear_screen() + + _start_time = time.time() + + # Show the same header as standalone mode + self.console.print("━" * 70) + self.console.print("Interactive Credential Setup Tool") + self.console.print("GitHub: https://github.com/Mirrowel/LLM-API-Key-Proxy") + self.console.print("━" * 70) + self.console.print("Loading credential management components...") + + # Now import with spinner (this is where the 6-7 second delay happens) + with self.console.status("Initializing credential tool...", spinner="dots"): + from rotator_library.credential_tool import ( + run_credential_tool, + _ensure_providers_loaded, + ) + + _, PROVIDER_PLUGINS = _ensure_providers_loaded() + self.console.print("✓ Credential tool initialized") + + _elapsed = time.time() - _start_time + self.console.print( + f"✓ Tool ready in {_elapsed:.2f}s ({len(PROVIDER_PLUGINS)} providers available)" + ) + + # Small delay to let user see the ready message + time.sleep(0.5) + + # Run the tool with from_launcher=True to skip duplicate loading screen + run_credential_tool(from_launcher=True) + # Reload environment after credential tool + load_dotenv(dotenv_path=_get_env_file(), override=True) + + def launch_settings_tool(self): + """Launch settings configuration tool""" + import time + + clear_screen() + + self.console.print("━" * 70) + self.console.print("Advanced Settings Configuration Tool") + self.console.print("━" * 70) + + _start_time = time.time() + + with self.console.status("Initializing settings tool...", spinner="dots"): + from proxy_app.settings_tool import run_settings_tool + + _elapsed = time.time() - _start_time + self.console.print(f"✓ Settings tool ready in {_elapsed:.2f}s") + + time.sleep(0.3) + + run_settings_tool() + # Reload environment after settings tool + load_dotenv(dotenv_path=_get_env_file(), override=True) + + def launch_quota_viewer(self): + """Launch the quota stats viewer""" + clear_screen() + + self.console.print("━" * 70) + self.console.print("Quota & Usage Statistics Viewer") + self.console.print("━" * 70) + self.console.print() + + # Import the lightweight viewer (no heavy imports) + from proxy_app.quota_viewer import run_quota_viewer + + run_quota_viewer() + + def show_about(self): + """Display About page with project information""" + clear_screen() + + self.console.print( + Panel.fit( + "[bold cyan]ℹ️ About LLM API Key Proxy[/bold cyan]", border_style="cyan" + ) + ) + + self.console.print() + self.console.print("[bold]📦 Project Information[/bold]") + self.console.print("━" * 70) + self.console.print(" [bold cyan]LLM API Key Proxy[/bold cyan]") + self.console.print( + " A lightweight, high-performance proxy server for managing" + ) + self.console.print(" LLM API keys with automatic rotation and OAuth support") + self.console.print() + self.console.print( + " [dim]GitHub:[/dim] [blue underline]https://github.com/Mirrowel/LLM-API-Key-Proxy[/blue underline]" + ) + + self.console.print() + self.console.print("[bold]✨ Key Features[/bold]") + self.console.print("━" * 70) + self.console.print( + " • [green]Smart Key Rotation[/green] - Automatic rotation across multiple API keys" + ) + self.console.print( + " • [green]OAuth Support[/green] - Automated OAuth flows for supported providers" + ) + self.console.print( + " • [green]Multiple Providers[/green] - Support for 10+ LLM providers" + ) + self.console.print( + " • [green]Custom Providers[/green] - Easy integration of custom OpenAI-compatible APIs" + ) + self.console.print( + " • [green]Advanced Filtering[/green] - Model whitelists and ignore lists per provider" + ) + self.console.print( + " • [green]Concurrency Control[/green] - Per-key rate limiting and request management" + ) + self.console.print( + " • [green]Cost Tracking[/green] - Track usage and costs across all providers" + ) + self.console.print( + " • [green]Interactive TUI[/green] - Beautiful terminal interface for easy configuration" + ) + + self.console.print() + self.console.print("[bold]📝 License & Credits[/bold]") + self.console.print("━" * 70) + self.console.print(" Made with ❤️ by the community") + self.console.print(" Open source - contributions welcome!") + + self.console.print() + self.console.print("━" * 70) + self.console.print() + + Prompt.ask("Press Enter to return to main menu", default="") + + def run_proxy(self): + """Prepare and launch proxy in same window""" + # Check if forced onboarding needed + if self.needs_onboarding(): + clear_screen() + self.console.print( + Panel( + Text.from_markup( + "⚠️ [bold yellow]Setup Required[/bold yellow]\n\n" + "Cannot start without .env.\n" + "Launching credential tool..." + ), + border_style="yellow", + ) + ) + + # Force credential tool + from rotator_library.credential_tool import ( + ensure_env_defaults, + run_credential_tool, + ) + + ensure_env_defaults() + load_dotenv(dotenv_path=_get_env_file(), override=True) + run_credential_tool() + load_dotenv(dotenv_path=_get_env_file(), override=True) + + # Check again after credential tool + if not os.getenv("PROXY_API_KEY"): + self.console.print( + "\n[red]❌ PROXY_API_KEY still not set. Cannot start proxy.[/red]" + ) + return + + # Clear console and modify sys.argv + clear_screen() + self.console.print( + f"\n[bold green]🚀 Starting proxy on {self.config.config['host']}:{self.config.config['port']}...[/bold green]\n" + ) + + # Brief pause so user sees the message before main.py takes over + import time + + time.sleep(0.5) + + # Reconstruct sys.argv for main.py + sys.argv = [ + "main.py", + "--host", + self.config.config["host"], + "--port", + str(self.config.config["port"]), + ] + if self.config.config["enable_request_logging"]: + sys.argv.append("--enable-request-logging") + if self.config.config.get("enable_raw_logging", False): + sys.argv.append("--enable-raw-logging") + + # Exit TUI - main.py will continue execution + self.running = False + + +def run_launcher_tui(): + """Entry point for launcher TUI""" + tui = LauncherTUI() + tui.run() diff --git a/src/proxy_app/main.py b/src/proxy_app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..e5b7e1a7abe0ec0b5a850542cac74cf77b094413 --- /dev/null +++ b/src/proxy_app/main.py @@ -0,0 +1,1731 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 Mirrowel + +import time +import uuid + +# Phase 1: Minimal imports for arg parsing and TUI +import asyncio +import os +from pathlib import Path +import sys +import argparse +import logging + +# --- Argument Parsing (BEFORE heavy imports) --- +parser = argparse.ArgumentParser(description="API Key Proxy Server") +parser.add_argument( + "--host", type=str, default="0.0.0.0", help="Host to bind the server to." +) +parser.add_argument("--port", type=int, default=8000, help="Port to run the server on.") +parser.add_argument( + "--enable-request-logging", + action="store_true", + help="Enable transaction logging in the library (logs request/response with provider correlation).", +) +parser.add_argument( + "--enable-raw-logging", + action="store_true", + help="Enable raw I/O logging at proxy boundary (captures unmodified HTTP data, disabled by default).", +) +parser.add_argument( + "--add-credential", + action="store_true", + help="Launch the interactive tool to add a new OAuth credential.", +) +args, _ = parser.parse_known_args() + +# Add the 'src' directory to the Python path +sys.path.append(str(Path(__file__).resolve().parent.parent)) + +# Check if we should launch TUI (no arguments = TUI mode) +if len(sys.argv) == 1: + # TUI MODE - Load ONLY what's needed for the launcher (fast path!) + from proxy_app.launcher_tui import run_launcher_tui + + run_launcher_tui() + # Launcher modifies sys.argv and returns, or exits if user chose Exit + # If we get here, user chose "Run Proxy" and sys.argv is modified + # Re-parse arguments with modified sys.argv + args = parser.parse_args() + +# Check if credential tool mode (also doesn't need heavy proxy imports) +if args.add_credential: + from rotator_library.credential_tool import run_credential_tool + + run_credential_tool() + sys.exit(0) + +# If we get here, we're ACTUALLY running the proxy - NOW show startup messages and start timer +_start_time = time.time() + +# Load all .env files from root folder (main .env first, then any additional *.env files) +from dotenv import load_dotenv +from glob import glob + +# Get the application root directory (EXE dir if frozen, else CWD) +# Inlined here to avoid triggering heavy rotator_library imports before loading screen +if getattr(sys, "frozen", False): + _root_dir = Path(sys.executable).parent +else: + _root_dir = Path.cwd() + +# [HUGGING FACE SUPPORT] If a bulk environment block is provided via Secret, save it to a file +# This allows users to paste their entire .env content into a single HF Secret called CONFIG_ENV +_bulk_env = os.getenv("CONFIG_ENV") +if _bulk_env: + _bulk_env_file = _root_dir / "bulk_config.env" + with open(_bulk_env_file, "w", encoding="utf-8") as _f: + _f.write(_bulk_env) + print(f"📝 Detected 'CONFIG_ENV' secret, saved to '{_bulk_env_file.name}'") + +# Load main .env first +load_dotenv(_root_dir / ".env") + +# Load any additional .env files (e.g., antigravity_all_combined.env, gemini_cli_all_combined.env) +_env_files_found = list(_root_dir.glob("*.env")) +for _env_file in sorted(_root_dir.glob("*.env")): + if _env_file.name != ".env": # Skip main .env (already loaded) + load_dotenv(_env_file, override=False) # Don't override existing values + +# Log discovered .env files for deployment verification +if _env_files_found: + _env_names = [_ef.name for _ef in _env_files_found] + print(f"📁 Loaded {len(_env_files_found)} .env file(s): {', '.join(_env_names)}") + +# Get proxy API key for display +proxy_api_key = os.getenv("PROXY_API_KEY") +if proxy_api_key: + key_display = f"✓ {proxy_api_key}" +else: + key_display = "✗ Not Set (INSECURE - anyone can access!)" + +print("━" * 70) +print(f"Starting proxy on {args.host}:{args.port}") +print(f"Proxy API Key: {key_display}") +print(f"GitHub: https://github.com/Mirrowel/LLM-API-Key-Proxy") +print("━" * 70) +print("Loading server components...") + + +# Phase 2: Load Rich for loading spinner (lightweight) +from rich.console import Console + +_console = Console() + +# Phase 3: Heavy dependencies with granular loading messages +print(" → Loading FastAPI framework...") +with _console.status("[dim]Loading FastAPI framework...", spinner="dots"): + from contextlib import asynccontextmanager + from fastapi import FastAPI, Request, HTTPException, Depends + from fastapi.middleware.cors import CORSMiddleware + from fastapi.responses import StreamingResponse, JSONResponse + from fastapi.security import APIKeyHeader + +print(" → Loading core dependencies...") +with _console.status("[dim]Loading core dependencies...", spinner="dots"): + from dotenv import load_dotenv + import colorlog + import json + from typing import AsyncGenerator, Any, List, Optional, Union + from pydantic import BaseModel, ConfigDict, Field + + # --- Early Log Level Configuration --- + logging.getLogger("LiteLLM").setLevel(logging.WARNING) + +print(" → Loading LiteLLM library...") +with _console.status("[dim]Loading LiteLLM library...", spinner="dots"): + import litellm + +# Phase 4: Application imports with granular loading messages +print(" → Initializing proxy core...") +with _console.status("[dim]Initializing proxy core...", spinner="dots"): + from rotator_library import RotatingClient + from rotator_library.credential_manager import CredentialManager + from rotator_library.background_refresher import BackgroundRefresher + from rotator_library.model_info_service import init_model_info_service + from proxy_app.request_logger import log_request_to_console + from proxy_app.batch_manager import EmbeddingBatcher + from proxy_app.detailed_logger import RawIOLogger + +print(" → Discovering provider plugins...") +# Provider lazy loading happens during import, so time it here +_provider_start = time.time() +with _console.status("[dim]Discovering provider plugins...", spinner="dots"): + from rotator_library import ( + PROVIDER_PLUGINS, + ) # This triggers lazy load via __getattr__ +_provider_time = time.time() - _provider_start + +# Get count after import (without timing to avoid double-counting) +_plugin_count = len(PROVIDER_PLUGINS) + + +# --- Pydantic Models --- +class EmbeddingRequest(BaseModel): + model: str + input: Union[str, List[str]] + input_type: Optional[str] = None + dimensions: Optional[int] = None + user: Optional[str] = None + + +class ModelCard(BaseModel): + """Basic model card for minimal response.""" + + id: str + object: str = "model" + created: int = Field(default_factory=lambda: int(time.time())) + owned_by: str = "Mirro-Proxy" + + +class ModelCapabilities(BaseModel): + """Model capability flags.""" + + tool_choice: bool = False + function_calling: bool = False + reasoning: bool = False + vision: bool = False + system_messages: bool = True + prompt_caching: bool = False + assistant_prefill: bool = False + + +class EnrichedModelCard(BaseModel): + """Extended model card with pricing and capabilities.""" + + id: str + object: str = "model" + created: int = Field(default_factory=lambda: int(time.time())) + owned_by: str = "unknown" + # Pricing (optional - may not be available for all models) + input_cost_per_token: Optional[float] = None + output_cost_per_token: Optional[float] = None + cache_read_input_token_cost: Optional[float] = None + cache_creation_input_token_cost: Optional[float] = None + # Limits (optional) + max_input_tokens: Optional[int] = None + max_output_tokens: Optional[int] = None + context_window: Optional[int] = None + # Capabilities + mode: str = "chat" + supported_modalities: List[str] = Field(default_factory=lambda: ["text"]) + supported_output_modalities: List[str] = Field(default_factory=lambda: ["text"]) + capabilities: Optional[ModelCapabilities] = None + # Debug info (optional) + _sources: Optional[List[str]] = None + _match_type: Optional[str] = None + + model_config = ConfigDict(extra="allow") # Allow extra fields from the service + + +class ModelList(BaseModel): + """List of models response.""" + + object: str = "list" + data: List[ModelCard] + + +class EnrichedModelList(BaseModel): + """List of enriched models with pricing and capabilities.""" + + object: str = "list" + data: List[EnrichedModelCard] + + +# --- Anthropic API Models (imported from library) --- +from rotator_library.anthropic_compat import ( + AnthropicMessagesRequest, + AnthropicCountTokensRequest, +) + + +# Calculate total loading time +_elapsed = time.time() - _start_time +print( + f"✓ Server ready in {_elapsed:.2f}s ({_plugin_count} providers discovered in {_provider_time:.2f}s)" +) + +# Clear screen and reprint header for clean startup view +# This pushes loading messages up (still in scroll history) but shows a clean final screen +import os as _os_module + +_os_module.system("cls" if _os_module.name == "nt" else "clear") + +# Reprint header +print("━" * 70) +print(f"Starting proxy on {args.host}:{args.port}") +print(f"Proxy API Key: {key_display}") +print(f"GitHub: https://github.com/Mirrowel/LLM-API-Key-Proxy") +print("━" * 70) +print( + f"✓ Server ready in {_elapsed:.2f}s ({_plugin_count} providers discovered in {_provider_time:.2f}s)" +) + + +# Note: Debug logging will be added after logging configuration below + +# --- Logging Configuration --- +# Import path utilities here (after loading screen) to avoid triggering heavy imports early +from rotator_library.utils.paths import get_logs_dir, get_data_file + +LOG_DIR = get_logs_dir(_root_dir) + +# Configure a console handler with color (INFO and above only, no DEBUG) +console_handler = colorlog.StreamHandler(sys.stdout) +console_handler.setLevel(logging.INFO) +formatter = colorlog.ColoredFormatter( + "%(log_color)s%(message)s", + log_colors={ + "DEBUG": "cyan", + "INFO": "green", + "WARNING": "yellow", + "ERROR": "red", + "CRITICAL": "red,bg_white", + }, +) +console_handler.setFormatter(formatter) + +# Configure a file handler for INFO-level logs and higher +info_file_handler = logging.FileHandler(LOG_DIR / "proxy.log", encoding="utf-8") +info_file_handler.setLevel(logging.INFO) +info_file_handler.setFormatter( + logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") +) + +# Configure a dedicated file handler for all DEBUG-level logs +debug_file_handler = logging.FileHandler(LOG_DIR / "proxy_debug.log", encoding="utf-8") +debug_file_handler.setLevel(logging.DEBUG) +debug_file_handler.setFormatter( + logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") +) + + +# Create a filter to ensure the debug handler ONLY gets DEBUG messages from the rotator_library +class RotatorDebugFilter(logging.Filter): + def filter(self, record): + return record.levelno == logging.DEBUG and record.name.startswith( + "rotator_library" + ) + + +debug_file_handler.addFilter(RotatorDebugFilter()) + +# Configure a console handler with color +console_handler = colorlog.StreamHandler(sys.stdout) +console_handler.setLevel(logging.INFO) +formatter = colorlog.ColoredFormatter( + "%(log_color)s%(message)s", + log_colors={ + "DEBUG": "cyan", + "INFO": "green", + "WARNING": "yellow", + "ERROR": "red", + "CRITICAL": "red,bg_white", + }, +) +console_handler.setFormatter(formatter) + + +# Add a filter to prevent any LiteLLM logs from cluttering the console +class NoLiteLLMLogFilter(logging.Filter): + def filter(self, record): + return not record.name.startswith("LiteLLM") + + +console_handler.addFilter(NoLiteLLMLogFilter()) + +# Get the root logger and set it to DEBUG to capture all messages +root_logger = logging.getLogger() +root_logger.setLevel(logging.DEBUG) + +# Add all handlers to the root logger +root_logger.addHandler(info_file_handler) +root_logger.addHandler(console_handler) +root_logger.addHandler(debug_file_handler) + +# Silence other noisy loggers by setting their level higher than root +logging.getLogger("uvicorn").setLevel(logging.WARNING) +logging.getLogger("httpx").setLevel(logging.WARNING) + +# Isolate LiteLLM's logger to prevent it from reaching the console. +# We will capture its logs via the logger_fn callback in the client instead. +litellm_logger = logging.getLogger("LiteLLM") +litellm_logger.handlers = [] +litellm_logger.propagate = False + +# Now that logging is configured, log the module load time to debug file only +logging.debug(f"Modules loaded in {_elapsed:.2f}s") + +# Load environment variables from .env file +load_dotenv(_root_dir / ".env") + +# --- Configuration --- +USE_EMBEDDING_BATCHER = False +ENABLE_REQUEST_LOGGING = args.enable_request_logging +ENABLE_RAW_LOGGING = args.enable_raw_logging +if ENABLE_REQUEST_LOGGING: + logging.info( + "Transaction logging is enabled (library-level with provider correlation)." + ) +if ENABLE_RAW_LOGGING: + logging.info("Raw I/O logging is enabled (proxy boundary, unmodified HTTP data).") +PROXY_API_KEY = os.getenv("PROXY_API_KEY") +# Note: PROXY_API_KEY validation moved to server startup to allow credential tool to run first + +# Discover API keys from environment variables +api_keys = {} +for key, value in os.environ.items(): + if "_API_KEY" in key and key != "PROXY_API_KEY": + provider = key.split("_API_KEY")[0].lower() + if provider not in api_keys: + api_keys[provider] = [] + api_keys[provider].append(value) + +# Load model ignore lists from environment variables +ignore_models = {} +for key, value in os.environ.items(): + if key.startswith("IGNORE_MODELS_"): + provider = key.replace("IGNORE_MODELS_", "").lower() + models_to_ignore = [ + model.strip() for model in value.split(",") if model.strip() + ] + ignore_models[provider] = models_to_ignore + logging.debug( + f"Loaded ignore list for provider '{provider}': {models_to_ignore}" + ) + +# Load model whitelist from environment variables +whitelist_models = {} +for key, value in os.environ.items(): + if key.startswith("WHITELIST_MODELS_"): + provider = key.replace("WHITELIST_MODELS_", "").lower() + models_to_whitelist = [ + model.strip() for model in value.split(",") if model.strip() + ] + whitelist_models[provider] = models_to_whitelist + logging.debug( + f"Loaded whitelist for provider '{provider}': {models_to_whitelist}" + ) + +# Load max concurrent requests per key from environment variables +max_concurrent_requests_per_key = {} +for key, value in os.environ.items(): + if key.startswith("MAX_CONCURRENT_REQUESTS_PER_KEY_"): + provider = key.replace("MAX_CONCURRENT_REQUESTS_PER_KEY_", "").lower() + try: + max_concurrent = int(value) + if max_concurrent < 1: + logging.warning( + f"Invalid max_concurrent value for provider '{provider}': {value}. Must be >= 1. Using default (1)." + ) + max_concurrent = 1 + max_concurrent_requests_per_key[provider] = max_concurrent + logging.debug( + f"Loaded max concurrent requests for provider '{provider}': {max_concurrent}" + ) + except ValueError: + logging.warning( + f"Invalid max_concurrent value for provider '{provider}': {value}. Using default (1)." + ) + + +# --- Lifespan Management --- +@asynccontextmanager +async def lifespan(app: FastAPI): + """Manage the RotatingClient's lifecycle with the app's lifespan.""" + # [MODIFIED] Perform skippable OAuth initialization at startup + skip_oauth_init = os.getenv("SKIP_OAUTH_INIT_CHECK", "false").lower() == "true" + + # The CredentialManager now handles all discovery, including .env overrides. + # We pass all environment variables to it for this purpose. + cred_manager = CredentialManager(os.environ) + oauth_credentials = cred_manager.discover_and_prepare() + + if not skip_oauth_init and oauth_credentials: + logging.info("Starting OAuth credential validation and deduplication...") + processed_emails = {} # email -> {provider: path} + credentials_to_initialize = {} # provider -> [paths] + final_oauth_credentials = {} + + # --- Pass 1: Pre-initialization Scan & Deduplication --- + # logging.info("Pass 1: Scanning for existing metadata to find duplicates...") + for provider, paths in oauth_credentials.items(): + if provider not in credentials_to_initialize: + credentials_to_initialize[provider] = [] + for path in paths: + # Skip env-based credentials (virtual paths) - they don't have metadata files + if path.startswith("env://"): + credentials_to_initialize[provider].append(path) + continue + + try: + with open(path, "r") as f: + data = json.load(f) + metadata = data.get("_proxy_metadata", {}) + email = metadata.get("email") + + if email: + if email not in processed_emails: + processed_emails[email] = {} + + if provider in processed_emails[email]: + original_path = processed_emails[email][provider] + logging.warning( + f"Duplicate for '{email}' on '{provider}' found in pre-scan: '{Path(path).name}'. Original: '{Path(original_path).name}'. Skipping." + ) + continue + else: + processed_emails[email][provider] = path + + credentials_to_initialize[provider].append(path) + + except (FileNotFoundError, json.JSONDecodeError) as e: + logging.warning( + f"Could not pre-read metadata from '{path}': {e}. Will process during initialization." + ) + credentials_to_initialize[provider].append(path) + + # --- Pass 2: Parallel Initialization of Filtered Credentials --- + # logging.info("Pass 2: Initializing unique credentials and performing final check...") + async def process_credential(provider: str, path: str, provider_instance): + """Process a single credential: initialize and fetch user info.""" + try: + await provider_instance.initialize_token(path) + + if not hasattr(provider_instance, "get_user_info"): + return (provider, path, None, None) + + user_info = await provider_instance.get_user_info(path) + email = user_info.get("email") + return (provider, path, email, None) + + except Exception as e: + logging.error( + f"Failed to process OAuth token for {provider} at '{path}': {e}" + ) + return (provider, path, None, e) + + # Collect all tasks for parallel execution + tasks = [] + for provider, paths in credentials_to_initialize.items(): + if not paths: + continue + + provider_plugin_class = PROVIDER_PLUGINS.get(provider) + if not provider_plugin_class: + continue + + provider_instance = provider_plugin_class() + + for path in paths: + tasks.append(process_credential(provider, path, provider_instance)) + + # Execute all credential processing tasks in parallel + results = await asyncio.gather(*tasks, return_exceptions=True) + + # --- Pass 3: Sequential Deduplication and Final Assembly --- + for result in results: + # Handle exceptions from gather + if isinstance(result, Exception): + logging.error(f"Credential processing raised exception: {result}") + continue + + provider, path, email, error = result + + # Skip if there was an error + if error: + continue + + # If provider doesn't support get_user_info, add directly + if email is None: + if provider not in final_oauth_credentials: + final_oauth_credentials[provider] = [] + final_oauth_credentials[provider].append(path) + continue + + # Handle empty email + if not email: + logging.warning( + f"Could not retrieve email for '{path}'. Treating as unique." + ) + if provider not in final_oauth_credentials: + final_oauth_credentials[provider] = [] + final_oauth_credentials[provider].append(path) + continue + + # Deduplication check + if email not in processed_emails: + processed_emails[email] = {} + + if ( + provider in processed_emails[email] + and processed_emails[email][provider] != path + ): + original_path = processed_emails[email][provider] + logging.warning( + f"Duplicate for '{email}' on '{provider}' found post-init: '{Path(path).name}'. Original: '{Path(original_path).name}'. Skipping." + ) + continue + else: + processed_emails[email][provider] = path + if provider not in final_oauth_credentials: + final_oauth_credentials[provider] = [] + final_oauth_credentials[provider].append(path) + + # Update metadata (skip for env-based credentials - they don't have files) + if not path.startswith("env://"): + try: + with open(path, "r+") as f: + data = json.load(f) + metadata = data.get("_proxy_metadata", {}) + metadata["email"] = email + metadata["last_check_timestamp"] = time.time() + data["_proxy_metadata"] = metadata + f.seek(0) + json.dump(data, f, indent=2) + f.truncate() + except Exception as e: + logging.error(f"Failed to update metadata for '{path}': {e}") + + logging.info("OAuth credential processing complete.") + oauth_credentials = final_oauth_credentials + + # [NEW] Load provider-specific params + litellm_provider_params = { + "gemini_cli": {"project_id": os.getenv("GEMINI_CLI_PROJECT_ID")} + } + + # Load global timeout from environment (default 30 seconds) + global_timeout = int(os.getenv("GLOBAL_TIMEOUT", "30")) + + # The client now uses the root logger configuration + client = RotatingClient( + api_keys=api_keys, + oauth_credentials=oauth_credentials, # Pass OAuth config + configure_logging=True, + global_timeout=global_timeout, + litellm_provider_params=litellm_provider_params, + ignore_models=ignore_models, + whitelist_models=whitelist_models, + enable_request_logging=ENABLE_REQUEST_LOGGING, + max_concurrent_requests_per_key=max_concurrent_requests_per_key, + ) + + # Log loaded credentials summary (compact, always visible for deployment verification) + # _api_summary = ', '.join([f"{p}:{len(c)}" for p, c in api_keys.items()]) if api_keys else "none" + # _oauth_summary = ', '.join([f"{p}:{len(c)}" for p, c in oauth_credentials.items()]) if oauth_credentials else "none" + # _total_summary = ', '.join([f"{p}:{len(c)}" for p, c in client.all_credentials.items()]) + # print(f"🔑 Credentials loaded: {_total_summary} (API: {_api_summary} | OAuth: {_oauth_summary})") + client.background_refresher.start() # Start the background task + app.state.rotating_client = client + + # Warn if no provider credentials are configured + if not client.all_credentials: + logging.warning("=" * 70) + logging.warning("⚠️ NO PROVIDER CREDENTIALS CONFIGURED") + logging.warning("The proxy is running but cannot serve any LLM requests.") + logging.warning( + "Launch the credential tool to add API keys or OAuth credentials." + ) + logging.warning(" • Executable: Run with --add-credential flag") + logging.warning(" • Source: python src/proxy_app/main.py --add-credential") + logging.warning("=" * 70) + + os.environ["LITELLM_LOG"] = "ERROR" + litellm.set_verbose = False + litellm.drop_params = True + if USE_EMBEDDING_BATCHER: + batcher = EmbeddingBatcher(client=client) + app.state.embedding_batcher = batcher + logging.info("RotatingClient and EmbeddingBatcher initialized.") + else: + app.state.embedding_batcher = None + logging.info("RotatingClient initialized (EmbeddingBatcher disabled).") + + # Start model info service in background (fetches pricing/capabilities data) + # This runs asynchronously and doesn't block proxy startup + model_info_service = await init_model_info_service() + app.state.model_info_service = model_info_service + logging.info("Model info service started (fetching pricing data in background).") + + yield + + await client.background_refresher.stop() # Stop the background task on shutdown + if app.state.embedding_batcher: + await app.state.embedding_batcher.stop() + await client.close() + + # Stop model info service + if hasattr(app.state, "model_info_service") and app.state.model_info_service: + await app.state.model_info_service.stop() + + if app.state.embedding_batcher: + logging.info("RotatingClient and EmbeddingBatcher closed.") + else: + logging.info("RotatingClient closed.") + + +# --- FastAPI App Setup --- +app = FastAPI(lifespan=lifespan) + +# Add CORS middleware to allow all origins, methods, and headers +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Allows all origins + allow_credentials=True, + allow_methods=["*"], # Allows all methods + allow_headers=["*"], # Allows all headers +) +api_key_header = APIKeyHeader(name="Authorization", auto_error=False) + + +def get_rotating_client(request: Request) -> RotatingClient: + """Dependency to get the rotating client instance from the app state.""" + return request.app.state.rotating_client + + +def get_embedding_batcher(request: Request) -> EmbeddingBatcher: + """Dependency to get the embedding batcher instance from the app state.""" + return request.app.state.embedding_batcher + + +async def verify_api_key(auth: str = Depends(api_key_header)): + """Dependency to verify the proxy API key.""" + # If PROXY_API_KEY is not set or empty, skip verification (open access) + if not PROXY_API_KEY: + return auth + if not auth or auth != f"Bearer {PROXY_API_KEY}": + raise HTTPException(status_code=401, detail="Invalid or missing API Key") + return auth + + +# --- Anthropic API Key Header --- +anthropic_api_key_header = APIKeyHeader(name="x-api-key", auto_error=False) + + +async def verify_anthropic_api_key( + x_api_key: str = Depends(anthropic_api_key_header), + auth: str = Depends(api_key_header), +): + """ + Dependency to verify API key for Anthropic endpoints. + Accepts either x-api-key header (Anthropic style) or Authorization Bearer (OpenAI style). + """ + # Check x-api-key first (Anthropic style) + if x_api_key and x_api_key == PROXY_API_KEY: + return x_api_key + # Fall back to Bearer token (OpenAI style) + if auth and auth == f"Bearer {PROXY_API_KEY}": + return auth + raise HTTPException(status_code=401, detail="Invalid or missing API Key") + + +async def streaming_response_wrapper( + request: Request, + request_data: dict, + response_stream: AsyncGenerator[str, None], + logger: Optional[RawIOLogger] = None, +) -> AsyncGenerator[str, None]: + """ + Wraps a streaming response to log the full response after completion + and ensures any errors during the stream are sent to the client. + """ + response_chunks = [] + full_response = {} + + try: + async for chunk_str in response_stream: + if await request.is_disconnected(): + logging.warning("Client disconnected, stopping stream.") + break + yield chunk_str + if chunk_str.strip() and chunk_str.startswith("data:"): + content = chunk_str[len("data:") :].strip() + if content != "[DONE]": + try: + chunk_data = json.loads(content) + response_chunks.append(chunk_data) + if logger: + logger.log_stream_chunk(chunk_data) + except json.JSONDecodeError: + pass + except Exception as e: + logging.error(f"An error occurred during the response stream: {e}") + # Yield a final error message to the client to ensure they are not left hanging. + error_payload = { + "error": { + "message": f"An unexpected error occurred during the stream: {str(e)}", + "type": "proxy_internal_error", + "code": 500, + } + } + yield f"data: {json.dumps(error_payload)}\n\n" + yield "data: [DONE]\n\n" + # Also log this as a failed request + if logger: + logger.log_final_response( + status_code=500, headers=None, body={"error": str(e)} + ) + return # Stop further processing + finally: + if response_chunks: + # --- Aggregation Logic --- + final_message = {"role": "assistant"} + aggregated_tool_calls = {} + usage_data = None + finish_reason = None + + for chunk in response_chunks: + if "choices" in chunk and chunk["choices"]: + choice = chunk["choices"][0] + delta = choice.get("delta", {}) + + # Dynamically aggregate all fields from the delta + for key, value in delta.items(): + if value is None: + continue + + if key == "content": + if "content" not in final_message: + final_message["content"] = "" + if value: + final_message["content"] += value + + elif key == "tool_calls": + for tc_chunk in value: + index = tc_chunk["index"] + if index not in aggregated_tool_calls: + aggregated_tool_calls[index] = { + "type": "function", + "function": {"name": "", "arguments": ""}, + } + # Ensure 'function' key exists for this index before accessing its sub-keys + if "function" not in aggregated_tool_calls[index]: + aggregated_tool_calls[index]["function"] = { + "name": "", + "arguments": "", + } + if tc_chunk.get("id"): + aggregated_tool_calls[index]["id"] = tc_chunk["id"] + if "function" in tc_chunk: + if "name" in tc_chunk["function"]: + if tc_chunk["function"]["name"] is not None: + aggregated_tool_calls[index]["function"][ + "name" + ] += tc_chunk["function"]["name"] + if "arguments" in tc_chunk["function"]: + if ( + tc_chunk["function"]["arguments"] + is not None + ): + aggregated_tool_calls[index]["function"][ + "arguments" + ] += tc_chunk["function"]["arguments"] + + elif key == "function_call": + if "function_call" not in final_message: + final_message["function_call"] = { + "name": "", + "arguments": "", + } + if "name" in value: + if value["name"] is not None: + final_message["function_call"]["name"] += value[ + "name" + ] + if "arguments" in value: + if value["arguments"] is not None: + final_message["function_call"]["arguments"] += ( + value["arguments"] + ) + + else: # Generic key handling for other data like 'reasoning' + # FIX: Role should always replace, never concatenate + if key == "role": + final_message[key] = value + elif key not in final_message: + final_message[key] = value + elif isinstance(final_message.get(key), str): + final_message[key] += value + else: + final_message[key] = value + + if "finish_reason" in choice and choice["finish_reason"]: + finish_reason = choice["finish_reason"] + + if "usage" in chunk and chunk["usage"]: + usage_data = chunk["usage"] + + # --- Final Response Construction --- + if aggregated_tool_calls: + final_message["tool_calls"] = list(aggregated_tool_calls.values()) + # CRITICAL FIX: Override finish_reason when tool_calls exist + # This ensures OpenCode and other agentic systems continue the conversation loop + finish_reason = "tool_calls" + + # Ensure standard fields are present for consistent logging + for field in ["content", "tool_calls", "function_call"]: + if field not in final_message: + final_message[field] = None + + first_chunk = response_chunks[0] + final_choice = { + "index": 0, + "message": final_message, + "finish_reason": finish_reason, + } + + full_response = { + "id": first_chunk.get("id"), + "object": "chat.completion", + "created": first_chunk.get("created"), + "model": first_chunk.get("model"), + "choices": [final_choice], + "usage": usage_data, + } + + if logger: + logger.log_final_response( + status_code=200, + headers=None, # Headers are not available at this stage + body=full_response, + ) + + +@app.post("/v1/chat/completions") +async def chat_completions( + request: Request, + client: RotatingClient = Depends(get_rotating_client), + _=Depends(verify_api_key), +): + """ + OpenAI-compatible endpoint powered by the RotatingClient. + Handles both streaming and non-streaming responses and logs them. + """ + # Raw I/O logger captures unmodified HTTP data at proxy boundary (disabled by default) + raw_logger = RawIOLogger() if ENABLE_RAW_LOGGING else None + try: + # Read and parse the request body only once at the beginning. + try: + request_data = await request.json() + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail="Invalid JSON in request body.") + + # Global temperature=0 override (controlled by .env variable, default: OFF) + # Low temperature makes models deterministic and prone to following training data + # instead of actual schemas, which can cause tool hallucination + # Modes: "remove" = delete temperature key, "set" = change to 1.0, "false" = disabled + override_temp_zero = os.getenv("OVERRIDE_TEMPERATURE_ZERO", "false").lower() + + if ( + override_temp_zero in ("remove", "set", "true", "1", "yes") + and "temperature" in request_data + and request_data["temperature"] == 0 + ): + if override_temp_zero == "remove": + # Remove temperature key entirely + del request_data["temperature"] + logging.debug( + "OVERRIDE_TEMPERATURE_ZERO=remove: Removed temperature=0 from request" + ) + else: + # Set to 1.0 (for "set", "true", "1", "yes") + request_data["temperature"] = 1.0 + logging.debug( + "OVERRIDE_TEMPERATURE_ZERO=set: Converting temperature=0 to temperature=1.0" + ) + + # If raw logging is enabled, capture the unmodified request data. + if raw_logger: + raw_logger.log_request(headers=request.headers, body=request_data) + + # Extract and log specific reasoning parameters for monitoring. + model = request_data.get("model") + generation_cfg = ( + request_data.get("generationConfig", {}) + or request_data.get("generation_config", {}) + or {} + ) + reasoning_effort = request_data.get("reasoning_effort") or generation_cfg.get( + "reasoning_effort" + ) + + logging.getLogger("rotator_library").debug( + f"Handling reasoning parameters: model={model}, reasoning_effort={reasoning_effort}" + ) + + # Log basic request info to console (this is a separate, simpler logger). + log_request_to_console( + url=str(request.url), + headers=dict(request.headers), + client_info=(request.client.host, request.client.port), + request_data=request_data, + ) + is_streaming = request_data.get("stream", False) + + if is_streaming: + response_generator = client.acompletion(request=request, **request_data) + return StreamingResponse( + streaming_response_wrapper( + request, request_data, response_generator, raw_logger + ), + media_type="text/event-stream", + ) + else: + response = await client.acompletion(request=request, **request_data) + if raw_logger: + # Assuming response has status_code and headers attributes + # This might need adjustment based on the actual response object + response_headers = ( + response.headers if hasattr(response, "headers") else None + ) + status_code = ( + response.status_code if hasattr(response, "status_code") else 200 + ) + raw_logger.log_final_response( + status_code=status_code, + headers=response_headers, + body=response.model_dump(), + ) + return response + + except ( + litellm.InvalidRequestError, + ValueError, + litellm.ContextWindowExceededError, + ) as e: + raise HTTPException(status_code=400, detail=f"Invalid Request: {str(e)}") + except litellm.AuthenticationError as e: + raise HTTPException(status_code=401, detail=f"Authentication Error: {str(e)}") + except litellm.RateLimitError as e: + raise HTTPException(status_code=429, detail=f"Rate Limit Exceeded: {str(e)}") + except (litellm.ServiceUnavailableError, litellm.APIConnectionError) as e: + raise HTTPException(status_code=503, detail=f"Service Unavailable: {str(e)}") + except litellm.Timeout as e: + raise HTTPException(status_code=504, detail=f"Gateway Timeout: {str(e)}") + except (litellm.InternalServerError, litellm.OpenAIError) as e: + raise HTTPException(status_code=502, detail=f"Bad Gateway: {str(e)}") + except Exception as e: + logging.error(f"Request failed after all retries: {e}") + # Optionally log the failed request + if ENABLE_REQUEST_LOGGING: + try: + request_data = await request.json() + except json.JSONDecodeError: + request_data = {"error": "Could not parse request body"} + if raw_logger: + raw_logger.log_final_response( + status_code=500, headers=None, body={"error": str(e)} + ) + raise HTTPException(status_code=500, detail=str(e)) + + +# --- Anthropic Messages API Endpoint --- +@app.post("/v1/messages") +async def anthropic_messages( + request: Request, + body: AnthropicMessagesRequest, + client: RotatingClient = Depends(get_rotating_client), + _=Depends(verify_anthropic_api_key), +): + """ + Anthropic-compatible Messages API endpoint. + + Accepts requests in Anthropic's format and returns responses in Anthropic's format. + Internally translates to OpenAI format for processing via LiteLLM. + + This endpoint is compatible with Claude Code and other Anthropic API clients. + """ + # Initialize raw I/O logger if enabled (for debugging proxy boundary) + logger = RawIOLogger() if ENABLE_RAW_LOGGING else None + + # Log raw Anthropic request if raw logging is enabled + if logger: + logger.log_request( + headers=dict(request.headers), + body=body.model_dump(exclude_none=True), + ) + + try: + # Log the request to console + log_request_to_console( + url=str(request.url), + headers=dict(request.headers), + client_info=( + request.client.host if request.client else "unknown", + request.client.port if request.client else 0, + ), + request_data=body.model_dump(exclude_none=True), + ) + + # Use the library method to handle the request + result = await client.anthropic_messages(body, raw_request=request) + + if body.stream: + # Streaming response + return StreamingResponse( + result, + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + else: + # Non-streaming response + if logger: + logger.log_final_response( + status_code=200, + headers=None, + body=result, + ) + return JSONResponse(content=result) + + except ( + litellm.InvalidRequestError, + ValueError, + litellm.ContextWindowExceededError, + ) as e: + error_response = { + "type": "error", + "error": {"type": "invalid_request_error", "message": str(e)}, + } + raise HTTPException(status_code=400, detail=error_response) + except litellm.AuthenticationError as e: + error_response = { + "type": "error", + "error": {"type": "authentication_error", "message": str(e)}, + } + raise HTTPException(status_code=401, detail=error_response) + except litellm.RateLimitError as e: + error_response = { + "type": "error", + "error": {"type": "rate_limit_error", "message": str(e)}, + } + raise HTTPException(status_code=429, detail=error_response) + except (litellm.ServiceUnavailableError, litellm.APIConnectionError) as e: + error_response = { + "type": "error", + "error": {"type": "api_error", "message": str(e)}, + } + raise HTTPException(status_code=503, detail=error_response) + except litellm.Timeout as e: + error_response = { + "type": "error", + "error": {"type": "api_error", "message": f"Request timed out: {str(e)}"}, + } + raise HTTPException(status_code=504, detail=error_response) + except Exception as e: + logging.error(f"Anthropic messages endpoint error: {e}") + if logger: + logger.log_final_response( + status_code=500, + headers=None, + body={"error": str(e)}, + ) + error_response = { + "type": "error", + "error": {"type": "api_error", "message": str(e)}, + } + raise HTTPException(status_code=500, detail=error_response) + + +# --- Anthropic Count Tokens Endpoint --- +@app.post("/v1/messages/count_tokens") +async def anthropic_count_tokens( + request: Request, + body: AnthropicCountTokensRequest, + client: RotatingClient = Depends(get_rotating_client), + _=Depends(verify_anthropic_api_key), +): + """ + Anthropic-compatible count_tokens endpoint. + + Counts the number of tokens that would be used by a Messages API request. + This is useful for estimating costs and managing context windows. + + Accepts requests in Anthropic's format and returns token count in Anthropic's format. + """ + try: + # Use the library method to handle the request + result = await client.anthropic_count_tokens(body) + return JSONResponse(content=result) + + except ( + litellm.InvalidRequestError, + ValueError, + litellm.ContextWindowExceededError, + ) as e: + error_response = { + "type": "error", + "error": {"type": "invalid_request_error", "message": str(e)}, + } + raise HTTPException(status_code=400, detail=error_response) + except litellm.AuthenticationError as e: + error_response = { + "type": "error", + "error": {"type": "authentication_error", "message": str(e)}, + } + raise HTTPException(status_code=401, detail=error_response) + except Exception as e: + logging.error(f"Anthropic count_tokens endpoint error: {e}") + error_response = { + "type": "error", + "error": {"type": "api_error", "message": str(e)}, + } + raise HTTPException(status_code=500, detail=error_response) + + +@app.post("/v1/embeddings") +async def embeddings( + request: Request, + body: EmbeddingRequest, + client: RotatingClient = Depends(get_rotating_client), + batcher: Optional[EmbeddingBatcher] = Depends(get_embedding_batcher), + _=Depends(verify_api_key), +): + """ + OpenAI-compatible endpoint for creating embeddings. + Supports two modes based on the USE_EMBEDDING_BATCHER flag: + - True: Uses a server-side batcher for high throughput. + - False: Passes requests directly to the provider. + """ + try: + request_data = body.model_dump(exclude_none=True) + log_request_to_console( + url=str(request.url), + headers=dict(request.headers), + client_info=(request.client.host, request.client.port), + request_data=request_data, + ) + if USE_EMBEDDING_BATCHER and batcher: + # --- Server-Side Batching Logic --- + request_data = body.model_dump(exclude_none=True) + inputs = request_data.get("input", []) + if isinstance(inputs, str): + inputs = [inputs] + + tasks = [] + for single_input in inputs: + individual_request = request_data.copy() + individual_request["input"] = single_input + tasks.append(batcher.add_request(individual_request)) + + results = await asyncio.gather(*tasks) + + all_data = [] + total_prompt_tokens = 0 + total_tokens = 0 + for i, result in enumerate(results): + result["data"][0]["index"] = i + all_data.extend(result["data"]) + total_prompt_tokens += result["usage"]["prompt_tokens"] + total_tokens += result["usage"]["total_tokens"] + + final_response_data = { + "object": "list", + "model": results[0]["model"], + "data": all_data, + "usage": { + "prompt_tokens": total_prompt_tokens, + "total_tokens": total_tokens, + }, + } + response = litellm.EmbeddingResponse(**final_response_data) + + else: + # --- Direct Pass-Through Logic --- + request_data = body.model_dump(exclude_none=True) + if isinstance(request_data.get("input"), str): + request_data["input"] = [request_data["input"]] + + response = await client.aembedding(request=request, **request_data) + + return response + + except HTTPException as e: + # Re-raise HTTPException to ensure it's not caught by the generic Exception handler + raise e + except ( + litellm.InvalidRequestError, + ValueError, + litellm.ContextWindowExceededError, + ) as e: + raise HTTPException(status_code=400, detail=f"Invalid Request: {str(e)}") + except litellm.AuthenticationError as e: + raise HTTPException(status_code=401, detail=f"Authentication Error: {str(e)}") + except litellm.RateLimitError as e: + raise HTTPException(status_code=429, detail=f"Rate Limit Exceeded: {str(e)}") + except (litellm.ServiceUnavailableError, litellm.APIConnectionError) as e: + raise HTTPException(status_code=503, detail=f"Service Unavailable: {str(e)}") + except litellm.Timeout as e: + raise HTTPException(status_code=504, detail=f"Gateway Timeout: {str(e)}") + except (litellm.InternalServerError, litellm.OpenAIError) as e: + raise HTTPException(status_code=502, detail=f"Bad Gateway: {str(e)}") + except Exception as e: + logging.error(f"Embedding request failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/") +def read_root(): + return {"Status": "API Key Proxy is running"} + + +@app.get("/v1/models") +async def list_models( + request: Request, + client: RotatingClient = Depends(get_rotating_client), + _=Depends(verify_api_key), + enriched: bool = True, +): + """ + Returns a list of available models in the OpenAI-compatible format. + + Query Parameters: + enriched: If True (default), returns detailed model info with pricing and capabilities. + If False, returns minimal OpenAI-compatible response. + """ + model_ids = await client.get_all_available_models(grouped=False) + + if enriched and hasattr(request.app.state, "model_info_service"): + model_info_service = request.app.state.model_info_service + if model_info_service.is_ready: + # Return enriched model data + enriched_data = model_info_service.enrich_model_list(model_ids) + return {"object": "list", "data": enriched_data} + + # Fallback to basic model cards + model_cards = [ + { + "id": model_id, + "object": "model", + "created": int(time.time()), + "owned_by": "Mirro-Proxy", + } + for model_id in model_ids + ] + return {"object": "list", "data": model_cards} + + +@app.get("/v1/models/{model_id:path}") +async def get_model( + model_id: str, + request: Request, + _=Depends(verify_api_key), +): + """ + Returns detailed information about a specific model. + + Path Parameters: + model_id: The model ID (e.g., "anthropic/claude-3-opus", "openrouter/openai/gpt-4") + """ + if hasattr(request.app.state, "model_info_service"): + model_info_service = request.app.state.model_info_service + if model_info_service.is_ready: + info = model_info_service.get_model_info(model_id) + if info: + return info.to_dict() + + # Return basic info if service not ready or model not found + return { + "id": model_id, + "object": "model", + "created": int(time.time()), + "owned_by": model_id.split("/")[0] if "/" in model_id else "unknown", + } + + +@app.get("/v1/model-info/stats") +async def model_info_stats( + request: Request, + _=Depends(verify_api_key), +): + """ + Returns statistics about the model info service (for monitoring/debugging). + """ + if hasattr(request.app.state, "model_info_service"): + return request.app.state.model_info_service.get_stats() + return {"error": "Model info service not initialized"} + + +@app.get("/v1/providers") +async def list_providers(_=Depends(verify_api_key)): + """ + Returns a list of all available providers. + """ + return list(PROVIDER_PLUGINS.keys()) + + +@app.get("/v1/quota-stats") +async def get_quota_stats( + request: Request, + client: RotatingClient = Depends(get_rotating_client), + _=Depends(verify_api_key), + provider: str = None, +): + """ + Returns quota and usage statistics for all credentials. + + This returns cached data from the proxy without making external API calls. + Use POST to reload from disk or force refresh from external APIs. + + Query Parameters: + provider: Optional filter to return stats for a specific provider only + + Returns: + { + "providers": { + "provider_name": { + "credential_count": int, + "active_count": int, + "on_cooldown_count": int, + "exhausted_count": int, + "total_requests": int, + "tokens": {...}, + "approx_cost": float | null, + "quota_groups": {...}, // For Antigravity + "credentials": [...] + } + }, + "summary": {...}, + "data_source": "cache", + "timestamp": float + } + """ + try: + stats = await client.get_quota_stats(provider_filter=provider) + return stats + except Exception as e: + logging.error(f"Failed to get quota stats: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/v1/quota-stats") +async def refresh_quota_stats( + request: Request, + client: RotatingClient = Depends(get_rotating_client), + _=Depends(verify_api_key), +): + """ + Refresh quota and usage statistics. + + Request body: + { + "action": "reload" | "force_refresh", + "scope": "all" | "provider" | "credential", + "provider": "antigravity", // required if scope != "all" + "credential": "antigravity_oauth_1.json" // required if scope == "credential" + } + + Actions: + - reload: Re-read data from disk (no external API calls) + - force_refresh: For Antigravity, fetch live quota from API. + For other providers, same as reload. + + Returns: + Same as GET, plus a "refresh_result" field with operation details. + """ + try: + data = await request.json() + action = data.get("action", "reload") + scope = data.get("scope", "all") + provider = data.get("provider") + credential = data.get("credential") + + # Validate parameters + if action not in ("reload", "force_refresh"): + raise HTTPException( + status_code=400, + detail="action must be 'reload' or 'force_refresh'", + ) + + if scope not in ("all", "provider", "credential"): + raise HTTPException( + status_code=400, + detail="scope must be 'all', 'provider', or 'credential'", + ) + + if scope in ("provider", "credential") and not provider: + raise HTTPException( + status_code=400, + detail="'provider' is required when scope is 'provider' or 'credential'", + ) + + if scope == "credential" and not credential: + raise HTTPException( + status_code=400, + detail="'credential' is required when scope is 'credential'", + ) + + refresh_result = { + "action": action, + "scope": scope, + "provider": provider, + "credential": credential, + } + + if action == "reload": + # Just reload from disk + start_time = time.time() + await client.reload_usage_from_disk() + refresh_result["duration_ms"] = int((time.time() - start_time) * 1000) + refresh_result["success"] = True + refresh_result["message"] = "Reloaded usage data from disk" + + elif action == "force_refresh": + # Force refresh from external API (for supported providers like Antigravity) + result = await client.force_refresh_quota( + provider=provider if scope in ("provider", "credential") else None, + credential=credential if scope == "credential" else None, + ) + refresh_result.update(result) + refresh_result["success"] = result["failed_count"] == 0 + + # Get updated stats + stats = await client.get_quota_stats(provider_filter=provider) + stats["refresh_result"] = refresh_result + stats["data_source"] = "refreshed" + + return stats + + except HTTPException: + raise + except Exception as e: + logging.error(f"Failed to refresh quota stats: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/v1/token-count") +async def token_count( + request: Request, + client: RotatingClient = Depends(get_rotating_client), + _=Depends(verify_api_key), +): + """ + Calculates the token count for a given list of messages and a model. + """ + try: + data = await request.json() + model = data.get("model") + messages = data.get("messages") + + if not model or not messages: + raise HTTPException( + status_code=400, detail="'model' and 'messages' are required." + ) + + count = client.token_count(**data) + return {"token_count": count} + + except Exception as e: + logging.error(f"Token count failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/v1/cost-estimate") +async def cost_estimate(request: Request, _=Depends(verify_api_key)): + """ + Estimates the cost for a request based on token counts and model pricing. + + Request body: + { + "model": "anthropic/claude-3-opus", + "prompt_tokens": 1000, + "completion_tokens": 500, + "cache_read_tokens": 0, # optional + "cache_creation_tokens": 0 # optional + } + + Returns: + { + "model": "anthropic/claude-3-opus", + "cost": 0.0375, + "currency": "USD", + "pricing": { + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.000075 + }, + "source": "model_info_service" # or "litellm_fallback" + } + """ + try: + data = await request.json() + model = data.get("model") + prompt_tokens = data.get("prompt_tokens", 0) + completion_tokens = data.get("completion_tokens", 0) + cache_read_tokens = data.get("cache_read_tokens", 0) + cache_creation_tokens = data.get("cache_creation_tokens", 0) + + if not model: + raise HTTPException(status_code=400, detail="'model' is required.") + + result = { + "model": model, + "cost": None, + "currency": "USD", + "pricing": {}, + "source": None, + } + + # Try model info service first + if hasattr(request.app.state, "model_info_service"): + model_info_service = request.app.state.model_info_service + if model_info_service.is_ready: + cost = model_info_service.calculate_cost( + model, + prompt_tokens, + completion_tokens, + cache_read_tokens, + cache_creation_tokens, + ) + if cost is not None: + cost_info = model_info_service.get_cost_info(model) + result["cost"] = cost + result["pricing"] = cost_info or {} + result["source"] = "model_info_service" + return result + + # Fallback to litellm + try: + import litellm + + # Create a mock response for cost calculation + model_info = litellm.get_model_info(model) + input_cost = model_info.get("input_cost_per_token", 0) + output_cost = model_info.get("output_cost_per_token", 0) + + if input_cost or output_cost: + cost = (prompt_tokens * input_cost) + (completion_tokens * output_cost) + result["cost"] = cost + result["pricing"] = { + "input_cost_per_token": input_cost, + "output_cost_per_token": output_cost, + } + result["source"] = "litellm_fallback" + return result + except Exception: + pass + + result["source"] = "unknown" + result["error"] = "Pricing data not available for this model" + return result + + except HTTPException: + raise + except Exception as e: + logging.error(f"Cost estimate failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +if __name__ == "__main__": + # Define ENV_FILE for onboarding checks using centralized path + ENV_FILE = get_data_file(".env") + + # Check if launcher TUI should be shown (no arguments provided) + if len(sys.argv) == 1: + # No arguments - show launcher TUI (lazy import) + from proxy_app.launcher_tui import run_launcher_tui + + run_launcher_tui() + # Launcher modifies sys.argv and returns, or exits if user chose Exit + # If we get here, user chose "Run Proxy" and sys.argv is modified + # Re-parse arguments with modified sys.argv + args = parser.parse_args() + + def needs_onboarding() -> bool: + """ + Check if the proxy needs onboarding (first-time setup). + Returns True if onboarding is needed, False otherwise. + """ + # Only check if .env file exists + # PROXY_API_KEY is optional (will show warning if not set) + if not ENV_FILE.is_file(): + return True + + return False + + def show_onboarding_message(): + """Display clear explanatory message for why onboarding is needed.""" + os.system( + "cls" if os.name == "nt" else "clear" + ) # Clear terminal for clean presentation + console.print( + Panel.fit( + "[bold cyan]🚀 LLM API Key Proxy - First Time Setup[/bold cyan]", + border_style="cyan", + ) + ) + console.print("[bold yellow]⚠️ Configuration Required[/bold yellow]\n") + + console.print("The proxy needs initial configuration:") + console.print(" [red]❌ No .env file found[/red]") + + console.print("\n[bold]Why this matters:[/bold]") + console.print(" • The .env file stores your credentials and settings") + console.print(" • PROXY_API_KEY protects your proxy from unauthorized access") + console.print(" • Provider API keys enable LLM access") + + console.print("\n[bold]What happens next:[/bold]") + console.print(" 1. We'll create a .env file with PROXY_API_KEY") + console.print(" 2. You can add LLM provider credentials (API keys or OAuth)") + console.print(" 3. The proxy will then start normally") + + console.print( + "\n[bold yellow]⚠️ Note:[/bold yellow] The credential tool adds PROXY_API_KEY by default." + ) + console.print(" You can remove it later if you want an unsecured proxy.\n") + + console.input( + "[bold green]Press Enter to launch the credential setup tool...[/bold green]" + ) + + # Check if user explicitly wants to add credentials + if args.add_credential: + # Import and call ensure_env_defaults to create .env and PROXY_API_KEY if needed + from rotator_library.credential_tool import ensure_env_defaults + + ensure_env_defaults() + # Reload environment variables after ensure_env_defaults creates/updates .env + load_dotenv(ENV_FILE, override=True) + run_credential_tool() + else: + # Check if onboarding is needed + if needs_onboarding(): + # Import console from rich for better messaging + from rich.console import Console + from rich.panel import Panel + + console = Console() + + # Show clear explanatory message + show_onboarding_message() + + # Launch credential tool automatically + from rotator_library.credential_tool import ensure_env_defaults + + ensure_env_defaults() + load_dotenv(ENV_FILE, override=True) + run_credential_tool() + + # After credential tool exits, reload and re-check + load_dotenv(ENV_FILE, override=True) + # Re-read PROXY_API_KEY from environment + PROXY_API_KEY = os.getenv("PROXY_API_KEY") + + # Verify onboarding is complete + if needs_onboarding(): + console.print("\n[bold red]❌ Configuration incomplete.[/bold red]") + console.print( + "The proxy still cannot start. Please ensure PROXY_API_KEY is set in .env\n" + ) + sys.exit(1) + else: + console.print("\n[bold green]✅ Configuration complete![/bold green]") + console.print("\nStarting proxy server...\n") + + import uvicorn + + uvicorn.run(app, host=args.host, port=args.port) diff --git a/src/proxy_app/model_filter_gui.py b/src/proxy_app/model_filter_gui.py new file mode 100644 index 0000000000000000000000000000000000000000..a4dbc549fbc649ffed9200bbf8bb07bfb5f9ad47 --- /dev/null +++ b/src/proxy_app/model_filter_gui.py @@ -0,0 +1,3639 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 Mirrowel + +""" +Model Filter GUI - Visual editor for model ignore/whitelist rules. + +A CustomTkinter application that provides a friendly interface for managing +which models are available per provider through ignore lists and whitelists. + +Features: +- Two synchronized model lists showing all fetched models and their filtered status +- Color-coded rules with visual association to affected models +- Real-time filtering preview as you type patterns +- Click interactions to highlight rule-model relationships +- Right-click context menus for quick actions +- Comprehensive help documentation +""" + +import customtkinter as ctk +from tkinter import Menu +import asyncio +import fnmatch +import platform +import threading +import os +import re +import traceback +from pathlib import Path +from dataclasses import dataclass, field +from typing import List, Dict, Tuple, Optional, Callable, Set +from dotenv import load_dotenv, set_key, unset_key + + +# ════════════════════════════════════════════════════════════════════════════════ +# CONSTANTS & CONFIGURATION +# ════════════════════════════════════════════════════════════════════════════════ + +# Window settings +WINDOW_TITLE = "Model Filter Configuration" +WINDOW_DEFAULT_SIZE = "1000x750" +WINDOW_MIN_WIDTH = 600 +WINDOW_MIN_HEIGHT = 400 + +# Color scheme (dark mode) +BG_PRIMARY = "#1a1a2e" # Main background +BG_SECONDARY = "#16213e" # Card/panel background +BG_TERTIARY = "#0f0f1a" # Input fields, lists +BG_HOVER = "#1f2b47" # Hover state +BORDER_COLOR = "#2a2a4a" # Subtle borders +TEXT_PRIMARY = "#e8e8e8" # Main text +TEXT_SECONDARY = "#a0a0a0" # Muted text +TEXT_MUTED = "#666680" # Very muted text +ACCENT_BLUE = "#4a9eff" # Primary accent +ACCENT_GREEN = "#2ecc71" # Success/normal +ACCENT_RED = "#e74c3c" # Danger/ignore +ACCENT_YELLOW = "#f1c40f" # Warning + +# Status colors +NORMAL_COLOR = "#2ecc71" # Green - models not affected by any rule +HIGHLIGHT_BG = "#2a3a5a" # Background for highlighted items + +# Ignore rules - warm color progression (reds/oranges) +IGNORE_COLORS = [ + "#e74c3c", # Bright red + "#c0392b", # Dark red + "#e67e22", # Orange + "#d35400", # Dark orange + "#f39c12", # Gold + "#e91e63", # Pink + "#ff5722", # Deep orange + "#f44336", # Material red + "#ff6b6b", # Coral + "#ff8a65", # Light deep orange +] + +# Whitelist rules - cool color progression (blues/teals) +WHITELIST_COLORS = [ + "#3498db", # Blue + "#2980b9", # Dark blue + "#1abc9c", # Teal + "#16a085", # Dark teal + "#9b59b6", # Purple + "#8e44ad", # Dark purple + "#00bcd4", # Cyan + "#2196f3", # Material blue + "#64b5f6", # Light blue + "#4dd0e1", # Light cyan +] + +# Font configuration +FONT_FAMILY = "Segoe UI" +FONT_SIZE_SMALL = 11 +FONT_SIZE_NORMAL = 12 +FONT_SIZE_LARGE = 14 +FONT_SIZE_TITLE = 16 +FONT_SIZE_HEADER = 20 + + +# ════════════════════════════════════════════════════════════════════════════════ +# CROSS-PLATFORM UTILITIES +# ════════════════════════════════════════════════════════════════════════════════ + + +def get_scroll_delta(event) -> int: + """ + Calculate scroll delta in a cross-platform manner. + + On Windows, event.delta is typically ±120 per notch. + On macOS, event.delta is typically ±1 per scroll event. + On Linux/X11, behavior varies but is usually similar to macOS. + + Returns a normalized scroll direction value (typically ±1). + """ + system = platform.system() + if system == "Darwin": # macOS + return -event.delta + elif system == "Linux": + # Linux with X11 typically uses ±1 like macOS + # but some configurations may use larger values + if abs(event.delta) >= 120: + return -1 * (event.delta // 120) + return -event.delta + else: # Windows + return -1 * (event.delta // 120) + + +# ════════════════════════════════════════════════════════════════════════════════ +# DATA CLASSES +# ════════════════════════════════════════════════════════════════════════════════ + + +@dataclass +class FilterRule: + """Represents a single filter rule (ignore or whitelist pattern).""" + + pattern: str + color: str + rule_type: str # 'ignore' or 'whitelist' + affected_count: int = 0 + affected_models: List[str] = field(default_factory=list) + + def __hash__(self): + return hash((self.pattern, self.rule_type)) + + def __eq__(self, other): + if not isinstance(other, FilterRule): + return False + return self.pattern == other.pattern and self.rule_type == other.rule_type + + +@dataclass +class ModelStatus: + """Status information for a single model.""" + + model_id: str + status: str # 'normal', 'ignored', 'whitelisted' + color: str + affecting_rule: Optional[FilterRule] = None + + @property + def display_name(self) -> str: + """Get the model name without provider prefix for display.""" + if "/" in self.model_id: + return self.model_id.split("/", 1)[1] + return self.model_id + + @property + def provider(self) -> str: + """Extract provider from model ID.""" + if "/" in self.model_id: + return self.model_id.split("/")[0] + return "" + + +# ════════════════════════════════════════════════════════════════════════════════ +# FILTER ENGINE +# ════════════════════════════════════════════════════════════════════════════════ + + +class FilterEngine: + """ + Core filtering logic with rule management. + + Handles pattern matching, rule storage, and status calculation. + Tracks changes for save/discard functionality. + Uses caching for performance with large model lists. + """ + + def __init__(self): + self.ignore_rules: List[FilterRule] = [] + self.whitelist_rules: List[FilterRule] = [] + self._ignore_color_index = 0 + self._whitelist_color_index = 0 + self._original_ignore_patterns: Set[str] = set() + self._original_whitelist_patterns: Set[str] = set() + self._current_provider: Optional[str] = None + + # Caching for performance + self._status_cache: Dict[str, ModelStatus] = {} + self._available_count_cache: Optional[Tuple[int, int]] = None + self._cache_valid: bool = False + + def _invalidate_cache(self): + """Mark cache as stale (call when rules change).""" + self._status_cache.clear() + self._available_count_cache = None + self._cache_valid = False + + def reset(self): + """Clear all rules and reset state.""" + self.ignore_rules.clear() + self.whitelist_rules.clear() + self._ignore_color_index = 0 + self._whitelist_color_index = 0 + self._original_ignore_patterns.clear() + self._original_whitelist_patterns.clear() + self._invalidate_cache() + + def _get_next_ignore_color(self) -> str: + """Get next color for ignore rules (cycles through palette).""" + color = IGNORE_COLORS[self._ignore_color_index % len(IGNORE_COLORS)] + self._ignore_color_index += 1 + return color + + def _get_next_whitelist_color(self) -> str: + """Get next color for whitelist rules (cycles through palette).""" + color = WHITELIST_COLORS[self._whitelist_color_index % len(WHITELIST_COLORS)] + self._whitelist_color_index += 1 + return color + + def add_ignore_rule(self, pattern: str) -> Optional[FilterRule]: + """Add a new ignore rule. Returns the rule if added, None if duplicate.""" + pattern = pattern.strip() + if not pattern: + return None + + # Check for duplicates + for rule in self.ignore_rules: + if rule.pattern == pattern: + return None + + rule = FilterRule( + pattern=pattern, color=self._get_next_ignore_color(), rule_type="ignore" + ) + self.ignore_rules.append(rule) + self._invalidate_cache() + return rule + + def add_whitelist_rule(self, pattern: str) -> Optional[FilterRule]: + """Add a new whitelist rule. Returns the rule if added, None if duplicate.""" + pattern = pattern.strip() + if not pattern: + return None + + # Check for duplicates + for rule in self.whitelist_rules: + if rule.pattern == pattern: + return None + + rule = FilterRule( + pattern=pattern, + color=self._get_next_whitelist_color(), + rule_type="whitelist", + ) + self.whitelist_rules.append(rule) + self._invalidate_cache() + return rule + + def remove_ignore_rule(self, pattern: str) -> bool: + """Remove an ignore rule by pattern. Returns True if removed.""" + for i, rule in enumerate(self.ignore_rules): + if rule.pattern == pattern: + self.ignore_rules.pop(i) + self._invalidate_cache() + return True + return False + + def remove_whitelist_rule(self, pattern: str) -> bool: + """Remove a whitelist rule by pattern. Returns True if removed.""" + for i, rule in enumerate(self.whitelist_rules): + if rule.pattern == pattern: + self.whitelist_rules.pop(i) + self._invalidate_cache() + return True + return False + + def _pattern_matches(self, model_id: str, pattern: str) -> bool: + """ + Check if a pattern matches a model ID. + + Supports full glob/fnmatch syntax: + - Exact match: "gpt-4" matches only "gpt-4" + - Prefix wildcard: "gpt-4*" matches "gpt-4", "gpt-4-turbo", etc. + - Suffix wildcard: "*-preview" matches "gpt-4-preview", "o1-preview", etc. + - Contains wildcard: "*-preview*" matches anything containing "-preview" + - Match all: "*" matches everything + - Single char wildcard: "gpt-?" matches "gpt-4", "gpt-5", etc. + - Character sets: "gpt-[45]*" matches "gpt-4*", "gpt-5*" + """ + # Extract model name without provider prefix + if "/" in model_id: + provider_model_name = model_id.split("/", 1)[1] + else: + provider_model_name = model_id + + # Use fnmatch for full glob pattern support + # Match against both the provider model name and the full model ID + return fnmatch.fnmatch(provider_model_name, pattern) or fnmatch.fnmatch( + model_id, pattern + ) + + def pattern_is_covered_by(self, new_pattern: str, existing_pattern: str) -> bool: + """ + Check if new_pattern is already covered by existing_pattern. + + A pattern A is covered by pattern B if every model that would match A + would also match B. + + Examples: + - "gpt-4" is covered by "gpt-4*" (prefix covers exact) + - "gpt-4-turbo" is covered by "gpt-4*" (prefix covers longer) + - "gpt-4*" is covered by "gpt-*" (broader prefix covers narrower) + - Anything is covered by "*" (match-all covers everything) + - "gpt-4" is covered by "gpt-4" (exact duplicate) + """ + # Exact duplicate + if new_pattern == existing_pattern: + return True + + # Existing is wildcard-all - covers everything + if existing_pattern == "*": + return True + + # If existing is a prefix wildcard + if existing_pattern.endswith("*"): + existing_prefix = existing_pattern[:-1] + + # New is exact match - check if it starts with existing prefix + if not new_pattern.endswith("*"): + return new_pattern.startswith(existing_prefix) + + # New is also a prefix wildcard - check if new prefix starts with existing + new_prefix = new_pattern[:-1] + return new_prefix.startswith(existing_prefix) + + # Existing is exact match - only covers exact duplicate (already handled) + return False + + def is_pattern_covered(self, new_pattern: str, rule_type: str) -> bool: + """ + Check if a new pattern is already covered by any existing rule of the same type. + """ + rules = self.ignore_rules if rule_type == "ignore" else self.whitelist_rules + for rule in rules: + if self.pattern_is_covered_by(new_pattern, rule.pattern): + return True + return False + + def get_covered_patterns(self, new_pattern: str, rule_type: str) -> List[str]: + """ + Get list of existing patterns that would be covered (made redundant) + by adding new_pattern. + + Used for smart merge: when adding a broader pattern, remove the + narrower patterns it covers. + """ + rules = self.ignore_rules if rule_type == "ignore" else self.whitelist_rules + covered = [] + for rule in rules: + if self.pattern_is_covered_by(rule.pattern, new_pattern): + # The existing rule would be covered by the new pattern + covered.append(rule.pattern) + return covered + + def _compute_status(self, model_id: str) -> ModelStatus: + """ + Compute the status of a model based on current rules (no caching). + + Priority: Whitelist > Ignore > Normal + """ + # Check whitelist first (takes priority) + for rule in self.whitelist_rules: + if self._pattern_matches(model_id, rule.pattern): + return ModelStatus( + model_id=model_id, + status="whitelisted", + color=rule.color, + affecting_rule=rule, + ) + + # Then check ignore + for rule in self.ignore_rules: + if self._pattern_matches(model_id, rule.pattern): + return ModelStatus( + model_id=model_id, + status="ignored", + color=rule.color, + affecting_rule=rule, + ) + + # Default: normal + return ModelStatus( + model_id=model_id, status="normal", color=NORMAL_COLOR, affecting_rule=None + ) + + def get_model_status(self, model_id: str) -> ModelStatus: + """Get status for a model (uses cache if available).""" + if model_id in self._status_cache: + return self._status_cache[model_id] + return self._compute_status(model_id) + + def _rebuild_cache(self, models: List[str]): + """Rebuild the entire status cache in one efficient pass.""" + self._status_cache.clear() + + # Reset rule counts + for rule in self.ignore_rules + self.whitelist_rules: + rule.affected_count = 0 + rule.affected_models = [] + + available = 0 + for model_id in models: + status = self._compute_status(model_id) + self._status_cache[model_id] = status + + if status.affecting_rule: + status.affecting_rule.affected_count += 1 + status.affecting_rule.affected_models.append(model_id) + + if status.status != "ignored": + available += 1 + + self._available_count_cache = (available, len(models)) + self._cache_valid = True + + def get_all_statuses(self, models: List[str]) -> List[ModelStatus]: + """Get status for all models (rebuilds cache if invalid).""" + if not self._cache_valid: + self._rebuild_cache(models) + return [self._status_cache.get(m, self._compute_status(m)) for m in models] + + def update_affected_counts(self, models: List[str]): + """Update the affected_count and affected_models for all rules.""" + # This now just ensures cache is valid - counts are updated in _rebuild_cache + if not self._cache_valid: + self._rebuild_cache(models) + + def get_available_count(self, models: List[str]) -> Tuple[int, int]: + """Returns (available_count, total_count) from cache.""" + if not self._cache_valid: + self._rebuild_cache(models) + return self._available_count_cache or (0, 0) + + def preview_pattern( + self, pattern: str, rule_type: str, models: List[str] + ) -> List[str]: + """ + Preview which models would be affected by a pattern without adding it. + Returns list of affected model IDs. + """ + affected = [] + pattern = pattern.strip() + if not pattern: + return affected + + for model_id in models: + if self._pattern_matches(model_id, pattern): + affected.append(model_id) + + return affected + + def load_from_env(self, provider: str): + """Load ignore/whitelist rules for a provider from environment.""" + self.reset() + self._current_provider = provider + load_dotenv(override=True) + + # Load ignore list + ignore_key = f"IGNORE_MODELS_{provider.upper()}" + ignore_value = os.getenv(ignore_key, "") + if ignore_value: + patterns = [p.strip() for p in ignore_value.split(",") if p.strip()] + for pattern in patterns: + self.add_ignore_rule(pattern) + self._original_ignore_patterns = set(patterns) + + # Load whitelist + whitelist_key = f"WHITELIST_MODELS_{provider.upper()}" + whitelist_value = os.getenv(whitelist_key, "") + if whitelist_value: + patterns = [p.strip() for p in whitelist_value.split(",") if p.strip()] + for pattern in patterns: + self.add_whitelist_rule(pattern) + self._original_whitelist_patterns = set(patterns) + + def save_to_env(self, provider: str) -> bool: + """ + Save current rules to .env file. + Returns True if successful. + """ + env_path = Path.cwd() / ".env" + + try: + ignore_key = f"IGNORE_MODELS_{provider.upper()}" + whitelist_key = f"WHITELIST_MODELS_{provider.upper()}" + + # Save ignore patterns + ignore_patterns = [rule.pattern for rule in self.ignore_rules] + if ignore_patterns: + set_key(str(env_path), ignore_key, ",".join(ignore_patterns)) + else: + # Remove the key if no patterns + unset_key(str(env_path), ignore_key) + + # Save whitelist patterns + whitelist_patterns = [rule.pattern for rule in self.whitelist_rules] + if whitelist_patterns: + set_key(str(env_path), whitelist_key, ",".join(whitelist_patterns)) + else: + unset_key(str(env_path), whitelist_key) + + # Update original state + self._original_ignore_patterns = set(ignore_patterns) + self._original_whitelist_patterns = set(whitelist_patterns) + + return True + except Exception as e: + print(f"Error saving to .env: {e}") + traceback.print_exc() + return False + + def has_unsaved_changes(self) -> bool: + """Check if current rules differ from saved state.""" + current_ignore = set(rule.pattern for rule in self.ignore_rules) + current_whitelist = set(rule.pattern for rule in self.whitelist_rules) + + return ( + current_ignore != self._original_ignore_patterns + or current_whitelist != self._original_whitelist_patterns + ) + + def discard_changes(self): + """Reload rules from environment, discarding unsaved changes.""" + if self._current_provider: + self.load_from_env(self._current_provider) + + +# ════════════════════════════════════════════════════════════════════════════════ +# MODEL FETCHER +# ════════════════════════════════════════════════════════════════════════════════ + +# Global cache for fetched models (persists across provider switches) +_model_cache: Dict[str, List[str]] = {} + + +class ModelFetcher: + """ + Handles async model fetching from providers. + + Runs fetching in a background thread to avoid blocking the GUI. + Includes caching to avoid refetching on every provider switch. + """ + + @staticmethod + def get_cached_models(provider: str) -> Optional[List[str]]: + """Get cached models for a provider, if available.""" + return _model_cache.get(provider) + + @staticmethod + def clear_cache(provider: Optional[str] = None): + """Clear model cache. If provider specified, only clear that provider.""" + if provider: + _model_cache.pop(provider, None) + else: + _model_cache.clear() + + @staticmethod + def get_available_providers() -> List[str]: + """Get list of providers that have credentials configured.""" + providers = set() + load_dotenv(override=True) + + # Scan environment for API keys (handles numbered keys like GEMINI_API_KEY_1) + for key in os.environ: + if "_API_KEY" in key and "PROXY_API_KEY" not in key: + # Extract provider: NVIDIA_NIM_API_KEY_1 -> nvidia_nim + provider = key.split("_API_KEY")[0].lower() + providers.add(provider) + + # Check for OAuth providers + oauth_dir = Path("oauth_creds") + if oauth_dir.exists(): + for file in oauth_dir.glob("*_oauth_*.json"): + provider = file.name.split("_oauth_")[0] + providers.add(provider) + + return sorted(list(providers)) + + @staticmethod + def _find_credential(provider: str) -> Optional[str]: + """Find a credential for a provider (handles numbered keys).""" + load_dotenv(override=True) + provider_upper = provider.upper() + + # Try exact match first (e.g., GEMINI_API_KEY) + exact_key = f"{provider_upper}_API_KEY" + if os.getenv(exact_key): + return os.getenv(exact_key) + + # Look for numbered keys (e.g., GEMINI_API_KEY_1, NVIDIA_NIM_API_KEY_1) + for key, value in os.environ.items(): + if key.startswith(f"{provider_upper}_API_KEY") and value: + return value + + # Check for OAuth credentials + oauth_dir = Path("oauth_creds") + if oauth_dir.exists(): + oauth_files = list(oauth_dir.glob(f"{provider}_oauth_*.json")) + if oauth_files: + return str(oauth_files[0]) + + return None + + @staticmethod + async def _fetch_models_async(provider: str) -> Tuple[List[str], Optional[str]]: + """ + Async implementation of model fetching. + Returns: (models_list, error_message_or_none) + """ + try: + import httpx + from rotator_library.providers import PROVIDER_PLUGINS + + # Get credential + credential = ModelFetcher._find_credential(provider) + if not credential: + return [], f"No credentials found for '{provider}'" + + # Get provider class + provider_class = PROVIDER_PLUGINS.get(provider.lower()) + if not provider_class: + return [], f"Unknown provider: '{provider}'" + + # Fetch models + async with httpx.AsyncClient(timeout=30.0) as client: + instance = provider_class() + models = await instance.get_models(credential, client) + return models, None + + except ImportError as e: + return [], f"Import error: {e}" + except Exception as e: + return [], f"Failed to fetch: {str(e)}" + + @staticmethod + def fetch_models( + provider: str, + on_success: Callable[[List[str]], None], + on_error: Callable[[str], None], + on_start: Optional[Callable[[], None]] = None, + force_refresh: bool = False, + ): + """ + Fetch models in a background thread. + + Args: + provider: Provider name (e.g., 'openai', 'gemini') + on_success: Callback with list of model IDs + on_error: Callback with error message + on_start: Optional callback when fetching starts + force_refresh: If True, bypass cache and fetch fresh + """ + # Check cache first (unless force refresh) + if not force_refresh: + cached = ModelFetcher.get_cached_models(provider) + if cached is not None: + on_success(cached) + return + + def run_fetch(): + if on_start: + on_start() + + try: + # Run async fetch in new event loop + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + models, error = loop.run_until_complete( + ModelFetcher._fetch_models_async(provider) + ) + # Clean up any pending tasks to avoid warnings + pending = asyncio.all_tasks(loop) + for task in pending: + task.cancel() + if pending: + loop.run_until_complete( + asyncio.gather(*pending, return_exceptions=True) + ) + finally: + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.close() + + if error: + on_error(error) + else: + # Cache the results + _model_cache[provider] = models + on_success(models) + + except Exception as e: + on_error(str(e)) + + thread = threading.Thread(target=run_fetch, daemon=True) + thread.start() + + +# ════════════════════════════════════════════════════════════════════════════════ +# HELP WINDOW +# ════════════════════════════════════════════════════════════════════════════════ + + +class HelpWindow(ctk.CTkToplevel): + """ + Modal help popup with comprehensive filtering documentation. + Uses CTkTextbox for proper scrolling with dark theme styling. + """ + + def __init__(self, parent): + super().__init__(parent) + + self.title("Help - Model Filtering") + self.geometry("700x600") + self.minsize(600, 500) + + # Make modal + self.transient(parent) + self.grab_set() + + # Configure appearance + self.configure(fg_color=BG_PRIMARY) + + # Build content + self._create_content() + + # Center on parent + self.update_idletasks() + x = parent.winfo_x() + (parent.winfo_width() - self.winfo_width()) // 2 + y = parent.winfo_y() + (parent.winfo_height() - self.winfo_height()) // 2 + self.geometry(f"+{x}+{y}") + + # Focus + self.focus_force() + + # Bind escape to close + self.bind("", lambda e: self.destroy()) + + def _create_content(self): + """Build the help content using CTkTextbox for proper scrolling.""" + # Main container + main_frame = ctk.CTkFrame(self, fg_color="transparent") + main_frame.pack(fill="both", expand=True, padx=20, pady=(20, 10)) + + # Use CTkTextbox - CustomTkinter's styled text widget with built-in scrolling + self.text_box = ctk.CTkTextbox( + main_frame, + font=(FONT_FAMILY, FONT_SIZE_NORMAL), + fg_color=BG_SECONDARY, + text_color=TEXT_SECONDARY, + corner_radius=8, + wrap="word", + activate_scrollbars=True, + ) + self.text_box.pack(fill="both", expand=True) + + # Configure text tags for formatting + # Access the underlying tk.Text widget for tag configuration + text_widget = self.text_box._textbox + + text_widget.tag_configure( + "title", + font=(FONT_FAMILY, FONT_SIZE_HEADER, "bold"), + foreground=TEXT_PRIMARY, + spacing1=5, + spacing3=15, + ) + text_widget.tag_configure( + "section_title", + font=(FONT_FAMILY, FONT_SIZE_LARGE, "bold"), + foreground=ACCENT_BLUE, + spacing1=20, + spacing3=8, + ) + text_widget.tag_configure( + "separator", + font=(FONT_FAMILY, 6), + foreground=BORDER_COLOR, + spacing3=5, + ) + text_widget.tag_configure( + "content", + font=(FONT_FAMILY, FONT_SIZE_NORMAL), + foreground=TEXT_SECONDARY, + spacing1=2, + spacing3=5, + lmargin1=5, + lmargin2=5, + ) + + # Insert content + self._insert_help_content() + + # Make read-only by disabling + self.text_box.configure(state="disabled") + + # Bind mouse wheel for faster scrolling on the internal canvas + self.text_box.bind("", self._on_mousewheel) + # Also bind on the textbox's internal widget + self.text_box._textbox.bind("", self._on_mousewheel) + + # Close button at bottom + btn_frame = ctk.CTkFrame(self, fg_color="transparent") + btn_frame.pack(fill="x", padx=20, pady=(10, 15)) + + close_btn = ctk.CTkButton( + btn_frame, + text="Got it!", + font=(FONT_FAMILY, FONT_SIZE_NORMAL, "bold"), + fg_color=ACCENT_BLUE, + hover_color="#3a8aee", + height=40, + width=120, + command=self.destroy, + ) + close_btn.pack() + + def _on_mousewheel(self, event): + """Handle mouse wheel with faster scrolling.""" + # CTkTextbox uses _textbox internally + # Use larger scroll amount (3 units) for faster scrolling in help window + delta = get_scroll_delta(event) * 3 + self.text_box._textbox.yview_scroll(delta, "units") + return "break" + + def _insert_help_content(self): + """Insert all help text with formatting.""" + # Access internal text widget for inserting with tags + text_widget = self.text_box._textbox + + # Title + text_widget.insert("end", "📖 Model Filtering Guide\n", "title") + + # Sections with emojis + sections = [ + ( + "🎯 Overview", + """Model filtering allows you to control which models are available through your proxy for each provider. + +• Use the IGNORE list to block specific models +• Use the WHITELIST to ensure specific models are always available +• Whitelist ALWAYS takes priority over Ignore""", + ), + ( + "⚖️ Filtering Priority", + """When a model is checked, the following order is used: + +1. WHITELIST CHECK + If the model matches any whitelist pattern → AVAILABLE + (Whitelist overrides everything else) + +2. IGNORE CHECK + If the model matches any ignore pattern → BLOCKED + +3. DEFAULT + If no patterns match → AVAILABLE""", + ), + ( + "✏️ Pattern Syntax", + """Full glob/wildcard patterns are supported: + +EXACT MATCH + Pattern: gpt-4 + Matches: only "gpt-4", nothing else + +PREFIX WILDCARD + Pattern: gpt-4* + Matches: "gpt-4", "gpt-4-turbo", "gpt-4-preview", etc. + +SUFFIX WILDCARD + Pattern: *-preview + Matches: "gpt-4-preview", "o1-preview", etc. + +CONTAINS WILDCARD + Pattern: *-preview* + Matches: anything containing "-preview" + +MATCH ALL + Pattern: * + Matches: every model for this provider + +SINGLE CHARACTER + Pattern: gpt-? + Matches: "gpt-4", "gpt-5", etc. (any single char) + +CHARACTER SET + Pattern: gpt-[45]* + Matches: "gpt-4", "gpt-4-turbo", "gpt-5", etc.""", + ), + ( + "💡 Common Patterns", + """BLOCK ALL, ALLOW SPECIFIC: + Ignore: * + Whitelist: gpt-4o, gpt-4o-mini + Result: Only gpt-4o and gpt-4o-mini available + +BLOCK PREVIEW MODELS: + Ignore: *-preview, *-preview* + Result: All preview variants blocked + +BLOCK SPECIFIC SERIES: + Ignore: o1*, dall-e* + Result: All o1 and DALL-E models blocked + +ALLOW ONLY LATEST: + Ignore: * + Whitelist: *-latest + Result: Only models ending in "-latest" available""", + ), + ( + "🖱️ Interface Guide", + """PROVIDER DROPDOWN + Select which provider to configure + +MODEL LISTS + • Left list: All fetched models (unfiltered) + • Right list: Same models with colored status + • Green = Available (normal) + • Red/Orange tones = Blocked (ignored) + • Blue/Teal tones = Whitelisted + +SEARCH BOX + Filter both lists to find specific models quickly + +CLICKING MODELS + • Left-click: Highlight the rule affecting this model + • Right-click: Context menu with quick actions + +CLICKING RULES + • Highlights all models affected by that rule + • Shows which models will be blocked/allowed + +RULE INPUT (Merge Mode) + • Enter patterns separated by commas + • Only adds patterns not covered by existing rules + • Press Add or Enter to create rules + +IMPORT BUTTON (Replace Mode) + • Replaces ALL existing rules with imported ones + • Paste comma-separated patterns + +DELETE RULES + • Click the × button on any rule to remove it""", + ), + ( + "⌨️ Keyboard Shortcuts", + """Ctrl+S Save changes +Ctrl+R Refresh models from provider +Ctrl+F Focus search box +F1 Open this help window +Escape Clear search / Close dialogs""", + ), + ( + "💾 Saving Changes", + """Changes are saved to your .env file in this format: + + IGNORE_MODELS_OPENAI=pattern1,pattern2* + WHITELIST_MODELS_OPENAI=specific-model + +Click "Save" to persist changes, or "Discard" to revert. +Closing the window with unsaved changes will prompt you.""", + ), + ] + + for section_title, content in sections: + text_widget.insert("end", f"\n{section_title}\n", "section_title") + text_widget.insert("end", "─" * 50 + "\n", "separator") + text_widget.insert("end", content.strip() + "\n", "content") + + +# ════════════════════════════════════════════════════════════════════════════════ +# CUSTOM DIALOG +# ════════════════════════════════════════════════════════════════════════════════ + + +class UnsavedChangesDialog(ctk.CTkToplevel): + """Modal dialog for unsaved changes confirmation.""" + + def __init__(self, parent): + super().__init__(parent) + + self.result: Optional[str] = None # 'save', 'discard', 'cancel' + + self.title("Unsaved Changes") + self.geometry("400x180") + self.resizable(False, False) + + # Make modal + self.transient(parent) + self.grab_set() + + # Configure appearance + self.configure(fg_color=BG_PRIMARY) + + # Build content + self._create_content() + + # Center on parent + self.update_idletasks() + x = parent.winfo_x() + (parent.winfo_width() - self.winfo_width()) // 2 + y = parent.winfo_y() + (parent.winfo_height() - self.winfo_height()) // 2 + self.geometry(f"+{x}+{y}") + + # Focus + self.focus_force() + + # Bind escape to cancel + self.bind("", lambda e: self._on_cancel()) + + # Handle window close + self.protocol("WM_DELETE_WINDOW", self._on_cancel) + + def _create_content(self): + """Build dialog content.""" + # Icon and message + msg_frame = ctk.CTkFrame(self, fg_color="transparent") + msg_frame.pack(fill="x", padx=30, pady=(25, 15)) + + icon = ctk.CTkLabel( + msg_frame, text="⚠️", font=(FONT_FAMILY, 32), text_color=ACCENT_YELLOW + ) + icon.pack(side="left", padx=(0, 15)) + + text_frame = ctk.CTkFrame(msg_frame, fg_color="transparent") + text_frame.pack(side="left", fill="x", expand=True) + + title = ctk.CTkLabel( + text_frame, + text="Unsaved Changes", + font=(FONT_FAMILY, FONT_SIZE_LARGE, "bold"), + text_color=TEXT_PRIMARY, + anchor="w", + ) + title.pack(anchor="w") + + subtitle = ctk.CTkLabel( + text_frame, + text="You have unsaved filter changes.\nWhat would you like to do?", + font=(FONT_FAMILY, FONT_SIZE_NORMAL), + text_color=TEXT_SECONDARY, + anchor="w", + justify="left", + ) + subtitle.pack(anchor="w") + + # Buttons + btn_frame = ctk.CTkFrame(self, fg_color="transparent") + btn_frame.pack(fill="x", padx=30, pady=(10, 25)) + + cancel_btn = ctk.CTkButton( + btn_frame, + text="Cancel", + font=(FONT_FAMILY, FONT_SIZE_NORMAL), + fg_color=BG_SECONDARY, + hover_color=BG_HOVER, + border_width=1, + border_color=BORDER_COLOR, + width=100, + command=self._on_cancel, + ) + cancel_btn.pack(side="right", padx=(10, 0)) + + discard_btn = ctk.CTkButton( + btn_frame, + text="Discard", + font=(FONT_FAMILY, FONT_SIZE_NORMAL), + fg_color=ACCENT_RED, + hover_color="#c0392b", + width=100, + command=self._on_discard, + ) + discard_btn.pack(side="right", padx=(10, 0)) + + save_btn = ctk.CTkButton( + btn_frame, + text="Save", + font=(FONT_FAMILY, FONT_SIZE_NORMAL), + fg_color=ACCENT_GREEN, + hover_color="#27ae60", + width=100, + command=self._on_save, + ) + save_btn.pack(side="right") + + def _on_save(self): + self.result = "save" + self.destroy() + + def _on_discard(self): + self.result = "discard" + self.destroy() + + def _on_cancel(self): + self.result = "cancel" + self.destroy() + + def show(self) -> Optional[str]: + """Show dialog and return result.""" + self.wait_window() + return self.result + + +class ImportRulesDialog(ctk.CTkToplevel): + """Modal dialog for importing rules from comma-separated text.""" + + def __init__(self, parent, rule_type: str): + super().__init__(parent) + + self.result: Optional[List[str]] = None + self.rule_type = rule_type + + title_text = ( + "Import Ignore Rules" if rule_type == "ignore" else "Import Whitelist Rules" + ) + self.title(title_text) + self.geometry("500x300") + self.minsize(400, 250) + + # Make modal + self.transient(parent) + self.grab_set() + + # Configure appearance + self.configure(fg_color=BG_PRIMARY) + + # Build content + self._create_content() + + # Center on parent + self.update_idletasks() + x = parent.winfo_x() + (parent.winfo_width() - self.winfo_width()) // 2 + y = parent.winfo_y() + (parent.winfo_height() - self.winfo_height()) // 2 + self.geometry(f"+{x}+{y}") + + # Focus + self.focus_force() + self.text_box.focus_set() + + # Bind escape to cancel + self.bind("", lambda e: self._on_cancel()) + + # Handle window close + self.protocol("WM_DELETE_WINDOW", self._on_cancel) + + def _create_content(self): + """Build dialog content.""" + # Instructions at TOP + instruction_frame = ctk.CTkFrame(self, fg_color="transparent") + instruction_frame.pack(fill="x", padx=20, pady=(15, 10)) + + instruction = ctk.CTkLabel( + instruction_frame, + text="Paste comma-separated patterns below (will REPLACE all existing rules):", + font=(FONT_FAMILY, FONT_SIZE_NORMAL), + text_color=TEXT_PRIMARY, + anchor="w", + ) + instruction.pack(anchor="w") + + example = ctk.CTkLabel( + instruction_frame, + text="Example: gpt-4*, claude-3*, model-name", + font=(FONT_FAMILY, FONT_SIZE_SMALL), + text_color=TEXT_MUTED, + anchor="w", + ) + example.pack(anchor="w") + + # Buttons at BOTTOM - pack BEFORE textbox to reserve space + btn_frame = ctk.CTkFrame(self, fg_color="transparent", height=50) + btn_frame.pack(side="bottom", fill="x", padx=20, pady=(10, 15)) + btn_frame.pack_propagate(False) + + cancel_btn = ctk.CTkButton( + btn_frame, + text="Cancel", + font=(FONT_FAMILY, FONT_SIZE_NORMAL), + fg_color=BG_SECONDARY, + hover_color=BG_HOVER, + border_width=1, + border_color=BORDER_COLOR, + width=100, + height=32, + command=self._on_cancel, + ) + cancel_btn.pack(side="right", padx=(10, 0)) + + import_btn = ctk.CTkButton( + btn_frame, + text="Replace All", + font=(FONT_FAMILY, FONT_SIZE_NORMAL, "bold"), + fg_color=ACCENT_BLUE, + hover_color="#3a8aee", + width=110, + height=32, + command=self._on_import, + ) + import_btn.pack(side="right") + + # Text box fills MIDDLE space - pack LAST + self.text_box = ctk.CTkTextbox( + self, + font=(FONT_FAMILY, FONT_SIZE_NORMAL), + fg_color=BG_TERTIARY, + border_color=BORDER_COLOR, + border_width=1, + text_color=TEXT_PRIMARY, + wrap="word", + ) + self.text_box.pack(fill="both", expand=True, padx=20, pady=(0, 0)) + + # Bind Ctrl+Enter to import + self.text_box.bind("", lambda e: self._on_import()) + + def _on_import(self): + """Parse and return the patterns.""" + text = self.text_box.get("1.0", "end").strip() + if text: + # Parse comma-separated patterns + patterns = [p.strip() for p in text.split(",") if p.strip()] + self.result = patterns + else: + self.result = [] + self.destroy() + + def _on_cancel(self): + self.result = None + self.destroy() + + def show(self) -> Optional[List[str]]: + """Show dialog and return list of patterns, or None if cancelled.""" + self.wait_window() + return self.result + + +class ImportResultDialog(ctk.CTkToplevel): + """Simple dialog showing import results.""" + + def __init__(self, parent, added: int, skipped: int, is_replace: bool = False): + super().__init__(parent) + + self.title("Import Complete") + self.geometry("380x160") + self.resizable(False, False) + + # Make modal + self.transient(parent) + self.grab_set() + + # Configure appearance + self.configure(fg_color=BG_PRIMARY) + + # Build content + self._create_content(added, skipped, is_replace) + + # Center on parent + self.update_idletasks() + x = parent.winfo_x() + (parent.winfo_width() - self.winfo_width()) // 2 + y = parent.winfo_y() + (parent.winfo_height() - self.winfo_height()) // 2 + self.geometry(f"+{x}+{y}") + + # Focus + self.focus_force() + + # Bind escape and enter to close + self.bind("", lambda e: self.destroy()) + self.bind("", lambda e: self.destroy()) + + def _create_content(self, added: int, skipped: int, is_replace: bool): + """Build dialog content.""" + # Icon and message + msg_frame = ctk.CTkFrame(self, fg_color="transparent") + msg_frame.pack(fill="x", padx=30, pady=(25, 15)) + + icon = ctk.CTkLabel( + msg_frame, + text="✅" if added > 0 else "ℹ️", + font=(FONT_FAMILY, 28), + text_color=ACCENT_GREEN if added > 0 else ACCENT_BLUE, + ) + icon.pack(side="left", padx=(0, 15)) + + text_frame = ctk.CTkFrame(msg_frame, fg_color="transparent") + text_frame.pack(side="left", fill="x", expand=True) + + # Title text differs based on mode + if is_replace: + if added > 0: + added_text = f"Replaced with {added} rule{'s' if added != 1 else ''}" + else: + added_text = "All rules cleared" + else: + if added > 0: + added_text = f"Added {added} rule{'s' if added != 1 else ''}" + else: + added_text = "No new rules added" + + title = ctk.CTkLabel( + text_frame, + text=added_text, + font=(FONT_FAMILY, FONT_SIZE_LARGE, "bold"), + text_color=TEXT_PRIMARY, + anchor="w", + ) + title.pack(anchor="w") + + # Subtitle for skipped/duplicates + if skipped > 0: + skip_text = f"{skipped} duplicate{'s' if skipped != 1 else ''} skipped" + subtitle = ctk.CTkLabel( + text_frame, + text=skip_text, + font=(FONT_FAMILY, FONT_SIZE_NORMAL), + text_color=TEXT_MUTED, + anchor="w", + ) + subtitle.pack(anchor="w") + + # OK button + btn_frame = ctk.CTkFrame(self, fg_color="transparent") + btn_frame.pack(fill="x", padx=30, pady=(0, 20)) + + ok_btn = ctk.CTkButton( + btn_frame, + text="OK", + font=(FONT_FAMILY, FONT_SIZE_NORMAL), + fg_color=ACCENT_BLUE, + hover_color="#3a8aee", + width=80, + command=self.destroy, + ) + ok_btn.pack(side="right") + + +# ════════════════════════════════════════════════════════════════════════════════ +# TOOLTIP +# ════════════════════════════════════════════════════════════════════════════════ + + +class ToolTip: + """Simple tooltip implementation for CustomTkinter widgets.""" + + def __init__(self, widget, text: str, delay: int = 500): + self.widget = widget + self.text = text + self.delay = delay + self.tooltip_window = None + self.after_id = None + + widget.bind("", self._schedule_show) + widget.bind("", self._hide) + widget.bind("