mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-15 17:25: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>
194 lines
6.4 KiB
Python
194 lines
6.4 KiB
Python
"""Shared OAuth/device-flow route scaffolding for provider setup."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from typing import Any, Callable, Iterable, Mapping, Optional
|
|
|
|
from fastapi import APIRouter, Form, HTTPException, Request
|
|
|
|
from core.middleware import require_admin
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DeviceFlowStart:
|
|
"""Provider-specific start result consumed by the shared route wrapper."""
|
|
|
|
pending: Mapping[str, Any]
|
|
response: Mapping[str, Any]
|
|
interval: int = 5
|
|
expires_in: int = 900
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DeviceFlowPoll:
|
|
"""Normalized provider poll outcome."""
|
|
|
|
status: str
|
|
endpoint: Optional[Mapping[str, Any]] = None
|
|
error: Optional[str] = None
|
|
detail: Optional[str] = None
|
|
interval: Optional[int] = None
|
|
|
|
@classmethod
|
|
def pending(cls, detail: Optional[str] = None) -> "DeviceFlowPoll":
|
|
return cls(status="pending", detail=detail)
|
|
|
|
@classmethod
|
|
def slow_down(cls, interval: Optional[int] = None, detail: Optional[str] = None) -> "DeviceFlowPoll":
|
|
return cls(status="slow_down", interval=interval, detail=detail)
|
|
|
|
@classmethod
|
|
def authorized(cls, endpoint: Mapping[str, Any]) -> "DeviceFlowPoll":
|
|
return cls(status="authorized", endpoint=endpoint)
|
|
|
|
@classmethod
|
|
def failed(cls, error: str) -> "DeviceFlowPoll":
|
|
return cls(status="failed", error=error)
|
|
|
|
|
|
class PendingDeviceFlowStore:
|
|
"""Thread-safe in-memory pending device-flow store.
|
|
|
|
Device codes and provider-side secrets stay inside this process. Each entry
|
|
stores provider payload separately from poll metadata so provider callbacks
|
|
only receive the fields they created.
|
|
"""
|
|
|
|
def __init__(self, *, time_func: Callable[[], float] = time.time):
|
|
self._pending: dict[str, dict[str, Any]] = {}
|
|
self._lock = threading.Lock()
|
|
self._time = time_func
|
|
|
|
def _now(self) -> float:
|
|
return float(self._time())
|
|
|
|
def prune_expired(self) -> None:
|
|
now = self._now()
|
|
with self._lock:
|
|
for key in [k for k, v in self._pending.items() if v.get("expires_at", 0) < now]:
|
|
self._pending.pop(key, None)
|
|
|
|
def add(self, payload: Mapping[str, Any], *, interval: int, expires_in: int) -> str:
|
|
self.prune_expired()
|
|
poll_id = uuid.uuid4().hex
|
|
with self._lock:
|
|
self._pending[poll_id] = {
|
|
"payload": dict(payload),
|
|
"interval": max(int(interval or 5), 1),
|
|
"expires_at": self._now() + max(int(expires_in or 900), 1),
|
|
"next_poll_at": 0.0,
|
|
}
|
|
return poll_id
|
|
|
|
def get_payload(self, poll_id: str) -> Optional[dict[str, Any]]:
|
|
self.prune_expired()
|
|
with self._lock:
|
|
entry = self._pending.get(poll_id)
|
|
if entry is None:
|
|
return None
|
|
return dict(entry.get("payload") or {})
|
|
|
|
def is_throttled(self, poll_id: str) -> bool:
|
|
with self._lock:
|
|
entry = self._pending.get(poll_id)
|
|
return bool(entry and self._now() < float(entry.get("next_poll_at") or 0))
|
|
|
|
def schedule_next(self, poll_id: str) -> None:
|
|
now = self._now()
|
|
with self._lock:
|
|
entry = self._pending.get(poll_id)
|
|
if entry is not None:
|
|
entry["next_poll_at"] = now + int(entry.get("interval") or 5)
|
|
|
|
def slow_down(self, poll_id: str, interval: Optional[int] = None) -> None:
|
|
now = self._now()
|
|
with self._lock:
|
|
entry = self._pending.get(poll_id)
|
|
if entry is not None:
|
|
new_interval = int(interval or (int(entry.get("interval") or 5) + 5))
|
|
entry["interval"] = max(new_interval, 1)
|
|
entry["next_poll_at"] = now + entry["interval"]
|
|
|
|
def pop(self, poll_id: str) -> None:
|
|
with self._lock:
|
|
self._pending.pop(poll_id, None)
|
|
|
|
|
|
async def _maybe_await(value: Any) -> Any:
|
|
if inspect.isawaitable(value):
|
|
return await value
|
|
return value
|
|
|
|
|
|
def _pending_response(detail: Optional[str] = None) -> dict[str, Any]:
|
|
response: dict[str, Any] = {"status": "pending"}
|
|
if detail:
|
|
response["detail"] = detail
|
|
return response
|
|
|
|
|
|
def create_device_flow_router(
|
|
*,
|
|
prefix: str,
|
|
tags: Iterable[str],
|
|
store: PendingDeviceFlowStore,
|
|
start_flow: Callable[[Request, Mapping[str, Any]], DeviceFlowStart],
|
|
poll_flow: Callable[[Request, Mapping[str, Any]], DeviceFlowPoll],
|
|
) -> APIRouter:
|
|
"""Create standard `/device/start|poll|cancel` routes for a provider."""
|
|
|
|
router = APIRouter(prefix=prefix, tags=list(tags))
|
|
|
|
@router.post("/device/start")
|
|
async def device_start(request: Request):
|
|
require_admin(request)
|
|
form = await request.form()
|
|
start = await _maybe_await(start_flow(request, form))
|
|
interval = int(start.interval or 5)
|
|
expires_in = int(start.expires_in or 900)
|
|
poll_id = store.add(start.pending, interval=interval, expires_in=expires_in)
|
|
response = dict(start.response)
|
|
response.update({"poll_id": poll_id, "interval": interval, "expires_in": expires_in})
|
|
return response
|
|
|
|
@router.post("/device/poll")
|
|
async def device_poll(request: Request, poll_id: str = Form(...)):
|
|
require_admin(request)
|
|
payload = store.get_payload(poll_id)
|
|
if payload is None:
|
|
raise HTTPException(404, "Unknown or expired login session")
|
|
if store.is_throttled(poll_id):
|
|
return {"status": "pending"}
|
|
|
|
try:
|
|
outcome = await _maybe_await(poll_flow(request, payload))
|
|
except Exception:
|
|
store.pop(poll_id)
|
|
raise
|
|
|
|
if outcome.status == "authorized":
|
|
store.pop(poll_id)
|
|
return {"status": "authorized", "endpoint": dict(outcome.endpoint or {})}
|
|
if outcome.status == "failed":
|
|
store.pop(poll_id)
|
|
return {"status": "failed", "error": outcome.error or "denied"}
|
|
if outcome.status == "slow_down":
|
|
store.slow_down(poll_id, outcome.interval)
|
|
return _pending_response(outcome.detail)
|
|
|
|
store.schedule_next(poll_id)
|
|
return _pending_response(outcome.detail)
|
|
|
|
@router.post("/device/cancel")
|
|
def device_cancel(request: Request, poll_id: str = Form(...)):
|
|
require_admin(request)
|
|
store.pop(poll_id)
|
|
return {"status": "cancelled"}
|
|
|
|
return router
|