Compare commits

..
Author SHA1 Message Date
Giles OdigweandCopilot 7cc5ff771b Remove duplicate WebhookKey properties from merge
Both our branch and main added WebhookKey to the Anthropic test
mock classes, resulting in CS0102 duplicate definition errors.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 10:02:56 -07:00
Giles Odigwe 0269529ccf Merge remote-tracking branch 'upstream/main' into dotnet-split-integration-tests
# Conflicts:
#	dotnet/tests/AnthropicChatCompletion.IntegrationTests/AnthropicSkillsIntegrationTests.cs
2026-05-12 09:54:58 -07:00
Giles OdigweandCopilot e514fc8837 Remove unnecessary RT0003 warning suppression
The RT0003 suppression was added during the Anthropic SDK 12.20.0
upgrade but the warning no longer fires. Removing it to keep the
NoWarn list minimal.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 08:16:42 -07:00
Giles OdigweandCopilot 049e823177 Rename filter parameters for consistency
TestProjectNameFilter  -> TestProjectNameIncludeFilter
TestProjectNameExclude -> TestProjectNameExcludeFilter

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-12 07:22:36 -07:00
Giles OdigweandCopilot c4f9d0d4cf Include workflow file in functions/core path filters
A PR editing only dotnet-build-and-test.yml would skip
dotnet-test-functions because the workflow path was missing
from both the functions and core path filter lists.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-11 08:10:58 -07:00
Giles Odigwe 1929f73959 Merge branch 'main' into dotnet-split-integration-tests 2026-05-11 06:34:47 -07:00
Giles OdigweandCopilot 1b0fbb808e Re-enable Foundry OpenAPI server-side tool integration test
Remove Skip="For manual testing only" from
AsAIAgent_WithOpenAPITool_NativeSDKCreation_InvokesServerSideToolAsync.
The test already uses RetryFact(3 retries, 5s delay) to handle
transient failures from the external restcountries.com API.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-10 16:33:37 -07:00
Giles OdigweandCopilot c799c61ff1 Fix CheckSystem test case to expect 1 response
The CheckSystem workflow sends a 'PASSED!' SendActivity when all system
variables are populated, producing 1 AgentResponseEvent. The test case
had min_response_count: 0 with no max, so the assertion defaulted max
to 0 and failed with 'Response count greater than expected: 0 (Actual: 1)'.
Updated to expect exactly 1 response, matching the SendActivity pattern.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-10 13:32:10 -07:00
Giles OdigweandCopilot 158ecb7b40 Re-enable CheckSystem declarative integration tests
The CheckSystem.yaml tests were temporarily skipped in PR #4270 during
the Azure.AI.Projects 2.0.0-beta.1 SDK update. Since then, the system
variable plumbing (SystemScope, SetLastMessageAsync, conversation
initialization) has been significantly updated and stabilized. The
other tests in these same files pass reliably using the same
infrastructure.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-10 12:20:25 -07:00
Giles OdigweandCopilot 3c8fdb6f49 Fix Anthropic unit test mocks for SDK 12.20.0 interface changes
Add missing interface members: IAnthropicClient.WebhookKey,
IBetaService.MemoryStores, IBetaService.Webhooks, IBetaService.UserProfiles

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-08 14:47:49 -07:00
Giles OdigweandCopilot 5ec3bcf390 Upgrade Anthropic SDK 12.13.0 -> 12.20.0 to fix M.E.AI incompatibility
Fixes MissingMethodException on WebSearchToolResultContent.get_Results()
caused by Anthropic 12.13.0 being compiled against an older
Microsoft.Extensions.AI.Abstractions version.

