mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-16 17:55:26 -04:00
1e0d9b92af
* feat: Add ChatGPT Subscription support and related features - Introduced a new provider option for ChatGPT Subscription in the endpoint selection UI. - Implemented OAuth flow for ChatGPT Subscription sign-in, including polling for authorization status. - Updated admin interface to handle ChatGPT Subscription, including disabling API key input and providing user guidance. - Enhanced cost tracking logic to differentiate between subscription and non-subscription endpoints. - Added new slash commands for managing skills, including listing, searching, and invoking skills. - Implemented caching for skill catalog to optimize performance. - Updated tests to cover new ChatGPT Subscription functionality and ensure proper endpoint probing. - Refactored existing code to accommodate new features and improve maintainability. * refactor: share provider device-flow setup - reuse one device-flow backend for Copilot and ChatGPT Subscription - add one frontend device-flow helper for Settings and /setup - put GitHub Copilot back into Add Models, now as a dropdown option - make provider selection just select; clicking Add starts sign-in - stop ChatGPT Subscription setup from opening auth tabs automatically - make /setup copilot and /setup chatgpt-subscription work from chat - show ChatGPT Subscription in the /setup suggestions - show the real error message when setup fails - add focused tests for the shared flow and setup UI * feat(chatgpt-subscription): harden credential lifecycle and streamline auth UX Backend: - Resolve runtime bearer for provider-auth endpoints at probe time via a shared _resolve_probe_key() that delegates to resolve_endpoint_runtime, applied across all probe/refresh call sites. - Skip live completion probes and health pings for discovery-only providers (centralized behind _is_discovery_only_provider) — the Codex/Responses API has no such endpoints, so status is derived from cached models. - Never persist the short lived ChatGPT bearer to the plaintext sessions table; proactively clear any stale bearer left by an earlier code path. - Revoke orphaned ProviderAuthSession credentials when the last endpoint backing them is deleted (_delete_orphaned_provider_auth), surfaced via cleared_provider_auth in the delete response. Frontend (admin.js): - Auto-start the device-auth flow on provider selection so the authorization panel (code + Authorize) shows immediately instead of behind a "Sign in" click. - Remove the redundant top button for device auth providers, move retry into the panel via an inline "Try again". - Drop the self-evident hint text and add an execCommand clipboard fallback so Copy works in non-secure (HTTP/LAN) contexts. * fix: harden chatgpt subscription provider * chore: remove PR media from branch * Fix chatgpt subscription recovery and token handling --------- Co-authored-by: 5p00kyy <admin@5p00ky.dev>
174 lines
6.2 KiB
Python
174 lines
6.2 KiB
Python
# routes/copilot_routes.py
|
|
"""GitHub Copilot device-flow login.
|
|
|
|
Drives the GitHub OAuth *device flow* and, on success, creates (or refreshes)
|
|
an owner-scoped ``ModelEndpoint`` pointing at the Copilot API with the
|
|
device-flow access token stored as its (encrypted) ``api_key``. After that the
|
|
endpoint behaves like any other OpenAI-compatible provider — the Copilot-
|
|
specific request headers are injected centrally by ``build_headers`` /
|
|
``_provider_headers`` (see :mod:`src.copilot`).
|
|
|
|
Flow:
|
|
1. ``POST /api/copilot/device/start`` → returns a ``poll_id`` plus the
|
|
``user_code`` + ``verification_uri`` to show the user. The secret
|
|
``device_code`` is kept server-side, never sent to the browser.
|
|
2. The browser polls ``POST /api/copilot/device/poll`` with ``poll_id``.
|
|
While pending it returns ``{status: "pending"}``; once the user authorises
|
|
it provisions the endpoint and returns ``{status: "authorized", ...}``.
|
|
|
|
All routes are admin-gated (endpoint/provider management is an admin action).
|
|
"""
|
|
|
|
import json
|
|
import uuid
|
|
import logging
|
|
from typing import Dict, Optional
|
|
|
|
import httpx
|
|
from fastapi import HTTPException, Request
|
|
|
|
from core.database import SessionLocal, ModelEndpoint
|
|
from routes.device_flow import (
|
|
DeviceFlowPoll,
|
|
DeviceFlowStart,
|
|
PendingDeviceFlowStore,
|
|
create_device_flow_router,
|
|
)
|
|
from src.auth_helpers import get_current_user
|
|
from src import copilot
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_DEVICE_FLOW_STORE = PendingDeviceFlowStore()
|
|
|
|
|
|
def _provision_endpoint(token: str, base: str, owner: Optional[str]) -> Dict:
|
|
"""Create or update the owner's Copilot endpoint with a fresh token."""
|
|
try:
|
|
models = copilot.fetch_models(base, token)
|
|
except Exception as e:
|
|
logger.warning(f"Copilot model fetch failed during provisioning: {e}")
|
|
models = []
|
|
model_ids = [m["id"] for m in models]
|
|
# Copilot picker models support OpenAI-style tool calling; mark the endpoint
|
|
# tool-capable so the agent loop sends native tool schemas.
|
|
# Tool-capable if any picker model advertises tool_calls. When the model
|
|
# fetch failed (empty list) default to True, since Copilot picker models
|
|
# support OpenAI-style tool calling.
|
|
supports_tools = bool(not models or any(m.get("tool_calls") for m in models))
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
ep = (
|
|
db.query(ModelEndpoint)
|
|
.filter(ModelEndpoint.base_url == base)
|
|
.filter((ModelEndpoint.owner.is_(None)) | (ModelEndpoint.owner == owner))
|
|
.order_by(ModelEndpoint.owner.desc())
|
|
.first()
|
|
)
|
|
if ep is None:
|
|
ep = ModelEndpoint(
|
|
id=str(uuid.uuid4())[:8],
|
|
name="GitHub Copilot",
|
|
base_url=base,
|
|
model_type="llm",
|
|
owner=owner,
|
|
)
|
|
db.add(ep)
|
|
ep.api_key = token
|
|
ep.is_enabled = True
|
|
ep.supports_tools = supports_tools
|
|
if model_ids:
|
|
ep.cached_models = json.dumps(model_ids)
|
|
db.commit()
|
|
result = {
|
|
"id": ep.id,
|
|
"name": ep.name,
|
|
"base_url": ep.base_url,
|
|
"models": model_ids,
|
|
}
|
|
finally:
|
|
db.close()
|
|
|
|
# Best-effort: refresh the model cache so the new endpoint shows up.
|
|
try:
|
|
from routes.model_routes import _invalidate_models_cache
|
|
_invalidate_models_cache()
|
|
except Exception:
|
|
pass
|
|
return result
|
|
|
|
|
|
def _start_device_flow(request: Request, form) -> DeviceFlowStart:
|
|
host = copilot.GITHUB_HOST
|
|
ent = str(form.get("enterprise_url") or "").strip()
|
|
if ent:
|
|
host = copilot.normalize_domain(ent)
|
|
try:
|
|
data = copilot.request_device_code(host)
|
|
except httpx.HTTPStatusError as e:
|
|
status = e.response.status_code if e.response is not None else "unknown"
|
|
raise HTTPException(502, f"GitHub device-code request failed (HTTP {status})")
|
|
except Exception as e:
|
|
raise HTTPException(502, f"GitHub device-code request failed: {e}")
|
|
|
|
device_code = data.get("device_code")
|
|
if not device_code:
|
|
raise HTTPException(502, "GitHub did not return a device code")
|
|
|
|
# verification_uri_complete embeds the user code, so the browser tab we
|
|
# open lands the user straight on GitHub's "Authorize" screen with the
|
|
# code pre-filled — one click, no manual code entry.
|
|
return DeviceFlowStart(
|
|
pending={
|
|
"device_code": device_code,
|
|
"host": host,
|
|
"enterprise_url": ent,
|
|
"owner": get_current_user(request) or None,
|
|
},
|
|
response={
|
|
"user_code": data.get("user_code"),
|
|
"verification_uri": data.get("verification_uri"),
|
|
"verification_uri_complete": data.get("verification_uri_complete"),
|
|
},
|
|
interval=int(data.get("interval") or 5),
|
|
expires_in=int(data.get("expires_in") or 900),
|
|
)
|
|
|
|
|
|
def _poll_device_flow(_request: Request, pending: Dict) -> DeviceFlowPoll:
|
|
try:
|
|
data = copilot.poll_access_token(pending["host"], pending["device_code"])
|
|
except Exception as e:
|
|
return DeviceFlowPoll.pending(f"poll error: {e}")
|
|
|
|
token = data.get("access_token")
|
|
if token:
|
|
base = copilot.enterprise_base(pending["enterprise_url"]) if pending["enterprise_url"] else copilot.COPILOT_BASE
|
|
try:
|
|
result = _provision_endpoint(token, base, pending["owner"])
|
|
except Exception as e:
|
|
logger.exception("Copilot endpoint provisioning failed")
|
|
raise HTTPException(500, f"Login succeeded but provisioning failed: {e}")
|
|
return DeviceFlowPoll.authorized(result)
|
|
|
|
err = data.get("error")
|
|
if err == "authorization_pending":
|
|
return DeviceFlowPoll.pending()
|
|
if err == "slow_down":
|
|
return DeviceFlowPoll.slow_down(int(data.get("interval") or 0) or None)
|
|
if err in ("expired_token", "access_denied"):
|
|
return DeviceFlowPoll.failed(err)
|
|
# Unknown error — surface but keep the session for another try.
|
|
return DeviceFlowPoll.pending(err or "unknown")
|
|
|
|
|
|
def setup_copilot_routes():
|
|
return create_device_flow_router(
|
|
prefix="/api/copilot",
|
|
tags=["copilot"],
|
|
store=_DEVICE_FLOW_STORE,
|
|
start_flow=_start_device_flow,
|
|
poll_flow=_poll_device_flow,
|
|
)
|