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:
+92
-42
@@ -11,49 +11,51 @@ from types import SimpleNamespace
|
||||
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
|
||||
|
||||
# Other tests stub this module during collection. These helper tests need
|
||||
# the real URL normalization helpers so Anthropic /v1 handling is covered.
|
||||
clear_fake_endpoint_resolver_modules()
|
||||
with preserve_import_state("core.database", "src.database", "core.session_manager", "routes.model_routes"):
|
||||
# Other tests stub this module during collection. These helper tests need
|
||||
# the real URL normalization helpers so Anthropic /v1 handling is covered.
|
||||
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.database as src_database
|
||||
import src.endpoint_resolver as endpoint_resolver
|
||||
import src.llm_core as llm_core
|
||||
from routes.model_routes import (
|
||||
_match_provider_curated,
|
||||
_curate_models,
|
||||
_visible_models,
|
||||
_normalize_model_ids,
|
||||
_api_key_fingerprint,
|
||||
_is_chat_model,
|
||||
_classify_endpoint,
|
||||
_effective_endpoint_kind,
|
||||
_probe_endpoint,
|
||||
_ping_endpoint,
|
||||
_parse_model_list,
|
||||
_normalize_refresh_mode,
|
||||
_truthy,
|
||||
_speech_settings_using_endpoint,
|
||||
_clear_speech_settings_for_endpoint,
|
||||
_endpoint_settings_using_endpoint,
|
||||
_clear_endpoint_settings_for_endpoint,
|
||||
_clear_user_pref_endpoint_refs,
|
||||
_PROVIDER_CURATED,
|
||||
)
|
||||
from src.llm_core import ANTHROPIC_MODELS
|
||||
import routes.model_routes as model_routes
|
||||
import src.database as src_database
|
||||
import src.endpoint_resolver as endpoint_resolver
|
||||
import src.llm_core as llm_core
|
||||
from routes.model_routes import (
|
||||
_match_provider_curated,
|
||||
_curate_models,
|
||||
_visible_models,
|
||||
_normalize_model_ids,
|
||||
_api_key_fingerprint,
|
||||
_is_chat_model,
|
||||
_classify_endpoint,
|
||||
_effective_endpoint_kind,
|
||||
_probe_endpoint,
|
||||
_ping_endpoint,
|
||||
_parse_model_list,
|
||||
_normalize_refresh_mode,
|
||||
_truthy,
|
||||
_speech_settings_using_endpoint,
|
||||
_clear_speech_settings_for_endpoint,
|
||||
_endpoint_settings_using_endpoint,
|
||||
_clear_endpoint_settings_for_endpoint,
|
||||
_clear_user_pref_endpoint_refs,
|
||||
_PROVIDER_CURATED,
|
||||
)
|
||||
from src.llm_core import ANTHROPIC_MODELS
|
||||
|
||||
|
||||
# ── speech endpoint settings ──
|
||||
@@ -687,8 +689,7 @@ class _PinnedFakeRequest:
|
||||
|
||||
|
||||
def _get_route(path, method):
|
||||
from routes.model_routes import setup_model_routes
|
||||
router = setup_model_routes(model_discovery=None)
|
||||
router = model_routes.setup_model_routes(model_discovery=None)
|
||||
for route in router.routes:
|
||||
if getattr(route, "path", "") == path and method in getattr(route, "methods", set()):
|
||||
return route.endpoint
|
||||
@@ -787,6 +788,55 @@ def test_reprobe_preserves_pinned_models(monkeypatch):
|
||||
assert json.loads(ep.cached_models) == ["m1"]
|
||||
|
||||
|
||||
def test_reprobe_chatgpt_subscription_does_not_hide_models(monkeypatch):
|
||||
# The whole point of the _probe_single_model short-circuit is that re-probing
|
||||
# a chatgpt-subscription endpoint must NOT mark every (un-probeable) model as
|
||||
# failed and write them all into hidden_models. Assert that end-to-end at the
|
||||
# route level, with the REAL _probe_single_model doing the skip.
|
||||
ep = _make_endpoint(
|
||||
base_url="https://chatgpt.com/backend-api/codex",
|
||||
api_key=None,
|
||||
hidden_models=json.dumps(["stale-hidden"]),
|
||||
)
|
||||
db = _PinnedFakeDb([ep])
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||
monkeypatch.setattr(model_routes, "_normalize_base", lambda url: url.rstrip("/"))
|
||||
monkeypatch.setattr(model_routes, "_probe_endpoint", lambda *a, **k: ["gpt-5.1-codex", "gpt-5.1"])
|
||||
monkeypatch.setattr(model_routes, "_is_chat_model", lambda m: True)
|
||||
# Any completion probe would be a bug for this provider.
|
||||
monkeypatch.setattr(
|
||||
model_routes.httpx, "post",
|
||||
lambda *a, **k: (_ for _ in ()).throw(AssertionError("must not probe chatgpt-subscription")),
|
||||
)
|
||||
endpoint = _get_route("/api/model-endpoints/{ep_id}/probe", "GET")
|
||||
|
||||
response = endpoint("ep1", _PinnedFakeRequest())
|
||||
chunks = []
|
||||
|
||||
async def _drain():
|
||||
async for chunk in response.body_iterator:
|
||||
chunks.append(chunk.decode() if isinstance(chunk, bytes) else chunk)
|
||||
|
||||
asyncio.run(_drain())
|
||||
|
||||
events = []
|
||||
for chunk in chunks:
|
||||
for line in chunk.splitlines():
|
||||
if line.startswith("data: "):
|
||||
events.append(json.loads(line[len("data: "):]))
|
||||
|
||||
done = next(e for e in events if e.get("type") == "probe_done")
|
||||
results = [e for e in events if e.get("type") == "probe_result"]
|
||||
|
||||
# Every model was skipped as ok; none failed → nothing hidden.
|
||||
assert done["hidden"] == 0
|
||||
assert done["ok"] == len(results) == 2
|
||||
assert all(r["status"] == "ok" and r.get("skipped") is True for r in results)
|
||||
# The stale hidden_models is cleared, not repopulated with every model.
|
||||
assert ep.hidden_models is None
|
||||
|
||||
|
||||
def test_visible_models_handles_malformed_strings():
|
||||
# Non-JSON cached/pinned strings are treated as comma/newline lists and
|
||||
# never raise; a malformed hidden string is normalized too.
|
||||
|
||||
Reference in New Issue
Block a user