Suppress RT0003 in AI.Abstractions.csproj as the transitive reference
from the upgraded Anthropic SDK conflicts with the explicit one.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-08 14:29:37 -07:00
Giles OdigweandCopilot 9ce21a2a2f Re-enable Anthropic integration tests
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-08 09:48:57 -07:00
Giles OdigweandCopilot 2a55b35176 Address PR feedback: add Workflows.Generators to core filter, drop dotnetChanges gate from functions job
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-08 07:48:34 -07:00
Giles OdigweandCopilot 08dcf74cf4 Split DurableTask/AzureFunctions integration tests into dedicated CI job
- Add -TestProjectNameExclude parameter to New-FilteredSolution.ps1
- Add 'functions' and 'core' path filters to paths-filter job
- Exclude DurableTask/AzureFunctions from main dotnet-test job
- Remove emulator setup from dotnet-test (no longer needed)
- Add new dotnet-test-functions job (ubuntu/net10.0 only, path-conditional)
- Update merge gate and report job to include dotnet-test-functions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-08 07:25:33 -07:00
15 changed files with 167 additions and 786 deletions
+124 -12
View File
@@ -38,6 +38,8 @@ jobs:
dotnetChanges: ${{ steps.filter.outputs.dotnet }}
cosmosDbChanges: ${{ steps.filter.outputs.cosmosdb }}
foundryHostingChanges: ${{ steps.filter.outputs.foundryHosting }}
functionsChanged: ${{ steps.filter.outputs.functions }}
coreChanged: ${{ steps.filter.outputs.core }}
steps:
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
@@ -64,6 +66,24 @@ jobs:
- 'dotnet/Directory.Packages.props'
- 'dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-build-image.ps1'
- '.github/workflows/dotnet-build-and-test.yml'
functions:
- 'dotnet/src/Microsoft.Agents.AI.DurableTask/**'
- 'dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/**'
- 'dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/**'
- 'dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/**'
- '.github/actions/azure-functions-integration-setup/**'
- '.github/workflows/dotnet-build-and-test.yml'
core:
- 'dotnet/src/Microsoft.Agents.AI/**'
- 'dotnet/src/Microsoft.Agents.AI.Abstractions/**'
- 'dotnet/src/Microsoft.Agents.AI.OpenAI/**'
- 'dotnet/src/Microsoft.Agents.AI.Workflows/**'
- 'dotnet/src/Microsoft.Agents.AI.Workflows.Generators/**'
- 'dotnet/eng/scripts/New-FilteredSolution.ps1'
- 'dotnet/tests/Directory.Build.props'
- 'dotnet/Directory.Packages.props'
- 'dotnet/global.json'
- '.github/workflows/dotnet-build-and-test.yml'
# run only if 'dotnet' files were changed
- name: dotnet tests
if: steps.filter.outputs.dotnet == 'true'
@@ -211,10 +231,11 @@ jobs:
Verbose = $true
}
./dotnet/eng/scripts/New-FilteredSolution.ps1 @commonArgs `
-TestProjectNameFilter "*UnitTests*" `
-TestProjectNameIncludeFilter "*UnitTests*" `
-OutputPath dotnet/filtered-unit.slnx
./dotnet/eng/scripts/New-FilteredSolution.ps1 @commonArgs `
-TestProjectNameFilter "*IntegrationTests*" `
-TestProjectNameIncludeFilter "*IntegrationTests*" `
-TestProjectNameExcludeFilter "*DurableTask.IntegrationTests*","*AzureFunctions.IntegrationTests*" `
-OutputPath dotnet/filtered-integration.slnx
- name: Run Unit Tests
@@ -256,14 +277,6 @@ jobs:
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# This setup action is required for both Durable Task and Azure Functions integration tests.
# We only run it on Ubuntu since the Durable Task and Azure Functions features are not available
# on .NET Framework (net472) which is what we use the Windows runner for.
- name: Set up Durable Task and Azure Functions Integration Test Emulators
if: github.event_name != 'pull_request' && matrix.integration-tests && matrix.os == 'ubuntu-latest'
uses: ./.github/actions/azure-functions-integration-setup
id: azure-functions-setup
- name: Run Integration Tests
shell: pwsh
working-directory: dotnet
@@ -416,11 +429,110 @@ jobs:
AZURE_SEARCH_INDEX_NAME: ${{ secrets.AZURE_SEARCH_INDEX_NAME }}
# IT_HOSTED_AGENT_IMAGE was exported into $GITHUB_ENV by the previous step.
# DurableTask and AzureFunctions integration tests (ubuntu/net10.0 only).
# Split from main dotnet-test job for path-based filtering and parallelism.
dotnet-test-functions:
needs: [paths-filter]
if: >
github.event_name != 'pull_request' &&
(needs.paths-filter.outputs.functionsChanged == 'true' ||
needs.paths-filter.outputs.coreChanged == 'true' ||
github.event_name == 'schedule' ||
github.event_name == 'workflow_dispatch')
runs-on: ubuntu-latest
environment: integration
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
sparse-checkout: |
.
.github
dotnet
python
declarative-agents
- name: Setup dotnet
uses: actions/setup-dotnet@v5.2.0
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
- name: Build functions integration test projects
shell: bash
working-directory: dotnet
run: |
dotnet build ./tests/Microsoft.Agents.AI.DurableTask.IntegrationTests -c Release -f net10.0 --warnaserror
dotnet build ./tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests -c Release -f net10.0 --warnaserror
- name: Azure CLI Login
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Set up Durable Task and Azure Functions Integration Test Emulators
uses: ./.github/actions/azure-functions-integration-setup
id: azure-functions-setup
- name: Run Functions Integration Tests
shell: pwsh
working-directory: dotnet
run: |
# Run DurableTask integration tests
dotnet test `
--project ./tests/Microsoft.Agents.AI.DurableTask.IntegrationTests `
-f net10.0 `
-c Release `
--no-build -v Normal `
--report-xunit-trx `
--report-junit `
--results-directory ../IntegrationTestResults/ `
--ignore-exit-code 8 `
--filter-not-trait "Category=IntegrationDisabled" `
--parallel-algorithm aggressive `
--max-threads 2.0x
# Run AzureFunctions integration tests
dotnet test `
--project ./tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests `
-f net10.0 `
-c Release `
--no-build -v Normal `
--report-xunit-trx `
--report-junit `
--results-directory ../IntegrationTestResults/ `
--ignore-exit-code 8 `
--filter-not-trait "Category=IntegrationDisabled" `
--parallel-algorithm aggressive `
--max-threads 2.0x
env:
# OpenAI Models
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_CHAT_MODEL_NAME: ${{ vars.OPENAI_CHAT_MODEL_NAME }}
OPENAI_REASONING_MODEL_NAME: ${{ vars.OPENAI_REASONING_MODEL_NAME }}
# Azure OpenAI Models
AZURE_OPENAI_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZURE_OPENAI_DEPLOYMENT_NAME }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZURE_OPENAI_ENDPOINT }}
# Azure AI Foundry
AZURE_AI_PROJECT_ENDPOINT: ${{ vars.AZURE_AI_PROJECT_ENDPOINT }}
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${{ vars.AZURE_AI_MODEL_DEPLOYMENT_NAME }}
AZURE_AI_BING_CONNECTION_ID: ${{ vars.AZURE_AI_BING_CONNECTION_ID }}
- name: Upload functions test results
if: always()
uses: actions/upload-artifact@v7
with:
name: dotnet-test-results-functions-net10.0-ubuntu-latest
path: IntegrationTestResults/**/*.junit
if-no-files-found: ignore
# This final job is required to satisfy the merge queue. It must only run (or succeed) if no tests failed
dotnet-build-and-test-check:
if: always()
runs-on: ubuntu-latest
needs: [dotnet-build, dotnet-test, dotnet-foundry-hosted-it]
needs: [dotnet-build, dotnet-test, dotnet-foundry-hosted-it, dotnet-test-functions]
steps:
- name: Get Date
shell: bash
@@ -467,7 +579,7 @@ jobs:
github.event_name != 'pull_request' &&
(contains(join(needs.*.result, ','), 'success') ||
contains(join(needs.*.result, ','), 'failure'))
needs: [dotnet-test]
needs: [dotnet-test, dotnet-test-functions]
runs-on: ubuntu-latest
defaults:
run:
+32 -4
View File
@@ -21,10 +21,15 @@
.PARAMETER Configuration
Optional MSBuild configuration used when querying TargetFrameworks. Defaults to Debug.
.PARAMETER TestProjectNameFilter
.PARAMETER TestProjectNameIncludeFilter
Optional wildcard pattern to filter test project names (e.g., *UnitTests*, *IntegrationTests*).
When specified, only test projects whose filename matches this pattern are kept.
.PARAMETER TestProjectNameExcludeFilter
Optional wildcard pattern(s) to exclude test projects by name (e.g., *DurableTask.IntegrationTests*).
When specified, test projects whose filename matches any of these patterns are removed.
Applied after TestProjectNameIncludeFilter. Can be a single string or an array of strings.
.PARAMETER ExcludeSamples
When specified, removes all projects under the samples/ directory from the solution.
@@ -38,11 +43,15 @@
.EXAMPLE
# Generate a solution with only unit test projects
./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net10.0 -TestProjectNameFilter "*UnitTests*" -OutputPath filtered-unit.slnx
./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net10.0 -TestProjectNameIncludeFilter "*UnitTests*" -OutputPath filtered-unit.slnx
.EXAMPLE
# Inline usage with dotnet test (PowerShell)
dotnet test --solution (./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net472) --no-build -f net472
.EXAMPLE
# Generate integration tests excluding DurableTask and AzureFunctions
./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net10.0 -TestProjectNameIncludeFilter "*IntegrationTests*" -TestProjectNameExcludeFilter "*DurableTask.IntegrationTests*","*AzureFunctions.IntegrationTests*" -OutputPath filtered-other-integration.slnx
#>
[CmdletBinding()]
@@ -55,7 +64,9 @@ param(
[string]$Configuration = "Debug",
[string]$TestProjectNameFilter,
[string]$TestProjectNameIncludeFilter,
[string[]]$TestProjectNameExcludeFilter,
[switch]$ExcludeSamples,
@@ -100,13 +111,30 @@ foreach ($proj in $allProjects) {
$isTestProject = $projRelPath -like "*tests/*"
# Filter test projects by name pattern if specified
if ($isTestProject -and $TestProjectNameFilter -and ($projFileName -notlike $TestProjectNameFilter)) {
if ($isTestProject -and $TestProjectNameIncludeFilter -and ($projFileName -notlike $TestProjectNameIncludeFilter)) {
Write-Verbose "Removing (name filter): $projRelPath"
$removed += $projRelPath
$proj.ParentNode.RemoveChild($proj) | Out-Null
continue
}
# Exclude test projects matching any exclusion pattern
if ($isTestProject -and $TestProjectNameExcludeFilter) {
$excluded = $false
foreach ($pattern in $TestProjectNameExcludeFilter) {
if ($projFileName -like $pattern) {
$excluded = $true
break
}
}
if ($excluded) {
Write-Verbose "Removing (exclude filter): $projRelPath"
$removed += $projRelPath
$proj.ParentNode.RemoveChild($proj) | Out-Null
continue
}
}
if (-not (Test-Path $projFullPath)) {
Write-Verbose "Project not found, keeping in solution: $projRelPath"
$kept += $projRelPath
@@ -163,22 +163,10 @@ app.MapA2AHttpJson(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves");
app.MapDevUI();
app.MapOpenAIResponses();
app.MapOpenAIResponses(pirateAgentBuilder);
app.MapOpenAIResponses(knightsKnavesAgentBuilder);
app.MapOpenAIResponses(chemistryAgent);
app.MapOpenAIResponses(mathsAgent);
app.MapOpenAIResponses(literatureAgent);
app.MapOpenAIResponses(scienceSequentialWorkflow);
app.MapOpenAIResponses(scienceConcurrentWorkflow);
app.MapOpenAIConversations();
app.MapOpenAIChatCompletions(pirateAgentBuilder);
app.MapOpenAIChatCompletions(knightsKnavesAgentBuilder);
app.MapOpenAIChatCompletions(chemistryAgent);
app.MapOpenAIChatCompletions(mathsAgent);
app.MapOpenAIChatCompletions(literatureAgent);
app.MapOpenAIChatCompletions(scienceSequentialWorkflow);
app.MapOpenAIChatCompletions(scienceConcurrentWorkflow);
// Map the agents HTTP endpoints
app.MapAgentDiscovery("/agents");
@@ -24,7 +24,7 @@ internal sealed class OpenAIChatCompletionsAgentClient(HttpClient httpClient) :
{
OpenAIClientOptions options = new()
{
Endpoint = new Uri(httpClient.BaseAddress!, $"/{Uri.EscapeDataString(agentName)}/v1/"),
Endpoint = new Uri(httpClient.BaseAddress!, $"/{agentName}/v1/"),
Transport = new HttpClientPipelineTransport(httpClient)
};
@@ -23,7 +23,7 @@ internal sealed class OpenAIResponsesAgentClient(HttpClient httpClient) : AgentC
{
OpenAIClientOptions options = new()
{
Endpoint = new Uri(httpClient.BaseAddress!, $"/{Uri.EscapeDataString(agentName)}/v1/"),
Endpoint = new Uri(httpClient.BaseAddress!, "/v1/"),
Transport = new HttpClientPipelineTransport(httpClient)
};
@@ -183,7 +183,7 @@ public class FoundryVersionedAgentCreateTests
/// invokes the server-side OpenAPI function through <c>RunAsync</c>.
/// Regression test for https://github.com/microsoft/agent-framework/issues/4883.
/// </summary>
[RetryFact(Constants.RetryCount, Constants.RetryDelay, Skip = "For manual testing only")]
[RetryFact(Constants.RetryCount, Constants.RetryDelay)]
public async Task AsAIAgent_WithOpenAPITool_NativeSDKCreation_InvokesServerSideToolAsync()
{
// Arrange — create agent version with OpenAPI tool using native Azure.AI.Projects SDK types.
@@ -267,43 +267,7 @@ public sealed class OpenAIResponsesAgentResolutionIntegrationTests : IAsyncDispo
Assert.Equal(System.Net.HttpStatusCode.BadRequest, httpResponse.StatusCode);
string responseJson = await httpResponse.Content.ReadAsStringAsync();
using JsonDocument errorDoc1 = JsonDocument.Parse(responseJson);
string? errorCode = errorDoc1.RootElement.GetProperty("error").GetProperty("code").GetString();
Assert.Equal("missing_required_parameter", errorCode);
}
/// <summary>
/// Verifies that the model field alone is not used for agent resolution.
/// The multi-agent endpoint requires agent.name or metadata.entity_id; setting only model returns 400.
/// </summary>
[Fact]
public async Task CreateResponse_WithModelOnly_ReturnsBadRequestAsync()
{
// Arrange
const string AgentName = "test-agent";
this._httpClient = await this.CreateTestServerWithAgentResolutionAsync(
(AgentName, "Instructions", "Response"));
// Act - Send request with model=agentName but no agent.name or metadata.entity_id
using StringContent requestContent = new(JsonSerializer.Serialize(new
{
model = AgentName,
input = new[]
{
new { type = "message", role = "user", content = "Test message" }
}
}), Encoding.UTF8, "application/json");
using HttpResponseMessage httpResponse = await this._httpClient!.PostAsync(new Uri("/v1/responses", UriKind.Relative), requestContent);
// Assert - model is not used for agent resolution
Assert.Equal(System.Net.HttpStatusCode.BadRequest, httpResponse.StatusCode);
string responseJson = await httpResponse.Content.ReadAsStringAsync();
using JsonDocument errorDoc2 = JsonDocument.Parse(responseJson);
string? errorCode = errorDoc2.RootElement.GetProperty("error").GetProperty("code").GetString();
Assert.Equal("missing_required_parameter", errorCode);
Assert.Contains("agent.name", responseJson, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
@@ -14,7 +14,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests;
public sealed class DeclarativeCodeGenTest(ITestOutputHelper output) : WorkflowTest(output)
{
[Theory]
[InlineData("CheckSystem.yaml", "CheckSystem.json", Skip = "Temporarily skipped")]
[InlineData("CheckSystem.yaml", "CheckSystem.json")]
[InlineData("SendActivity.yaml", "SendActivity.json")]
[InlineData("InvokeAgent.yaml", "InvokeAgent.json")]
[InlineData("InvokeAgent.yaml", "InvokeAgent.json", true)]
@@ -15,7 +15,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests;
public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : WorkflowTest(output)
{
[Theory]
[InlineData("CheckSystem.yaml", "CheckSystem.json", Skip = "Temporarily skipped")]
[InlineData("CheckSystem.yaml", "CheckSystem.json")]
[InlineData("ConversationMessages.yaml", "ConversationMessages.json")]
[InlineData("ConversationMessages.yaml", "ConversationMessages.json", true)]
[InlineData("InputArguments.yaml", "InputArguments.json")]
@@ -10,7 +10,10 @@
"conversation_count": 1,
"min_action_count": 2,
"max_action_count": -1,
"min_response_count": 0,
"min_response_count": 1,
"max_response_count": 1,
"min_message_count": 0,
"max_message_count": 0,
"actions": {
"start": [
"check_system"
@@ -3,16 +3,6 @@
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__)
@@ -20,17 +10,9 @@ 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",
]
@@ -1,573 +0,0 @@
# 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,7 +24,6 @@ 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'",
]
@@ -1,108 +0,0 @@
# 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())
+1 -15
View File
@@ -596,15 +596,13 @@ 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 = "agent-framework-openai", editable = "packages/openai" },
{ name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=1.0.0b2,<=1.0.0b2" },
{ name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = "<=1.0.0b2,>=1.0.0b2" },
]
[[package]]
@@ -2647,18 +2645,14 @@ 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" },
@@ -2666,9 +2660,7 @@ 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" },
@@ -2676,9 +2668,7 @@ 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" },
@@ -2686,9 +2676,7 @@ 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" },
@@ -2696,9 +2684,7 @@ 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" },