Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions code_puppy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ def _get_xdg_dir(env_var: str, fallback: str) -> str:
CHATGPT_MODELS_FILE = os.path.join(DATA_DIR, "chatgpt_models.json")
CLAUDE_MODELS_FILE = os.path.join(DATA_DIR, "claude_models.json")
ANTIGRAVITY_MODELS_FILE = os.path.join(DATA_DIR, "antigravity_models.json")
GITHUB_MODELS_FILE = os.path.join(DATA_DIR, "github_models.json")

# Cache files (XDG_CACHE_HOME)
AUTOSAVE_DIR = os.path.join(CACHE_DIR, "autosaves")
Expand Down
2 changes: 2 additions & 0 deletions code_puppy/model_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ def load_config() -> Dict[str, Any]:
CHATGPT_MODELS_FILE,
CLAUDE_MODELS_FILE,
GEMINI_MODELS_FILE,
GITHUB_MODELS_FILE,
)

# Build list of extra model sources
Expand All @@ -321,6 +322,7 @@ def load_config() -> Dict[str, Any]:
(pathlib.Path(CLAUDE_MODELS_FILE), "Claude Code OAuth models", True),
(pathlib.Path(GEMINI_MODELS_FILE), "Gemini OAuth models", False),
(pathlib.Path(ANTIGRAVITY_MODELS_FILE), "Antigravity OAuth models", False),
(pathlib.Path(GITHUB_MODELS_FILE), "GitHub Models OAuth models", False),
]

for source_path, label, use_filtered in extra_sources:
Expand Down
Empty file.
61 changes: 61 additions & 0 deletions code_puppy/plugins/github_models_oauth/config.py
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"
175 changes: 175 additions & 0 deletions code_puppy/plugins/github_models_oauth/device_flow.py
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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
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

Comment thread
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)

Comment thread
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
Loading