mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-17 10:15:27 -04:00
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:
@@ -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 ──
|
||||
|
||||
|
||||
Reference in New Issue
Block a user