feat: add usage retrieval methods for GitHub Copilot and Antigravity providers, update Flask dependency to async version

This commit is contained in:
hlohaus
2026-02-20 13:55:04 +01:00
parent 7461d049c9
commit 3f5eca67da
11 changed files with 137 additions and 24 deletions
+1
View File
@@ -11,3 +11,4 @@ pyvenv.cfg
lib64
/.idea
container/
g4f.dev
+33 -1
View File
@@ -4,14 +4,17 @@ import sys
import json
import time
import asyncio
import aiohttp
from pathlib import Path
from typing import Optional
from ...typing import Messages, AsyncResult
from ...errors import MissingAuthError
from ..template import OpenaiTemplate
from ...providers.asyncio import get_running_loop
from .copilotTokenProvider import CopilotTokenProvider, EDITOR_VERSION, EDITOR_PLUGIN_VERSION
from .copilotTokenProvider import CopilotTokenProvider, EDITOR_VERSION, EDITOR_PLUGIN_VERSION, USER_AGENT, API_VERSION
from .sharedTokenManager import TokenManagerError, SharedTokenManager
from .githubOAuth2 import GithubOAuth2Client
from .oauthFlow import launch_browser_for_oauth
@@ -233,6 +236,35 @@ class GithubCopilot(OpenaiTemplate):
pass
return None
@classmethod
async def get_usage(cls) -> dict:
"""
Fetch and summarize current GitHub Copilot usage/quota information.
Returns a dictionary with usage details or raises an exception on failure.
"""
client = GithubOAuth2Client()
github_creds = await client.sharedManager.getValidCredentials(client)
if not github_creds or not github_creds.get("access_token"):
raise MissingAuthError("No GitHub OAuth token available. Please login first.")
github_token = github_creds["access_token"]
url = f"https://api.github.com/copilot_internal/user"
headers = {
"Accept": "application/json",
"authorization": f"token {github_token}",
"editor-version": EDITOR_VERSION,
"editor-plugin-version": EDITOR_PLUGIN_VERSION,
"user-agent": USER_AGENT,
"x-github-api-version": API_VERSION,
"x-vscode-user-agent-library-version": "electron-fetch",
}
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers) as resp:
if resp.status != 200:
text = await resp.text()
raise RuntimeError(f"Failed to fetch Copilot usage: {resp.status} {text}")
usage = await resp.json()
return usage
async def main():
"""CLI entry point for GitHub Copilot OAuth authentication."""
+4 -3
View File
@@ -14,7 +14,8 @@ from .sharedTokenManager import SharedTokenManager, TokenManagerError
# Editor/Plugin version headers required by Copilot API
EDITOR_VERSION = "vscode/1.95.0"
EDITOR_PLUGIN_VERSION = "copilot/1.250.0"
USER_AGENT = "GithubCopilot/1.250.0"
API_VERSION = "2024-12-15"
class CopilotTokenProvider:
"""Provides Copilot API tokens from GitHub OAuth credentials."""
@@ -55,11 +56,11 @@ class CopilotTokenProvider:
headers={
"Authorization": f"token {github_token}",
"Accept": "application/json",
"User-Agent": "GithubCopilot/1.250.0",
"User-Agent": USER_AGENT,
"Editor-Version": EDITOR_VERSION,
"Editor-Plugin-Version": EDITOR_PLUGIN_VERSION,
"Openai-Organization": "github-copilot",
"X-GitHub-Api-Version": "2024-12-15",
"X-GitHub-Api-Version": API_VERSION,
}
) as resp:
if resp.status == 401:
+57 -12
View File
@@ -34,6 +34,7 @@ from ...errors import MissingAuthError
from ...image.copy_images import save_response_media
from ...image import to_bytes, is_data_an_media
from ...providers.response import Usage, ImageResponse, ToolCalls, Reasoning
from ...providers.asyncio import get_running_loop
from ..base_provider import AsyncGeneratorProvider, ProviderModelMixin, AuthFileMixin
from ..helper import get_connector, get_system_prompt, format_media_prompt
from ... import debug
@@ -1255,16 +1256,8 @@ class Antigravity(AsyncGeneratorProvider, ProviderModelMixin):
# Try to fetch models dynamically if we have credentials
if not cls.models and cls.has_credentials():
try:
import asyncio
cls.models = asyncio.get_event_loop().run_until_complete(
cls._fetch_models()
)
except RuntimeError:
# No event loop running, try creating one
try:
cls.models = asyncio.run(cls._fetch_models())
except Exception as e:
debug.log(f"Failed to fetch dynamic models: {e}")
get_running_loop(check_nested=True)
cls.models = asyncio.run(cls._fetch_models())
except Exception as e:
debug.log(f"Failed to fetch dynamic models: {e}")
@@ -1275,7 +1268,7 @@ class Antigravity(AsyncGeneratorProvider, ProviderModelMixin):
if cls.auth_manager.get_access_token() is not None:
cls.live += 1
return [m for m in cls.models if not m.startswith("chat_") and not m.startswith("tab_")] if cls.models else cls.fallback_models
return cls.models if cls.models else cls.fallback_models
@classmethod
async def _fetch_models(cls) -> List[str]:
@@ -1292,7 +1285,7 @@ class Antigravity(AsyncGeneratorProvider, ProviderModelMixin):
)
# Extract model names from the response
models = list(response.get("models", {}).keys())
models = [key for key, value in response.get("models", {}).items() if not value.get("isInternal", False) and not key.startswith("tab_")]
if not isinstance(models, list):
raise ValueError("Invalid response format: 'models' should be a list")
@@ -1301,6 +1294,58 @@ class Antigravity(AsyncGeneratorProvider, ProviderModelMixin):
debug.log(f"Failed to fetch models: {e}")
return []
@classmethod
async def get_usage(cls) -> dict:
"""
Fetch and summarize quota usage for Antigravity account.
Returns a dict with OpenAI Usage keys if possible, or quota info.
"""
if cls.auth_manager is None:
cls.auth_manager = AntigravityAuthManager(env=os.environ)
await cls.auth_manager.initialize_auth()
access_token = cls.auth_manager.get_access_token()
project_id = cls.auth_manager.get_project_id()
if not access_token or not project_id:
raise MissingAuthError("Cannot fetch usage without valid authentication")
data = await cls.auth_manager.call_endpoint(
method="fetchAvailableModels",
body={"project": cls.auth_manager.get_project_id()}
)
def classify_group(model_name, display_name=None):
combined = f"{model_name} {display_name or ''}".lower()
if "claude" in combined:
return "claude"
if "gemini-3" in combined or "gemini 3" in combined:
if "flash" in combined:
return "gemini-flash"
return "gemini-pro"
if "gemini-2.5" in combined or "gemini 2.5" in combined:
if "flash" in combined:
return "gemini-flash"
return "gemini-pro"
return None
groups = {}
models = data.get("models", {})
for model_name, entry in models.items():
group = classify_group(model_name, entry.get("displayName") or entry.get("modelName"))
if not group:
continue
quota_info = entry.get("quotaInfo", {})
remaining = quota_info.get("remainingFraction")
reset_time = quota_info.get("resetTime")
if group not in groups:
groups[group] = {"remainingFraction": remaining, "resetTime": reset_time, "modelCount": 1}
else:
g = groups[group]
g["remainingFraction"] = min(g["remainingFraction"], remaining) if g["remainingFraction"] is not None and remaining is not None else g["remainingFraction"] or remaining
g["resetTime"] = reset_time if not g["resetTime"] or (reset_time and reset_time < g["resetTime"]) else g["resetTime"]
g["modelCount"] += 1
return {**data, "groups": groups}
@classmethod
async def create_async_generator(
cls,
+7
View File
@@ -854,6 +854,13 @@ class GeminiCLI(AsyncGeneratorProvider, ProviderModelMixin):
cls.models = [bucket["modelId"] for bucket in buckets.get("buckets", [])]
return cls.models if cls.models else cls.fallback_models
@classmethod
async def get_usage(cls) -> dict:
if cls.auth_manager is None:
cls.auth_manager = AuthManager(env=os.environ)
provider = GeminiCLIProvider(env=os.environ, auth_manager=cls.auth_manager)
return await provider.retrieve_user_quota()
@classmethod
async def create_async_generator(
cls,
+16
View File
@@ -253,6 +253,22 @@ class Backend_Api(Api):
else:
return (jsonify({"error": {"message": "No usage data found for this date"}}), 404)
@app.route('/backend-api/v2/quota/<provider>', methods=['GET'])
async def get_quota(provider: str):
try:
provider_handler = convert_to_provider(provider)
except ProviderNotFoundError:
return "Provider not found", 404
if not hasattr(provider_handler, "get_usage"):
return "Provider doesn't support get_usage", 500
try:
response_data = await provider_handler.getUsage()
return jsonify(response_data)
except MissingAuthError as e:
return jsonify({"error": {"message": f"{type(e).__name__}: {e}"}}), 401
except Exception as e:
return jsonify({"error": {"message": f"{type(e).__name__}: {e}"}}), 500
@app.route('/backend-api/v2/log', methods=['POST'])
def add_log():
cache_dir = Path(get_cookies_dir()) / ".logging"
+7
View File
@@ -98,6 +98,10 @@ class Website:
'function': self._background,
'methods': ['GET', 'POST']
},
'/home.html': {
'function': self._home,
'methods': ['GET', 'POST']
},
'/chat/<filename>': {
'function': self._chat,
'methods': ['GET', 'POST']
@@ -137,6 +141,9 @@ class Website:
def _background(self, filename = "background"):
return render(filename)
def _home(self, filename = "home"):
return render(filename)
def _chat(self, filename = ""):
filename = f"chat/{filename}" if filename else "chat/index"
return render(filename)
+4 -2
View File
@@ -17,8 +17,10 @@ from typing import (
Optional,
TYPE_CHECKING,
)
from typing_extensions import TypedDict
try:
from typing_extensions import TypedDict
except ImportError:
from typing import TypedDict
# Only import PIL for type-checkers; no runtime dependency required.
if TYPE_CHECKING:
from PIL.Image import Image as PILImage
+1 -1
View File
@@ -8,7 +8,7 @@ werkzeug
pillow
fastapi
uvicorn
flask
flask[async]
brotli
beautifulsoup4
aiohttp_socks
+1 -1
View File
@@ -10,7 +10,7 @@ pillow
platformdirs
fastapi
uvicorn
flask
flask[async]
brotli
beautifulsoup4
setuptools
+5 -3
View File
@@ -28,7 +28,8 @@ EXTRA_REQUIRE = {
"aiohttp_socks", # proxy
"pillow", # image
"cairosvg", # svg image
"werkzeug", "flask", # gui
"werkzeug",
"flask[async]", # gui
"fastapi", # api
"uvicorn", # api
"nodriver",
@@ -48,7 +49,8 @@ EXTRA_REQUIRE = {
"beautifulsoup4", # web_search and bing.create_images
"aiohttp_socks", # proxy
"pillow", # image
"werkzeug", "flask", # gui
"werkzeug",
"flask[async]", # gui
"fastapi", # api
"uvicorn", # api
"nodriver",
@@ -78,7 +80,7 @@ EXTRA_REQUIRE = {
"a2wsgi",
],
"gui": [
"werkzeug", "flask",
"werkzeug", "flask[async]",
"beautifulsoup4", "pillow",
],
"search": [