feat: add ChatGPT Subscription provider (#2876)

* 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>
This commit is contained in:
stocky789
2026-06-08 18:19:18 +10:00
committed by GitHub
parent ac94885c84
commit 1e0d9b92af
37 changed files with 3425 additions and 485 deletions
+22 -17
View File
@@ -92,18 +92,13 @@ def _reject_compact_during_active_run(session_id: str) -> None:
def _verify_session_owner(request: Request, session_id: str, session_manager=None):
"""Verify the current user owns the session. Raises 404 if not.
"""Verify the current user owns the session, honoring single-user modes.
Ownership is checked against the DB row when one exists (unchanged). If
there is no DB row but the caller owns an in-memory "ghost" session — one
that lives only in ``session_manager`` because it was never persisted, or
its DB row was removed out-of-band — fall back to the in-memory owner so the
user can still manage and delete it. Without this fallback such sessions are
listed by ``/api/sessions`` (they come from the in-memory manager) yet every
per-session operation 404s, making them impossible to delete (issue #1044).
``session_manager`` is optional and defaults to ``None`` so existing callers
that only care about persisted sessions keep their exact prior behavior.
Authenticated requests must match the stored DB or in-memory owner. When
auth is disabled and no user is present, treat the app as single-user mode:
verify that the session exists, but do not compare its stored owner. This
keeps QA/dev instances with AUTH_ENABLED=false from rejecting owner-stamped
rows created while auth was previously enabled.
"""
user = effective_user(request)
if not user and not _auth_disabled():
@@ -114,13 +109,13 @@ def _verify_session_owner(request: Request, session_id: str, session_manager=Non
finally:
db.close()
if row is not None:
if row.owner != user:
if user and row.owner != user:
raise HTTPException(404, f"Session {session_id} not found")
return
# No DB row — allow the caller to act on an in-memory ghost they own.
if session_manager is not None:
ghost = getattr(session_manager, "sessions", {}).get(session_id)
if ghost is not None and getattr(ghost, "owner", None) == user:
if ghost is not None and (not user or getattr(ghost, "owner", None) == user):
return
raise HTTPException(404, f"Session {session_id} not found")
@@ -372,8 +367,13 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
pass
elif not model_to_use:
from src.llm_core import list_model_ids
ids = list_model_ids(endpoint_url, timeout=REQUEST_TIMEOUT,
headers=validation_headers)
ids = list_model_ids(
endpoint_url,
timeout=REQUEST_TIMEOUT,
headers=validation_headers,
owner=user,
endpoint_id=endpoint_id.strip() if endpoint_id else None,
)
if not ids:
raise HTTPException(400, "Cannot reach /v1/models")
# Default to the first CHAT model — endpoints often list embedding/
@@ -387,8 +387,13 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
from src.llm_core import list_model_ids
import os as _os
req_base = _os.path.basename(model_to_use.rstrip("/"))
avail = list_model_ids(endpoint_url, timeout=REQUEST_TIMEOUT,
headers=validation_headers)
avail = list_model_ids(
endpoint_url,
timeout=REQUEST_TIMEOUT,
headers=validation_headers,
owner=user,
endpoint_id=endpoint_id.strip() if endpoint_id else None,
)
if not avail:
raise HTTPException(400, "Cannot reach /v1/models")
if model_to_use not in avail: