mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
859f020669 | ||
|
|
144c8a6450 |
@@ -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'",
|
||||
]
|
||||
|
||||
|
||||
+108
@@ -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())
|
||||
Generated
+15
-1
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user