Compare commits

...
Author SHA1 Message Date
Shawn Henry 859f020669 fix for model_client 2026-05-12 13:41:11 -07:00
Shawn Henry 144c8a6450 Inital checking 2026-05-12 12:58:26 -07:00
5 changed files with 715 additions and 1 deletions
@@ -3,6 +3,16 @@
import importlib.metadata
from ._agent import GitHubCopilotAgent, GitHubCopilotOptions, GitHubCopilotSettings, RawGitHubCopilotAgent
from ._model_client import (
COPILOT_BASE_URL,
GitHubCopilotModelClient,
build_copilot_headers,
device_code_login,
exchange_token,
fetch_copilot_model_catalog,
resolve_github_token,
validate_token,
)
try:
__version__ = importlib.metadata.version(__name__)
@@ -10,9 +20,17 @@ except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"
__all__ = [
"COPILOT_BASE_URL",
"GitHubCopilotAgent",
"GitHubCopilotModelClient",
"GitHubCopilotOptions",
"GitHubCopilotSettings",
"RawGitHubCopilotAgent",
"__version__",
"build_copilot_headers",
"device_code_login",
"exchange_token",
"fetch_copilot_model_catalog",
"resolve_github_token",
"validate_token",
]
@@ -0,0 +1,573 @@
# Copyright (c) Microsoft. All rights reserved.
"""GitHub Copilot model client.
Provides :class:`GitHubCopilotModelClient`, a chat client that targets the
OpenAI-compatible inference endpoint exposed by GitHub Copilot
(``https://api.githubcopilot.com``).
The class wraps :class:`agent_framework_openai.OpenAIChatCompletionClient` and
adds the Copilot-specific concerns:
* Resolving a raw GitHub token from environment variables or the ``gh`` CLI,
with optional interactive OAuth device-code login.
* Exchanging the raw token for the short-lived Copilot API token and
refreshing it transparently on expiry.
* Attaching the editor-attribution headers the Copilot API expects.
This client is intended for prototyping and personal-use scenarios; the OAuth
client ID below is the public one shared by the Copilot CLI / opencode
project. Production integrations should register their own GitHub OAuth App.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import os
import shutil
import subprocess
import time
import urllib.error
import urllib.parse
import urllib.request
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from agent_framework._middleware import ChatAndFunctionMiddlewareTypes
from agent_framework._tools import FunctionInvocationConfiguration
from agent_framework_openai import OpenAIChatCompletionClient
logger = logging.getLogger("agent_framework.github_copilot")
# region Constants
# OAuth device-code used by Copilot CLI.
OAUTH_CLIENT_ID = "Ov23li8tweQw6odWQebz"
OAUTH_SCOPE = "read:user"
# Token exchange endpoint (raw GitHub token -> short-lived Copilot API token).
TOKEN_EXCHANGE_URL = "https://api.github.com/copilot_internal/v2/token"
# OpenAI-compatible inference endpoint.
COPILOT_BASE_URL = "https://api.githubcopilot.com"
# Model catalog endpoint.
COPILOT_MODELS_URL = f"{COPILOT_BASE_URL}/models"
# Editor attribution headers - the API validates these.
EDITOR_VERSION = "vscode/1.104.1"
COPILOT_INTEGRATION_ID = "vscode-chat"
USER_AGENT = "GitHubCopilotChat/0.26.7"
# Env vars checked in priority order (matches Copilot CLI behaviour).
TOKEN_ENV_VARS = ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN")
_CLASSIC_PAT_PREFIX = "ghp_"
DEVICE_CODE_POLL_SAFETY_MARGIN = 3 # seconds added to server poll interval
EXCHANGE_CACHE_REFRESH_MARGIN = 120 # refresh 2 min before expiry
DEFAULT_MODEL = "gpt-5-mini"
# region Token resolution
def validate_token(token: str) -> tuple[bool, str]:
"""Return ``(ok, reason)`` for whether ``token`` is usable with Copilot.
Classic PATs (``ghp_*``) are explicitly unsupported - the token exchange
endpoint rejects them. Accepted prefixes are ``gho_*``, ``github_pat_*``
and ``ghu_*``.
"""
token = token.strip()
if not token:
return False, "Empty token"
if token.startswith(_CLASSIC_PAT_PREFIX):
return False, (
"Classic Personal Access Tokens (ghp_*) are not supported by the "
"Copilot API. Use an OAuth token (gho_*) from `gh auth login`, a "
"fine-grained PAT (github_pat_*) with the Copilot Requests "
"permission, or this client's interactive device-code login."
)
return True, "OK"
def _gh_cli_candidates() -> list[str]:
candidates: list[str] = []
resolved = shutil.which("gh")
if resolved:
candidates.append(resolved)
for path in (
"/opt/homebrew/bin/gh",
"/usr/local/bin/gh",
str(Path.home() / ".local" / "bin" / "gh"),
):
if path not in candidates and os.path.isfile(path) and os.access(path, os.X_OK):
candidates.append(path)
return candidates
def _try_gh_cli_token() -> str | None:
"""Read a token from ``gh auth token`` if the GitHub CLI is installed."""
clean_env = {k: v for k, v in os.environ.items() if k not in ("GITHUB_TOKEN", "GH_TOKEN")}
gh_host = os.getenv("COPILOT_GH_HOST", "").strip()
for gh_path in _gh_cli_candidates():
cmd = [gh_path, "auth", "token"]
if gh_host:
cmd += ["--hostname", gh_host]
try:
result = subprocess.run( # noqa: S603 - trusted gh binary
cmd, capture_output=True, text=True, timeout=5, env=clean_env, check=False,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
continue
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
return None
def resolve_github_token() -> tuple[str, str]:
"""Find a usable GitHub token.
Returns ``(token, source)``. ``token`` is empty when nothing was found.
Unsupported classic PATs from env vars are skipped with a warning.
"""
for var in TOKEN_ENV_VARS:
val = os.getenv(var, "").strip()
if val:
ok, msg = validate_token(val)
if not ok:
logger.warning("Token from %s rejected: %s", var, msg)
continue
return val, var
token = _try_gh_cli_token()
if token:
ok, msg = validate_token(token)
if not ok:
raise ValueError(f"Token from `gh auth token` is unsupported: {msg}")
return token, "gh auth token"
return "", ""
# region Device-code login
def device_code_login(
*,
host: str = "github.com",
timeout_seconds: float = 300,
) -> str | None:
"""Run the GitHub OAuth device-code flow (RFC 8628).
Prints a URL and one-time code, polls until the user authorizes, and
returns the resulting OAuth access token (``gho_*``). Returns ``None``
on failure, denial or timeout.
"""
domain = host.rstrip("/")
device_code_url = f"https://{domain}/login/device/code"
access_token_url = f"https://{domain}/login/oauth/access_token"
body = urllib.parse.urlencode({"client_id": OAUTH_CLIENT_ID, "scope": OAUTH_SCOPE}).encode()
req = urllib.request.Request( # noqa: S310 - https URL
device_code_url,
data=body,
headers={
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": USER_AGENT,
},
)
try:
with urllib.request.urlopen(req, timeout=15) as resp: # noqa: S310
data = json.loads(resp.read().decode())
except Exception as exc:
logger.error("Failed to start device authorization: %s", exc)
return None
verification_uri = data.get("verification_uri", f"https://{domain}/login/device")
user_code = data.get("user_code", "")
device_code = data.get("device_code", "")
interval = max(int(data.get("interval", 5)), 1)
if not device_code or not user_code:
logger.error("GitHub did not return a device code.")
return None
print()
print(f" Open: {verification_uri}")
print(f" Code: {user_code}")
print()
print(" Waiting for authorization", end="", flush=True)
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
time.sleep(interval + DEVICE_CODE_POLL_SAFETY_MARGIN)
poll_body = urllib.parse.urlencode({
"client_id": OAUTH_CLIENT_ID,
"device_code": device_code,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
}).encode()
poll_req = urllib.request.Request( # noqa: S310
access_token_url,
data=poll_body,
headers={
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": USER_AGENT,
},
)
try:
with urllib.request.urlopen(poll_req, timeout=10) as resp: # noqa: S310
result = json.loads(resp.read().decode())
except Exception:
print(".", end="", flush=True)
continue
if result.get("access_token"):
print(" ok")
return str(result["access_token"])
error = result.get("error", "")
if error == "authorization_pending":
print(".", end="", flush=True)
elif error == "slow_down":
server_interval = result.get("interval")
interval = int(server_interval) if isinstance(server_interval, (int, float)) else interval + 5
print(".", end="", flush=True)
elif error == "expired_token":
print("\n Device code expired.")
return None
elif error == "access_denied":
print("\n Authorization denied.")
return None
else:
print(f"\n Unexpected error: {error}")
return None
print("\n Timed out.")
return None
# region Token exchange
@dataclass
class _ExchangeCache:
entries: dict[str, tuple[str, float]] = field(default_factory=dict)
_cache = _ExchangeCache()
def _fingerprint(raw_token: str) -> str:
return hashlib.sha256(raw_token.encode()).hexdigest()[:16]
def exchange_token(raw_token: str, *, timeout: float = 10.0) -> tuple[str, float]:
"""Exchange a raw GitHub token for a short-lived Copilot API token.
Returns ``(api_token, expires_at_epoch)``. Results are cached in-process
and reused until close to expiry.
"""
fp = _fingerprint(raw_token)
cached = _cache.entries.get(fp)
if cached:
api_token, expires_at = cached
if time.time() < expires_at - EXCHANGE_CACHE_REFRESH_MARGIN:
return api_token, expires_at
req = urllib.request.Request( # noqa: S310
TOKEN_EXCHANGE_URL,
method="GET",
headers={
"Authorization": f"token {raw_token}",
"User-Agent": USER_AGENT,
"Accept": "application/json",
"Editor-Version": EDITOR_VERSION,
},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
data = json.loads(resp.read().decode())
except urllib.error.HTTPError as exc:
body = ""
try:
body = exc.read().decode(errors="ignore")
except Exception:
pass
raise ValueError(f"Token exchange failed (HTTP {exc.code}): {body}") from exc
except Exception as exc:
raise ValueError(f"Token exchange failed: {exc}") from exc
api_token = data.get("token", "")
expires_at = data.get("expires_at", 0)
if not api_token:
raise ValueError("Token exchange returned an empty token")
expires_at = float(expires_at) if expires_at else time.time() + 1800
_cache.entries[fp] = (api_token, expires_at)
return api_token, expires_at
# region Headers / catalog
def build_copilot_headers(*, is_agent_turn: bool = True) -> dict[str, str]:
"""Build the editor-attribution headers required by the Copilot API."""
return {
"Editor-Version": EDITOR_VERSION,
"User-Agent": USER_AGENT,
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
"Openai-Intent": "conversation-edits",
"x-initiator": "agent" if is_agent_turn else "user",
}
def fetch_copilot_model_catalog(api_token: str, *, timeout: float = 5.0) -> list[dict[str, Any]]:
"""Return the chat-capable models visible to the authenticated account."""
headers = {**build_copilot_headers(), "Authorization": f"Bearer {api_token}"}
req = urllib.request.Request(COPILOT_MODELS_URL, headers=headers) # noqa: S310
try:
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
data = json.loads(resp.read().decode())
except Exception as exc:
logger.error("Failed to fetch model catalog: %s", exc)
return []
items = data if isinstance(data, list) else data.get("data", [])
models: list[dict[str, Any]] = []
seen: set[str] = set()
for item in items:
if not isinstance(item, dict):
continue
model_id = str(item.get("id") or "").strip()
if not model_id or model_id in seen:
continue
if item.get("model_picker_enabled") is False:
continue
caps = item.get("capabilities") or {}
model_type = str(caps.get("type") or "").lower()
if model_type and model_type != "chat":
continue
endpoints = item.get("supported_endpoints")
if isinstance(endpoints, list):
normalized = {str(e).strip() for e in endpoints if str(e).strip()}
if normalized and not normalized & {"/chat/completions", "/responses", "/v1/messages"}:
continue
seen.add(model_id)
models.append(item)
return models
# region Token acquisition (cache + fallback to device code)
_TOKEN_CACHE_PATH = Path.home() / ".agent_framework" / "github_copilot_token"
def _read_cached_raw_token() -> str:
try:
if _TOKEN_CACHE_PATH.is_file():
token = _TOKEN_CACHE_PATH.read_text(encoding="utf-8").strip()
ok, _ = validate_token(token)
if ok:
return token
except OSError:
pass
return ""
def _write_cached_raw_token(token: str) -> None:
try:
_TOKEN_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
_TOKEN_CACHE_PATH.write_text(token, encoding="utf-8")
try:
os.chmod(_TOKEN_CACHE_PATH, 0o600)
except OSError:
pass
except OSError as exc:
logger.debug("Failed to cache GitHub token: %s", exc)
def _acquire_copilot_token(
*,
api_key: str | None,
interactive: bool,
) -> tuple[str, str]:
"""Resolve a raw GitHub token for the Copilot API.
Tries (in order): explicit ``api_key``, on-disk cache, env vars, ``gh`` CLI.
Returns the first available token without requiring a successful exchange
(the Copilot API accepts the raw ``gho_*`` token directly when the
exchange endpoint is unavailable).
If nothing is found and ``interactive`` is True, falls back to the
device-code login flow and caches the resulting token.
"""
explicit = (api_key or "").strip()
if explicit:
ok, msg = validate_token(explicit)
if not ok:
raise ValueError(f"Provided GitHub token is unsupported: {msg}")
return explicit, "api_key argument"
cached = _read_cached_raw_token()
if cached:
return cached, f"cache ({_TOKEN_CACHE_PATH})"
resolved, src = resolve_github_token()
if resolved:
return resolved, src
if not interactive:
raise RuntimeError(
"No GitHub token found. Pass `api_key=...`, set GITHUB_TOKEN / "
"GH_TOKEN / COPILOT_GITHUB_TOKEN, or pass `interactive=True` to "
"sign in via device-code."
)
print("No GitHub token found - starting interactive login...")
obtained = device_code_login()
if not obtained:
raise RuntimeError("Interactive GitHub login failed or was cancelled.")
_write_cached_raw_token(obtained)
return obtained, "device-code login"
class _CopilotTokenProvider:
"""Callable that returns a current Copilot API token, refreshing as needed.
The OpenAI Python SDK invokes the api_key callable per request, so this
transparently handles the ~25-minute Copilot token lifetime.
"""
def __init__(self, raw_token: str) -> None:
self._raw_token = raw_token
async def __call__(self) -> str:
try:
api_token, _ = await asyncio.to_thread(exchange_token, self._raw_token)
return api_token
except Exception as exc:
logger.debug("Token exchange failed, using raw token: %s", exc)
return self._raw_token
# region Client
class GitHubCopilotModelClient(OpenAIChatCompletionClient):
"""Chat client backed by the GitHub Copilot OpenAI-compatible API.
Authentication is resolved automatically:
1. ``api_key`` keyword argument (a raw GitHub OAuth or fine-grained PAT).
2. ``COPILOT_GITHUB_TOKEN`` / ``GH_TOKEN`` / ``GITHUB_TOKEN`` env vars.
3. ``gh auth token`` from the GitHub CLI.
4. Interactive OAuth device-code login (when ``interactive=True``).
The raw token is exchanged for a short-lived Copilot API token on each
request via a callable api_key, so token refresh is automatic.
Args:
model: Copilot model id (e.g. ``"gpt-4o"``, ``"claude-sonnet-4"``).
When omitted, falls back to the ``GITHUB_COPILOT_MODEL`` env var
and finally to ``"gpt-4o"``.
api_key: Optional raw GitHub token to use instead of the resolution
chain above.
interactive: When ``True`` (default) and no token can be resolved,
launch the device-code login flow.
default_headers: Extra HTTP headers merged on top of the Copilot
attribution headers.
middleware: Optional chat/function middleware.
function_invocation_configuration: Optional function-invocation
configuration forwarded to the base client.
Example:
>>> from agent_framework_github_copilot import GitHubCopilotModelClient
>>> client = GitHubCopilotModelClient(model="gpt-4o")
>>> # use as you would any agent_framework chat client
"""
OTEL_PROVIDER_NAME = "github_copilot"
def __init__(
self,
model: str | None = None,
*,
api_key: str | None = None,
interactive: bool = True,
default_headers: Mapping[str, str] | None = None,
instruction_role: str | None = None,
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
) -> None:
raw_token, source = _acquire_copilot_token(api_key=api_key, interactive=interactive)
logger.info("GitHubCopilotModelClient using token from: %s", source)
resolved_model = (
model
or os.getenv("GITHUB_COPILOT_MODEL", "").strip()
or DEFAULT_MODEL
)
merged_headers: dict[str, str] = dict(build_copilot_headers())
if default_headers:
merged_headers.update(default_headers)
# Construct AsyncOpenAI ourselves so the User-Agent header (which the
# Copilot API validates) isn't prefixed with "agent-framework/...".
from openai import AsyncOpenAI
async_client = AsyncOpenAI(
api_key=raw_token, # initial value; refreshed per request via api_key callable
base_url=COPILOT_BASE_URL,
default_headers=merged_headers,
)
super().__init__(
model=resolved_model,
api_key=_CopilotTokenProvider(raw_token),
async_client=async_client,
instruction_role=instruction_role,
middleware=middleware,
function_invocation_configuration=function_invocation_configuration,
)
def _parse_response_from_openai(self, response: Any, options: Mapping[str, Any]) -> Any: # type: ignore[override]
# Copilot's chat-completions response sometimes omits the `created`
# timestamp; the base parser unconditionally calls
# ``datetime.fromtimestamp(response.created)`` and crashes. Patch it.
if getattr(response, "created", None) is None:
try:
response.created = int(time.time())
except Exception:
pass
return super()._parse_response_from_openai(response, options)
@classmethod
def list_models(cls, *, api_key: str | None = None, interactive: bool = True) -> list[dict[str, Any]]:
"""Return the chat-capable models available to the authenticated account."""
raw_token, _ = _acquire_copilot_token(api_key=api_key, interactive=interactive)
try:
api_token, _ = exchange_token(raw_token)
except Exception as exc:
logger.debug("Token exchange failed, using raw token: %s", exc)
api_token = raw_token
return fetch_copilot_model_catalog(api_token)
def __repr__(self) -> str:
return f"GitHubCopilotModelClient(model={self.model!r})"
@@ -24,6 +24,7 @@ classifiers = [
]
dependencies = [
"agent-framework-core>=1.3.0,<2",
"agent-framework-openai>=1.2.2,<2",
"github-copilot-sdk>=1.0.0b2,<=1.0.0b2; python_version >= '3.11'",
]
@@ -0,0 +1,108 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from random import randint
from typing import Annotated
from agent_framework import Agent, tool
from agent_framework_github_copilot import GitHubCopilotModelClient
from dotenv import load_dotenv
load_dotenv()
"""
GitHub Copilot Model Client Example
Uses the OpenAI-compatible Copilot inference endpoint
(https://api.githubcopilot.com) with automatic GitHub OAuth.
Auth resolves in this order:
1. `api_key=` argument (a raw GitHub OAuth token / fine-grained PAT)
2. COPILOT_GITHUB_TOKEN / GH_TOKEN / GITHUB_TOKEN env vars
3. `gh auth token` from the GitHub CLI
4. Interactive OAuth device-code login (when interactive=True, the default)
Optional env vars:
GITHUB_COPILOT_MODEL default model id (e.g. "gpt-4o", "claude-sonnet-4")
"""
MODEL = "claude-opus-4.7-1m-internal"
@tool(approval_mode="never_require")
def get_weather(
location: Annotated[str, "The location to get the weather for."],
) -> str:
"""Get the weather for a given location."""
conditions = ["sunny", "cloudy", "rainy", "stormy"]
return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C."
async def non_streaming_example() -> None:
"""Non-streaming example.
Note: The Copilot chat-completions endpoint can return 403 for
non-streaming requests issued via the OpenAI SDK (the same payload
succeeds with ``stream=True``). This demo therefore wraps the call so a
failure is reported and the rest of the sample continues.
"""
print("=== Non-streaming Response Example ===")
agent = Agent(
client=GitHubCopilotModelClient(model=MODEL),
name="WeatherAgent",
instructions="You are a helpful weather agent.",
tools=get_weather,
)
query = "What's the weather like in Seattle?"
print(f"User: {query}")
try:
result = await agent.run(query)
print(f"Result: {result}\n")
except Exception as exc: # noqa: BLE001 - sample-only diagnostic
print(f"(non-streaming failed: {exc})\n")
async def streaming_example() -> None:
print("=== Streaming Response Example ===")
agent = Agent(
client=GitHubCopilotModelClient(model=MODEL),
name="WeatherAgent",
instructions="You are a helpful weather agent.",
tools=get_weather,
)
query = "What's the weather like in Portland and in Paris?"
print(f"User: {query}")
print("Agent: ", end="", flush=True)
async for chunk in agent.run(query, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
async def list_models_example() -> None:
print("=== Available Copilot Models ===")
models = GitHubCopilotModelClient.list_models()
for item in models[:10]:
mid = item["id"]
caps = item.get("capabilities") or {}
ctx = (caps.get("limits") or {}).get("max_prompt_tokens", "?")
print(f" - {mid} (context: {ctx})")
if len(models) > 10:
print(f" ... and {len(models) - 10} more")
print()
async def main() -> None:
print("=== GitHub Copilot Model Client Example ===\n")
await list_models_example()
await streaming_example()
await non_streaming_example()
if __name__ == "__main__":
asyncio.run(main())
+15 -1
View File
@@ -596,13 +596,15 @@ version = "1.0.0b260507"
source = { editable = "packages/github_copilot" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "agent-framework-openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "github-copilot-sdk", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" },
]
[package.metadata]
requires-dist = [
{ name = "agent-framework-core", editable = "packages/core" },
{ name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = "<=1.0.0b2,>=1.0.0b2" },
{ name = "agent-framework-openai", editable = "packages/openai" },
{ name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=1.0.0b2,<=1.0.0b2" },
]
[[package]]
@@ -2645,14 +2647,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/03/84359833f7e1d49a883e92777637c592306030e30cee5e2b1e6476f95c88/greenlet-3.5.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:29ea813b2e1f45fa9649a17853b2b5465c4072fbcb072e5af6cd3a288216574a", size = 283502, upload-time = "2026-04-27T12:20:55.213Z" },
{ url = "https://files.pythonhosted.org/packages/25/ce/6f9f008266273aa14a2e011945797ac5802b97b8b40efe7afe1ee6c1afc9/greenlet-3.5.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:804a70b328e706b785c6ef16187051c394a63dd1a906d89be24b6ad77759f13f", size = 600508, upload-time = "2026-04-27T12:52:37.876Z" },
{ url = "https://files.pythonhosted.org/packages/e0/6d/b0f3272c2368ea2c1aa19a5ad70db0be8f8dff6e6d3d1eb82efa00cbcf19/greenlet-3.5.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:884f649de075b84739713d41dd4dfd41e2b910bfb769c4a3ea02ec1da52cd9bb", size = 613283, upload-time = "2026-04-27T12:59:37.957Z" },
{ url = "https://files.pythonhosted.org/packages/e5/ae/1db979ff6ae7958d80b288f63d5f6c30df96682700ea9fc340ce994d94a1/greenlet-3.5.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d0eadc7e4d9ffb2af4247b606cae307be8e448911e5a0d0b16d72fc3d224cfd", size = 619894, upload-time = "2026-04-27T13:02:35.13Z" },
{ url = "https://files.pythonhosted.org/packages/ed/ac/0b509b6fb93551ce5a01612ee1acda7f7dda4bbb66c99aeb2ab403d205dc/greenlet-3.5.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b28037cb07768933c54d81bfe47a85f9f402f57d7d69743b991a713b63954eb", size = 613418, upload-time = "2026-04-27T12:25:23.852Z" },
{ url = "https://files.pythonhosted.org/packages/ce/94/b0590e3d1978f02419f30502341c40d72f77eb0a2198119fe27df47714ee/greenlet-3.5.0-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:f8c30c2225f40dd76c50790f0eb3b5c7c18431efb299e2782083e1981feed243", size = 415681, upload-time = "2026-04-27T13:05:11.494Z" },
{ url = "https://files.pythonhosted.org/packages/03/03/2b2b680ec87aaa97998fb5b8d76658d4d3560386864f17efab33ba7c2e24/greenlet-3.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cda05425526240807408156b6960a17a79a0c760b813573b67027823be760977", size = 1572229, upload-time = "2026-04-27T12:53:23.509Z" },
{ url = "https://files.pythonhosted.org/packages/61/e4/42b259e7a19aff1a270a4bd82caf6353109ed6860c9454e18f37162b83ae/greenlet-3.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9c615f869163e14bb1ced20322d8038fb680b08236521ac3f30cd4c1288785a0", size = 1639886, upload-time = "2026-04-27T12:25:22.325Z" },
{ url = "https://files.pythonhosted.org/packages/6f/b4/733ca47b883b67c57f90d3ecb21055c9ec753597d10754ac201644061f9d/greenlet-3.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:ba8f0bdc2fae6ce915dfd0c16d2d00bca7e4247c1eae4416e06430e522137858", size = 237795, upload-time = "2026-04-27T12:21:40.118Z" },
{ url = "https://files.pythonhosted.org/packages/8b/0f/a91f143f356523ff682309732b175765a9bc2836fd7c081c2c67fedc1ad4/greenlet-3.5.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082", size = 284726, upload-time = "2026-04-27T12:20:51.402Z" },
{ url = "https://files.pythonhosted.org/packages/95/82/800646c7ffc5dbabd75ddd2f6b519bb898c0c9c969e5d0473bfe5d20bcce/greenlet-3.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3", size = 604264, upload-time = "2026-04-27T12:52:39.494Z" },
{ url = "https://files.pythonhosted.org/packages/ca/ac/354867c0bba812fc33b15bc55aedafedd0aee3c7dd91dfca22444157dc0c/greenlet-3.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c", size = 616099, upload-time = "2026-04-27T12:59:39.623Z" },
{ url = "https://files.pythonhosted.org/packages/c9/ab/192090c4a5b30df148c22bf4b8895457d739a7c7c5a7b9c41e5dd7f537f2/greenlet-3.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564", size = 623976, upload-time = "2026-04-27T13:02:37.363Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b0/815bece7399e01cadb69014219eebd0042339875c59a59b0820a46ece356/greenlet-3.5.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662", size = 615198, upload-time = "2026-04-27T12:25:25.928Z" },
{ url = "https://files.pythonhosted.org/packages/24/11/05eb2b9b188c6df7d68a89c99134d644a7af616a40b9808e8e6ced315d5d/greenlet-3.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc", size = 418379, upload-time = "2026-04-27T13:05:12.755Z" },
{ url = "https://files.pythonhosted.org/packages/10/80/3b2c0a895d6698f6ddb31b07942ebfa982f3e30888bc5546a5b5990de8b2/greenlet-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b", size = 1574927, upload-time = "2026-04-27T12:53:25.81Z" },
{ url = "https://files.pythonhosted.org/packages/44/0e/f354af514a4c61454dbc68e44d47544a5a4d6317e30b77ddfa3a09f4c5f3/greenlet-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4", size = 1642683, upload-time = "2026-04-27T12:25:23.9Z" },
{ url = "https://files.pythonhosted.org/packages/fa/6a/87f38255201e993a1915265ebb80cd7c2c78b04a45744995abbf6b259fd8/greenlet-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8", size = 238115, upload-time = "2026-04-27T12:21:48.845Z" },
@@ -2660,7 +2666,9 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" },
{ url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" },
{ url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" },
{ url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610, upload-time = "2026-04-27T13:02:39.194Z" },
{ url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" },
{ url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775, upload-time = "2026-04-27T13:05:14.261Z" },
{ url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" },
{ url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" },
@@ -2668,7 +2676,9 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" },
{ url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" },
{ url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" },
{ url = "https://files.pythonhosted.org/packages/6a/15/a643b4ecd09969e30b8a150d5919960caae0abe4f5af75ab040b1ab85e78/greenlet-3.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d", size = 623234, upload-time = "2026-04-27T13:02:40.611Z" },
{ url = "https://files.pythonhosted.org/packages/8a/17/a3918541fd0ddefe024a69de6d16aa7b46d36ac19562adaa63c7fa180eff/greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", size = 613927, upload-time = "2026-04-27T12:25:30.28Z" },
{ url = "https://files.pythonhosted.org/packages/77/18/3b13d5ef1275b0ffaf933b05efa21408ac4ca95823c7411d79682e4fdcff/greenlet-3.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae", size = 425243, upload-time = "2026-04-27T13:05:15.689Z" },
{ url = "https://files.pythonhosted.org/packages/ee/e1/bd0af6213c7dd33175d8a462d4c1fe1175124ebed4855bc1475a5b5242c2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", size = 1570893, upload-time = "2026-04-27T12:53:29.483Z" },
{ url = "https://files.pythonhosted.org/packages/9b/2a/0789702f864f5382cb476b93d7a9c823c10472658102ccd65f415747d2e2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", size = 1636060, upload-time = "2026-04-27T12:25:28.845Z" },
{ url = "https://files.pythonhosted.org/packages/b2/8f/22bf9df92bbff0eb07842b60f7e63bf7675a9742df628437a9f02d09137f/greenlet-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5", size = 238740, upload-time = "2026-04-27T12:24:01.341Z" },
@@ -2676,7 +2686,9 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" },
{ url = "https://files.pythonhosted.org/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" },
{ url = "https://files.pythonhosted.org/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" },
{ url = "https://files.pythonhosted.org/packages/fb/89/2dadb89793c37ee8b4c237857188293e9060dc085f19845c292e00f8e091/greenlet-3.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37", size = 668086, upload-time = "2026-04-27T13:02:42.314Z" },
{ url = "https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" },
{ url = "https://files.pythonhosted.org/packages/82/35/75722be7e26a2af4cbd2dc35b0ed382dacf9394b7e75551f76ed1abe87f2/greenlet-3.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2", size = 470799, upload-time = "2026-04-27T13:05:17.094Z" },
{ url = "https://files.pythonhosted.org/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" },
{ url = "https://files.pythonhosted.org/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" },
{ url = "https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" },
@@ -2684,7 +2696,9 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" },
{ url = "https://files.pythonhosted.org/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" },
{ url = "https://files.pythonhosted.org/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" },
{ url = "https://files.pythonhosted.org/packages/5f/5c/0602239503b124b70e39355cbdb39361ecfe65b87a5f2f63752c32f5286f/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce", size = 657015, upload-time = "2026-04-27T13:02:43.973Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" },
{ url = "https://files.pythonhosted.org/packages/38/51/8699f865f125dc952384cb432b0f7138aa4d8f2969a7d12d0df5b94d054d/greenlet-3.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2", size = 488275, upload-time = "2026-04-27T13:05:18.28Z" },
{ url = "https://files.pythonhosted.org/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" },
{ url = "https://files.pythonhosted.org/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" },
{ url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" },