Skip to content

feat: Add GitHub Copilot provider with OAuth support#2366

Open
alex-spacemit wants to merge 3 commits intoagentscope-ai:mainfrom
alex-spacemit:add-github-copilot-fixed
Open

feat: Add GitHub Copilot provider with OAuth support#2366
alex-spacemit wants to merge 3 commits intoagentscope-ai:mainfrom
alex-spacemit:add-github-copilot-fixed

Conversation

@alex-spacemit
Copy link
Copy Markdown

  • Implemented GitHubCopilotProvider to support device authorization and OAuth login.
  • Enhanced ProviderInfo model to include authentication state and OAuth support fields.
  • Added tests for GitHubCopilotProvider covering device authorization, polling, and model fetching.
  • Updated ProviderManager to register GitHub Copilot as a built-in provider.
  • Created compatibility tests for OpenAIResponsesChatModelCompat to ensure proper functionality with GitHub Copilot.

Description

[Describe what this PR does and why]

Related Issue: Fixes #(issue_number) or Relates to #(issue_number)

Security Considerations: [If applicable, e.g. channel auth, env/config handling]

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation
  • Refactoring

Component(s) Affected

  • Core / Backend (app, agents, config, providers, utils, local_models)
  • Console (frontend web UI)
  • Channels (DingTalk, Feishu, QQ, Discord, iMessage, etc.)
  • Skills
  • CLI
  • Documentation (website)
  • Tests
  • CI/CD
  • Scripts / Deploy

Checklist

  • I ran pre-commit run --all-files locally and it passes
  • If pre-commit auto-fixed files, I committed those changes and reran checks
  • I ran tests locally (pytest or as relevant) and they pass
  • Documentation updated (if needed)
  • Ready for review

Testing

Screenshot_20260326231549 Screenshot_20260326231637

Copilot AI review requested due to automatic review settings March 26, 2026 15:37
@github-project-automation github-project-automation Bot moved this to Todo in QwenPaw Mar 26, 2026
@github-actions github-actions Bot added the first-time-contributor PR created by a first time contributor label Mar 26, 2026
@github-actions
Copy link
Copy Markdown

Welcome to CoPaw! 🐾

Hi @alex-spacemit, thank you for your first Pull Request! 🎉

🙌 Join Developer Community

Thanks so much for your contribution! We'd love to invite you to join the official CoPaw developer group! You can find the Discord and DingTalk group links under the "Developer Community" section on our docs page:
https://copaw.agentscope.io/docs/community

We truly appreciate your enthusiasm—and look forward to your future contributions! 😊

We'll review your PR soon.


Tip

⭐ If you find CoPaw useful, please give us a Star!

Star CoPaw

Staying ahead

Star CoPaw on GitHub and be instantly notified of new releases.

Your star helps more developers discover this project! 🐾

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements support for GitHub Copilot by introducing a new provider that utilizes the GitHub device authorization flow. Key additions include a compatibility wrapper for the OpenAI Responses API, backend endpoints for session management, and frontend components to handle the authentication UI and polling. The review feedback identifies opportunities to improve the robustness of the React polling effect by optimizing its dependencies and suggests implementing a cleanup mechanism for expired authorization sessions in the backend to avoid potential memory leaks.

cancelled = true;
window.clearTimeout(timer);
};
}, [authSession, isOauthProvider, onSaved, open, provider.id, t]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The onSaved function is included in the useEffect dependency array. If onSaved is not wrapped in useCallback in the parent component, it will have a new reference on each render, causing this effect to re-run unnecessarily. This would clear and reset the polling timer, potentially delaying or interrupting the authentication flow.

To make this effect more robust, consider using a ref to hold the latest version of onSaved and removing it from the dependency array. This avoids re-triggering the effect when the function reference changes.

Example of how to apply this pattern:

const onSavedRef = useRef(onSaved);
useEffect(() => {
  onSavedRef.current = onSaved;
}, [onSaved]);

useEffect(() => {
  // ... inside your polling logic
  await onSavedRef.current();
  // ...
}, [authSession, isOauthProvider, open, provider.id, t]); // onSaved is removed

self,
timeout: float = 10,
) -> DeviceAuthorizationSession:
async with httpx.AsyncClient(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To prevent potential memory leaks from abandoned device authorization sessions, it's a good practice to clean up expired sessions from _device_sessions before creating a new one. A user might start the authorization process but never complete it, leaving an expired session in memory until the server restarts.

        # Clean up expired sessions to prevent memory leaks from abandoned flows.
        now = int(time.time())
        expired_ids = [
            sid for sid, s in self._device_sessions.items() if s.expires_at <= now
        ]
        for sid in expired_ids:
            self._device_sessions.pop(sid, None)

        async with httpx.AsyncClient(

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds first-class GitHub Copilot support as a built-in provider, including GitHub device-flow OAuth login and a Responses-API-based chat model wrapper to work with Copilot’s newer model endpoints.

Changes:

  • Introduces GitHubCopilotProvider with device authorization flow, token refresh, dynamic base URL derivation, and Responses API model usage.
  • Adds OpenAIResponsesChatModelCompat to route chat requests through /responses and normalize tool schemas/stream parsing.
  • Extends provider API/UI to support OAuth login state (start/poll/logout) and display authenticated status; adds unit tests for provider + compat wrapper.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/unit/providers/test_provider_manager.py Verifies ProviderManager dispatches github-copilot configs to GitHubCopilotProvider.
tests/unit/providers/test_openai_responses_chat_model_compat.py Covers streaming/text/tool-call behavior and tool schema normalization for the Responses wrapper.
tests/unit/providers/test_github_copilot_provider.py Tests device auth flow, token refresh/base-url derivation, model fetch, and Responses-based model checks.
src/copaw/providers/provider_manager.py Registers GitHub Copilot as a built-in provider and enables deserialization dispatch.
src/copaw/providers/provider.py Extends ProviderInfo with OAuth/auth-session state fields and returns them from get_info().
src/copaw/providers/openai_responses_chat_model_compat.py New Responses-API compatibility model for AgentScope chat usage.
src/copaw/providers/github_copilot_provider.py New provider implementation with device login, token exchange, dynamic base URL, and Responses API model instance.
src/copaw/app/routers/providers.py Adds REST endpoints for device auth start/poll and logout for GitHub Copilot providers.
console/src/pages/Settings/Models/components/sections/ModelsSection.tsx Treats OAuth providers as “configured” only when authenticated.
console/src/pages/Settings/Models/components/modals/ProviderConfigModal.tsx Adds OAuth login UI (device flow) and integrates start/poll/logout API calls.
console/src/pages/Settings/Models/components/cards/RemoteProviderCard.tsx Displays OAuth authorization state instead of API key for OAuth providers.
console/src/locales/en.json Adds GitHub device-flow UI strings.
console/src/locales/zh.json Adds GitHub device-flow UI strings.
console/src/locales/ru.json Adds GitHub device-flow UI strings.
console/src/locales/ja.json Adds GitHub device-flow UI strings.
console/src/api/types/provider.ts Adds OAuth/auth state fields to ProviderInfo and defines device-auth response types.
console/src/api/modules/provider.ts Adds API methods for device auth start/poll and logout.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +277 to +283
provider = _get_github_copilot_provider(manager, provider_id)
status, message = await provider.poll_device_authorization(session_id)
manager.update_provider(provider_id, {})
provider_info = None
if status == "authorized":
provider_info = await manager.get_provider_info(provider_id)
return DeviceAuthPollResponse(
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

poll_provider_device_auth persists provider state on every poll via manager.update_provider(provider_id, {}), which writes the provider JSON to disk even when the status is still pending. With the UI polling every few seconds this can create unnecessary disk I/O; consider only persisting when state changes (e.g., on authorized / terminal statuses) or when any provider fields were actually updated.

Copilot uses AI. Check for mistakes.
Comment on lines +411 to +452
const timer = window.setTimeout(async () => {
setAuthPolling(true);
try {
const result = await api.pollDeviceAuth(provider.id, authSession.session_id);
if (cancelled) {
return;
}
if (result.status === "authorized") {
setAuthSession(null);
setAuthPolling(false);
try {
await api.discoverModels(provider.id);
} catch {
// Ignore model discovery failure here; auth already succeeded.
}
await onSaved();
message.success(result.message || t("models.githubAuthSuccess"));
return;
}
if (result.status === "pending") {
setAuthPolling(false);
return;
}
setAuthSession(null);
setAuthPolling(false);
message.warning(result.message || t("models.githubAuthFailed"));
} catch (error) {
if (!cancelled) {
setAuthPolling(false);
setAuthSession(null);
message.error(
error instanceof Error
? error.message
: t("models.githubAuthFailed"),
);
}
}
}, Math.max(authSession.interval, 2) * 1000);

return () => {
cancelled = true;
window.clearTimeout(timer);
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The device-auth polling effect only schedules a single setTimeout. When the server returns pending, the code clears authPolling and returns without scheduling the next poll, and the effect won’t re-run because authSession hasn’t changed. This will stop polling after the first attempt; consider using a repeating timer (setInterval) or re-scheduling another timeout while status === "pending" (and respecting interval/slow_down).

Suggested change
const timer = window.setTimeout(async () => {
setAuthPolling(true);
try {
const result = await api.pollDeviceAuth(provider.id, authSession.session_id);
if (cancelled) {
return;
}
if (result.status === "authorized") {
setAuthSession(null);
setAuthPolling(false);
try {
await api.discoverModels(provider.id);
} catch {
// Ignore model discovery failure here; auth already succeeded.
}
await onSaved();
message.success(result.message || t("models.githubAuthSuccess"));
return;
}
if (result.status === "pending") {
setAuthPolling(false);
return;
}
setAuthSession(null);
setAuthPolling(false);
message.warning(result.message || t("models.githubAuthFailed"));
} catch (error) {
if (!cancelled) {
setAuthPolling(false);
setAuthSession(null);
message.error(
error instanceof Error
? error.message
: t("models.githubAuthFailed"),
);
}
}
}, Math.max(authSession.interval, 2) * 1000);
return () => {
cancelled = true;
window.clearTimeout(timer);
let timer: number | undefined;
const schedulePoll = (intervalSeconds: number) => {
const delay = Math.max(intervalSeconds, 2) * 1000;
timer = window.setTimeout(async () => {
if (cancelled) {
return;
}
setAuthPolling(true);
try {
const result = await api.pollDeviceAuth(
provider.id,
authSession.session_id,
);
if (cancelled) {
return;
}
if (result.status === "authorized") {
setAuthSession(null);
setAuthPolling(false);
try {
await api.discoverModels(provider.id);
} catch {
// Ignore model discovery failure here; auth already succeeded.
}
await onSaved();
message.success(result.message || t("models.githubAuthSuccess"));
return;
}
if (result.status === "pending") {
setAuthPolling(false);
// Respect server-provided interval/slow_down if available.
let nextInterval =
typeof result.interval === "number"
? result.interval
: intervalSeconds;
if ((result as any).slow_down) {
nextInterval += 5;
}
if (!cancelled) {
schedulePoll(nextInterval);
}
return;
}
setAuthSession(null);
setAuthPolling(false);
message.warning(result.message || t("models.githubAuthFailed"));
} catch (error) {
if (!cancelled) {
setAuthPolling(false);
setAuthSession(null);
message.error(
error instanceof Error
? error.message
: t("models.githubAuthFailed"),
);
}
}
}, delay);
};
schedulePoll(authSession.interval);
return () => {
cancelled = true;
if (typeof timer === "number") {
window.clearTimeout(timer);
}

Copilot uses AI. Check for mistakes.
Comment thread src/copaw/app/routers/providers.py Outdated
) -> DeviceAuthStartResponse:
provider = _get_github_copilot_provider(manager, provider_id)
session = await provider.start_device_authorization()
manager.update_provider(provider_id, {})
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

start_provider_device_auth calls manager.update_provider(provider_id, {}) immediately after starting the device flow, but start_device_authorization() only updates the in-memory _device_sessions (a PrivateAttr that won’t be persisted anyway). This write appears redundant and causes an extra disk write; consider removing it or persisting only meaningful state.

Suggested change
manager.update_provider(provider_id, {})

Copilot uses AI. Check for mistakes.
Comment on lines +277 to +283
provider = _get_github_copilot_provider(manager, provider_id)
status, message = await provider.poll_device_authorization(session_id)
manager.update_provider(provider_id, {})
provider_info = None
if status == "authorized":
provider_info = await manager.get_provider_info(provider_id)
return DeviceAuthPollResponse(
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These endpoints call manager.update_provider(provider_id, {}) after mutating the in-memory GitHubCopilotProvider. This will persist all pydantic fields (including github_oauth_token / copilot_access_token) to disk via provider.model_dump(). Currently, builtin providers are only rehydrated with api_key, extra_models, and generate_kwargs in ProviderManager._init_from_storage, so the persisted OAuth/session fields won’t be restored after restart—while still writing long-lived secrets to disk. Consider either (a) explicitly excluding OAuth/session fields from persistence, or (b) updating builtin-provider rehydration to restore the needed auth fields (and then limiting writes to state changes).

Copilot uses AI. Check for mistakes.
- Implemented GitHubCopilotProvider to support device authorization and OAuth login.
- Enhanced ProviderInfo model to include authentication state and OAuth support fields.
- Added tests for GitHubCopilotProvider covering device authorization, polling, and model fetching.
- Updated ProviderManager to register GitHub Copilot as a built-in provider.
- Created compatibility tests for OpenAIResponsesChatModelCompat to ensure proper functionality with GitHub Copilot.
Copilot AI review requested due to automatic review settings March 28, 2026 16:07
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


import json
from datetime import datetime
from types import SimpleNamespace
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SimpleNamespace is imported but not used in this module. Please remove the unused import to keep linting clean.

Suggested change
from types import SimpleNamespace

Copilot uses AI. Check for mistakes.

import httpx
from openai import APIError, AsyncOpenAI
from pydantic import BaseModel, Field, PrivateAttr
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Field is imported from pydantic but not used in this file. Please remove the unused import.

Suggested change
from pydantic import BaseModel, Field, PrivateAttr
from pydantic import BaseModel, PrivateAttr

Copilot uses AI. Check for mistakes.
Comment on lines +49 to +56
class GitHubCopilotProvider(OpenAIProvider):
"""OpenAI-compatible provider backed by GitHub Copilot."""

supports_oauth_login: bool = True
auth_method: str | None = "github-device"
is_authenticated: bool = False
auth_account_label: str | None = None
auth_expires_at: int | None = None
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GitHubCopilotProvider inherits OpenAIProvider.probe_model_multimodal(), which probes via /chat/completions. For Copilot you’re explicitly routing chat traffic through /responses, so the inherited probe is likely to fail and then persist supports_image=False/supports_video=False for discovered models (since fetch_models() returns ModelInfo with supports_multimodal=None, triggering the auto-probe in ProviderManager.maybe_probe_multimodal). Consider overriding probe_model_multimodal() for this provider (or disabling auto-probe for it) so Copilot models aren’t incorrectly marked as non-multimodal due to an endpoint mismatch.

Copilot uses AI. Check for mistakes.
Comment on lines +640 to 642
{provider.support_connection_check &&
(!isOauthProvider || provider.is_authenticated) && (
<Button
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The footer correctly hides the “Test connection” button until an OAuth provider is authenticated, but handleSubmit() still performs a connection test whenever provider.support_connection_check is true. For OAuth providers this can prevent saving non-auth settings (e.g. generate_kwargs) before login, because /models/{id}/test will fail with “GitHub authorization required”. Consider aligning handleSubmit() with this same (!isOauthProvider || provider.is_authenticated) gating (or otherwise skipping the connection test when saving config for unauthenticated OAuth providers).

Copilot uses AI. Check for mistakes.
Comment on lines +336 to +339
return {
**schema,
"anyOf": [schema, {"type": "null"}],
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_make_optional_schema_nullable() builds an invalid schema for oneOf: it adds an anyOf field that contains the entire schema dict (including the existing oneOf) rather than appending {"type":"null"} to the oneOf variants. This can produce schemas with both oneOf and anyOf and may even self-reference, which can break tool schema validation downstream. Consider converting oneOf -> anyOf by copying the oneOf list (plus {type:null}) and removing the original oneOf key (or otherwise ensuring no recursion / mixed combinators).

Suggested change
return {
**schema,
"anyOf": [schema, {"type": "null"}],
}
# Convert oneOf variants to anyOf and append a null variant,
# removing the original oneOf key to avoid mixed combinators.
new_schema = {**schema}
new_schema.pop("oneOf", None)
new_schema["anyOf"] = [*one_of, {"type": "null"}]
return new_schema

Copilot uses AI. Check for mistakes.
@moarychan
Copy link
Copy Markdown

👍,我还没时间测试。另一个小问题:是否考虑了支持凭证序列化,这样重启服务之后不需要再做一次认证

@BrandonStudio
Copy link
Copy Markdown

It seems this implementation is based on reverse-engineered internal APIs. Is there any risk of triggering anti-abuse systems or resulting in account restriction? Should we consider using official Copilot SDK instead?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

first-time-contributor PR created by a first time contributor

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

5 participants