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
+92 -23
View File
@@ -25,32 +25,36 @@ from unittest.mock import MagicMock
import httpx
import pytest
from tests.helpers.import_state import clear_fake_endpoint_resolver_modules
from tests.helpers.import_state import clear_fake_endpoint_resolver_modules, preserve_import_state
# Match test_model_routes.py: if another test stubbed src.endpoint_resolver
# during collection, drop the stub so the real URL helpers load here.
clear_fake_endpoint_resolver_modules()
with preserve_import_state("core.database", "src.database", "core.session_manager", "routes.model_routes"):
# Match test_model_routes.py: if another test stubbed src.endpoint_resolver
# during collection, drop the stub so the real URL helpers load here.
clear_fake_endpoint_resolver_modules()
if "core.database" not in sys.modules:
_core_db = types.ModuleType("core.database")
for _name in [
"SessionLocal", "ModelEndpoint", "Session", "ChatMessage", "Document",
"DocumentVersion", "GalleryImage", "GalleryAlbum", "Note",
"CalendarCal", "CalendarEvent", "ScheduledTask", "TaskRun", "McpServer",
]:
setattr(_core_db, _name, MagicMock())
sys.modules["core.database"] = _core_db
if "core.database" not in sys.modules:
_core_db = types.ModuleType("core.database")
for _name in [
"SessionLocal", "ModelEndpoint", "Session", "ChatMessage", "Document",
"DocumentVersion", "GalleryImage", "GalleryAlbum", "Note",
"CalendarCal", "CalendarEvent", "ScheduledTask", "TaskRun", "McpServer",
"ProviderAuthSession", "Base",
]:
setattr(_core_db, _name, MagicMock())
_core_db.utcnow_naive = MagicMock()
sys.modules["core.database"] = _core_db
import routes.model_routes as model_routes
import src.endpoint_resolver as endpoint_resolver
from routes.model_routes import (
_probe_endpoint,
_ping_endpoint,
_probe_single_model,
_classify_endpoint,
_rewrite_loopback_for_docker,
_PROVIDER_CURATED,
)
import routes.model_routes as model_routes
import src.endpoint_resolver as endpoint_resolver
from routes.model_routes import (
_probe_endpoint,
_ping_endpoint,
_probe_single_model,
_resolve_probe_key,
_classify_endpoint,
_rewrite_loopback_for_docker,
_PROVIDER_CURATED,
)
def _patch_resolve(monkeypatch):
@@ -117,6 +121,26 @@ class TestProbeEndpointParsing:
)
assert _probe_endpoint("https://api.example.com/v1") == []
def test_chatgpt_subscription_probe_uses_discovery_only(self, monkeypatch):
_patch_resolve(monkeypatch)
calls = []
def fake_fetch(access_token, timeout=5):
calls.append((access_token, timeout))
return ["gpt-5.5"]
monkeypatch.setattr("src.chatgpt_subscription.fetch_available_models", fake_fetch)
assert _probe_endpoint("https://chatgpt.com/backend-api/codex", "ACCESS", timeout=7) == ["gpt-5.5"]
assert calls == [("ACCESS", 7)]
def test_chatgpt_subscription_probe_without_discovery_returns_empty(self, monkeypatch):
_patch_resolve(monkeypatch)
monkeypatch.setattr("src.chatgpt_subscription.fetch_available_models", lambda access_token, timeout=5: [])
assert _probe_endpoint("https://chatgpt.com/backend-api/codex", "ACCESS") == []
assert _probe_endpoint("https://chatgpt.com/backend-api/codex") == []
# ── _ping_endpoint: reachability classification ──
@@ -321,6 +345,51 @@ class TestProbeSingleModel:
_probe_single_model("https://api.anthropic.com/v1", "sk-ant", "claude-sonnet-4-5", with_tools=True)
assert "input_schema" in captured["payload"]["tools"][0]
def test_chatgpt_subscription_skips_completion_probe(self, monkeypatch):
# This provider speaks the Responses/Codex API. A chat-completions probe
# would 400 and (via the re-probe flow) hide every model, so it must be
# short-circuited as discovery-only without any HTTP call.
_patch_resolve(monkeypatch)
def boom(*args, **kwargs):
raise AssertionError("must not send a completion probe for chatgpt-subscription")
monkeypatch.setattr(model_routes.httpx, "post", boom)
result = _probe_single_model("https://chatgpt.com/backend-api/codex", None, "gpt-5.1-codex")
assert result["status"] == "ok"
assert result.get("skipped") is True
# Pin the full documented return shape — downstream JSON/UI reads latency_ms.
assert result["latency_ms"] == 0
# ── _resolve_probe_key: static key vs provider-auth runtime token ──
class TestResolveProbeKey:
def test_static_endpoint_uses_api_key(self):
ep = types.SimpleNamespace(id="e1", api_key="sk-static", provider_auth_id=None, owner=None)
assert _resolve_probe_key(ep) == "sk-static"
def test_provider_auth_endpoint_resolves_runtime_token(self, monkeypatch):
ep = types.SimpleNamespace(id="e2", api_key=None, provider_auth_id="auth123", owner="alice")
seen = {}
def fake_runtime(endpoint, owner=None):
seen["owner"] = owner
return ("https://chatgpt.com/backend-api/codex", "live-bearer")
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint_runtime", fake_runtime)
assert _resolve_probe_key(ep) == "live-bearer"
assert seen["owner"] == "alice"
def test_provider_auth_resolution_failure_returns_none(self, monkeypatch):
ep = types.SimpleNamespace(id="e3", api_key=None, provider_auth_id="auth123", owner=None)
def boom(endpoint, owner=None):
raise RuntimeError("reauth required")
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint_runtime", boom)
assert _resolve_probe_key(ep) is None
# ── _classify_endpoint: Tailscale CGNAT range ──