-
Notifications
You must be signed in to change notification settings - Fork 155
Added GH Auth for use with GH and Copilot models. Uses GH cli first, … #251
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
judggernaut
wants to merge
3
commits into
mpfaffenberger:main
Choose a base branch
from
judggernaut:judggernaut/github_oauth
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 2 commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| """Configuration for the GitHub Models OAuth plugin.""" | ||
|
|
||
| from pathlib import Path | ||
| from typing import Any, Dict | ||
|
|
||
| from code_puppy import config | ||
|
|
||
| # GitHub Models OAuth configuration | ||
| GITHUB_MODELS_OAUTH_CONFIG: Dict[str, Any] = { | ||
| # GitHub OAuth Device Flow endpoints | ||
| "device_code_url": "https://github.com/login/device/code", | ||
| "access_token_url": "https://github.com/login/oauth/access_token", | ||
| "user_api_url": "https://api.github.com/user", | ||
| # GitHub Models Inference API (OpenAI-compatible) | ||
| "api_base_url": "https://models.github.ai/inference", | ||
| "api_version": "2026-03-10", | ||
| # GitHub Copilot API (OpenAI-compatible, has Claude/Gemini) | ||
| "copilot_api_base_url": "https://api.githubcopilot.com", | ||
| "copilot_integration_id": "vscode-chat", | ||
| "copilot_prefix": "copilot-", | ||
| # OAuth configuration — client_id from a registered GitHub OAuth App | ||
| # with device flow enabled. Set via GITHUB_MODELS_CLIENT_ID env var. | ||
| # No default — users must register their own app or use `gh` CLI auth. | ||
| "default_client_id": "", | ||
| "client_id_env_var": "GITHUB_MODELS_CLIENT_ID", | ||
| "scope": "read:user", | ||
| # Device flow polling | ||
| "poll_timeout": 900, # 15 minutes (matches GitHub's device_code expiry) | ||
| # Model configuration | ||
| "prefix": "github-", | ||
| "default_context_length": 200000, | ||
| # User-Agent for API calls | ||
| "user_agent": "code-puppy/github-models-oauth", | ||
| } | ||
|
|
||
|
|
||
| def get_client_id() -> str: | ||
| """Get the GitHub OAuth App client ID. | ||
|
|
||
| Checks the environment variable first, then falls back to the default. | ||
| """ | ||
| import os | ||
|
|
||
| return os.environ.get( | ||
| GITHUB_MODELS_OAUTH_CONFIG["client_id_env_var"], | ||
| GITHUB_MODELS_OAUTH_CONFIG["default_client_id"], | ||
| ) | ||
|
|
||
|
|
||
| def get_token_storage_path() -> Path: | ||
| """Get the path for storing GitHub OAuth tokens.""" | ||
| data_dir = Path(config.DATA_DIR) | ||
| data_dir.mkdir(parents=True, exist_ok=True, mode=0o700) | ||
| return data_dir / "github_models_oauth.json" | ||
|
|
||
|
|
||
| def get_github_models_path() -> Path: | ||
| """Get the path to the github_models.json model config file.""" | ||
| data_dir = Path(config.DATA_DIR) | ||
| data_dir.mkdir(parents=True, exist_ok=True, mode=0o700) | ||
| return data_dir / "github_models.json" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| """GitHub OAuth device flow implementation. | ||
|
|
||
| Follows the GitHub device flow (RFC 8628) as used by copilot-sdk. | ||
| No local HTTP server required — the user visits github.com/login/device | ||
| and enters a short code. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| import time | ||
| from dataclasses import dataclass | ||
| from typing import Optional | ||
|
|
||
| import requests | ||
|
|
||
| from code_puppy.messaging import emit_error, emit_info, emit_success, emit_warning | ||
|
|
||
| from .config import GITHUB_MODELS_OAUTH_CONFIG, get_client_id | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| @dataclass | ||
| class DeviceFlowResponse: | ||
| """Response from GitHub's device code endpoint.""" | ||
|
|
||
| device_code: str | ||
| user_code: str | ||
| verification_uri: str | ||
| expires_in: int | ||
| interval: int | ||
|
|
||
|
|
||
| def start_device_flow() -> Optional[DeviceFlowResponse]: | ||
| """Initiate the GitHub OAuth device flow. | ||
|
|
||
| POST to ``github.com/login/device/code`` to obtain a device code and | ||
| user code that the user enters at ``github.com/login/device``. | ||
| """ | ||
| client_id = get_client_id() | ||
| url = GITHUB_MODELS_OAUTH_CONFIG["device_code_url"] | ||
| scope = GITHUB_MODELS_OAUTH_CONFIG["scope"] | ||
|
|
||
| try: | ||
| response = requests.post( | ||
| url, | ||
| data={"client_id": client_id, "scope": scope}, | ||
| headers={ | ||
| "Accept": "application/json", | ||
| }, | ||
| timeout=30, | ||
| ) | ||
| response.raise_for_status() | ||
| data = response.json() | ||
|
|
||
| return DeviceFlowResponse( | ||
| device_code=data["device_code"], | ||
| user_code=data["user_code"], | ||
| verification_uri=data["verification_uri"], | ||
| expires_in=int(data.get("expires_in", 900)), | ||
| interval=int(data.get("interval", 5)), | ||
| ) | ||
| except requests.RequestException as exc: | ||
| logger.error("Failed to start device flow: %s", exc) | ||
| emit_error(f"Failed to start GitHub device flow: {exc}") | ||
| return None | ||
| except (KeyError, ValueError) as exc: | ||
| logger.error("Unexpected device flow response: %s", exc) | ||
| emit_error(f"Unexpected response from GitHub: {exc}") | ||
| return None | ||
|
|
||
|
|
||
| def poll_for_access_token(device_code: str, interval: int) -> Optional[str]: | ||
| """Poll GitHub for an access token after the user authorises. | ||
|
|
||
| Handles ``authorization_pending`` (keep trying), ``slow_down`` | ||
| (increase interval), and ``expired_token`` (give up). | ||
| """ | ||
| client_id = get_client_id() | ||
| url = GITHUB_MODELS_OAUTH_CONFIG["access_token_url"] | ||
| timeout = GITHUB_MODELS_OAUTH_CONFIG["poll_timeout"] | ||
|
|
||
| delay = interval | ||
| elapsed = 0.0 | ||
|
|
||
| while elapsed < timeout: | ||
| time.sleep(delay) | ||
| elapsed += delay | ||
|
|
||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| try: | ||
| response = requests.post( | ||
| url, | ||
| data={ | ||
| "client_id": client_id, | ||
| "device_code": device_code, | ||
| "grant_type": "urn:ietf:params:oauth:grant-type:device_code", | ||
| }, | ||
| headers={ | ||
| "Accept": "application/json", | ||
| }, | ||
| timeout=30, | ||
| ) | ||
| data = response.json() | ||
| except requests.RequestException as exc: | ||
| logger.warning("Token poll request failed: %s", exc) | ||
| continue | ||
| except ValueError: | ||
| logger.warning("Token poll returned non-JSON response") | ||
| continue | ||
|
|
||
| # Success | ||
| if data.get("access_token"): | ||
| return data["access_token"] | ||
|
|
||
| error = data.get("error", "") | ||
|
|
||
| if error == "authorization_pending": | ||
| continue | ||
|
|
||
| if error == "slow_down": | ||
| delay = int(data.get("interval", delay + 5)) | ||
| continue | ||
|
|
||
| if error == "expired_token": | ||
| logger.warning("Device code expired before user authorized") | ||
| return None | ||
|
|
||
| # Unknown error — abort | ||
| desc = data.get("error_description", error) | ||
| logger.error("OAuth polling error: %s", desc) | ||
| emit_error(f"GitHub OAuth error: {desc}") | ||
| return None | ||
|
|
||
| logger.warning("Device flow polling timed out after %ds", timeout) | ||
| return None | ||
|
|
||
|
|
||
| def run_device_flow() -> Optional[str]: | ||
| """Run the full GitHub OAuth device flow. | ||
|
|
||
| Returns the access token on success, or ``None`` on failure. | ||
| """ | ||
| emit_info("🔐 Starting GitHub OAuth device flow…") | ||
|
|
||
| device = start_device_flow() | ||
| if not device: | ||
| return None | ||
|
|
||
| emit_info(f"\n📋 Open: {device.verification_uri}") | ||
| emit_info(f"📋 Enter code: {device.user_code}\n") | ||
|
|
||
| # Try to open the browser automatically | ||
| try: | ||
| import webbrowser | ||
|
|
||
| from code_puppy.tools.common import should_suppress_browser | ||
|
|
||
| if should_suppress_browser(): | ||
| emit_info(f"[HEADLESS MODE] Would normally open: {device.verification_uri}") | ||
| else: | ||
| webbrowser.open(device.verification_uri) | ||
| except Exception as exc: # noqa: BLE001 | ||
| logger.debug("Could not open browser: %s", exc) | ||
|
|
||
| emit_info("⏳ Waiting for authorization (press Ctrl+C to cancel)…") | ||
|
|
||
| token = poll_for_access_token(device.device_code, device.interval) | ||
|
|
||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| if token: | ||
| emit_success("✅ GitHub authentication successful!") | ||
| return token | ||
|
|
||
| emit_error("❌ GitHub authentication failed or timed out.") | ||
| return None | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.