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>
216 lines
7.7 KiB
Python
216 lines
7.7 KiB
Python
"""resolve_session_auth must not persist the ChatGPT Subscription bearer.
|
|
|
|
The ChatGPT Subscription access token is a short-lived OAuth bearer re-resolved
|
|
(and refreshed) on every request. resolve_session_auth() may set it on the
|
|
in-memory session for the current request, but it must never write it back into
|
|
the sessions table — otherwise the live token sits at rest as
|
|
"Authorization: Bearer ...". Only the encrypted refresh token in
|
|
ProviderAuthSession is allowed to persist.
|
|
"""
|
|
|
|
import types
|
|
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
import routes.chat_helpers as chat_helpers
|
|
import src.endpoint_resolver as endpoint_resolver
|
|
from core.database import Base, ModelEndpoint, Session as DbSession
|
|
|
|
_CODEX_BASE = "https://chatgpt.com/backend-api/codex"
|
|
|
|
|
|
def _mem_db(monkeypatch):
|
|
engine = create_engine("sqlite:///:memory:")
|
|
Base.metadata.create_all(bind=engine)
|
|
# Match production SessionLocal (core.database) which is autoflush=False.
|
|
TestSessionLocal = sessionmaker(bind=engine, autoflush=False)
|
|
monkeypatch.setattr(chat_helpers, "SessionLocal", TestSessionLocal)
|
|
return TestSessionLocal
|
|
|
|
|
|
def test_chatgpt_subscription_auth_is_not_written_to_sessions_table(monkeypatch):
|
|
TestSessionLocal = _mem_db(monkeypatch)
|
|
db = TestSessionLocal()
|
|
try:
|
|
db.add(ModelEndpoint(
|
|
id="ep1", name="ChatGPT Subscription", base_url=_CODEX_BASE,
|
|
provider_auth_id="auth1", owner="alice", is_enabled=True, api_key=None,
|
|
))
|
|
db.add(DbSession(
|
|
id="sess1", name="chat", endpoint_url=_CODEX_BASE,
|
|
model="gpt-5.1-codex", owner="alice", headers={},
|
|
))
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
|
|
# A live access token is resolved at request time.
|
|
monkeypatch.setattr(
|
|
endpoint_resolver, "resolve_endpoint_runtime",
|
|
lambda ep, owner=None: (_CODEX_BASE, "live-access-token"),
|
|
)
|
|
|
|
sess = types.SimpleNamespace(
|
|
id="sess1", endpoint_url=_CODEX_BASE, model="gpt-5.1-codex",
|
|
owner="alice", headers={},
|
|
)
|
|
chat_helpers.resolve_session_auth(sess, "sess1", owner="alice")
|
|
|
|
# In-memory session got request-local auth for this request...
|
|
assert any(k.lower() == "authorization" for k in sess.headers)
|
|
assert sess.headers["Authorization"] == "Bearer live-access-token"
|
|
|
|
# ...but the DB row must NOT have the bearer persisted.
|
|
db = TestSessionLocal()
|
|
try:
|
|
row = db.query(DbSession).filter(DbSession.id == "sess1").first()
|
|
stored = row.headers or {}
|
|
assert not any(k.lower() == "authorization" for k in stored), (
|
|
f"ChatGPT bearer leaked into sessions table: {stored}"
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_non_subscription_auth_is_still_persisted_to_sessions_table(monkeypatch):
|
|
"""The early-return must be scoped to ChatGPT Subscription only.
|
|
|
|
Ordinary endpoints rely on resolve_session_auth() persisting the resolved
|
|
headers into the sessions table so they aren't re-resolved on every request.
|
|
If the is_chatgpt_subscription guard ever widened, this would silently break;
|
|
this test pins the persistence path as still reached for normal endpoints.
|
|
"""
|
|
base = "https://api.example.com/v1"
|
|
TestSessionLocal = _mem_db(monkeypatch)
|
|
db = TestSessionLocal()
|
|
try:
|
|
db.add(ModelEndpoint(
|
|
id="ep1", name="Generic", base_url=base,
|
|
owner="alice", is_enabled=True, api_key="sk-static",
|
|
))
|
|
db.add(DbSession(
|
|
id="sess1", name="chat", endpoint_url=base,
|
|
model="gpt-x", owner="alice", headers={},
|
|
))
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
|
|
monkeypatch.setattr(
|
|
endpoint_resolver, "resolve_endpoint_runtime",
|
|
lambda ep, owner=None: (base, "sk-static"),
|
|
)
|
|
|
|
sess = types.SimpleNamespace(
|
|
id="sess1", endpoint_url=base, model="gpt-x", owner="alice", headers={},
|
|
)
|
|
chat_helpers.resolve_session_auth(sess, "sess1", owner="alice")
|
|
|
|
# In-memory session got auth...
|
|
assert any(k.lower() in ("authorization", "x-api-key") for k in sess.headers)
|
|
|
|
# ...AND it was persisted to the DB row (the normal, non-subscription path).
|
|
db = TestSessionLocal()
|
|
try:
|
|
row = db.query(DbSession).filter(DbSession.id == "sess1").first()
|
|
stored = row.headers or {}
|
|
assert any(k.lower() in ("authorization", "x-api-key") for k in stored), (
|
|
f"non-subscription auth was not persisted: {stored}"
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_chatgpt_subscription_clears_previously_persisted_bearer(monkeypatch):
|
|
"""A bearer left at rest by an older code path is stripped on next resolve."""
|
|
TestSessionLocal = _mem_db(monkeypatch)
|
|
db = TestSessionLocal()
|
|
try:
|
|
db.add(ModelEndpoint(
|
|
id="ep1", name="ChatGPT Subscription", base_url=_CODEX_BASE,
|
|
provider_auth_id="auth1", owner="alice", is_enabled=True, api_key=None,
|
|
))
|
|
# Simulate the leak: a stale bearer already sitting in the sessions table.
|
|
db.add(DbSession(
|
|
id="sess1", name="chat", endpoint_url=_CODEX_BASE,
|
|
model="gpt-5.1-codex", owner="alice",
|
|
headers={"Authorization": "Bearer stale-leaked-token"},
|
|
))
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
|
|
monkeypatch.setattr(
|
|
endpoint_resolver,
|
|
"resolve_endpoint_runtime",
|
|
lambda ep, owner=None: (_CODEX_BASE, "live-access-token"),
|
|
)
|
|
|
|
sess = types.SimpleNamespace(
|
|
id="sess1", endpoint_url=_CODEX_BASE, model="gpt-5.1-codex",
|
|
owner="alice", headers={},
|
|
)
|
|
chat_helpers.resolve_session_auth(sess, "sess1", owner="alice")
|
|
|
|
# The stale bearer must have been stripped from the DB row.
|
|
db = TestSessionLocal()
|
|
try:
|
|
row = db.query(DbSession).filter(DbSession.id == "sess1").first()
|
|
stored = row.headers or {}
|
|
assert not any(k.lower() == "authorization" for k in stored), (
|
|
f"stale ChatGPT bearer was not cleared: {stored}"
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def test_chatgpt_subscription_fallback_auth_is_not_written_to_sessions_table(monkeypatch):
|
|
"""Fallback endpoint selection must keep the resolved bearer request-local."""
|
|
TestSessionLocal = _mem_db(monkeypatch)
|
|
db = TestSessionLocal()
|
|
try:
|
|
db.add(ModelEndpoint(
|
|
id="ep1", name="ChatGPT Subscription", base_url=_CODEX_BASE,
|
|
provider_auth_id="auth1", owner="alice", is_enabled=True, api_key=None,
|
|
cached_models='["gpt-5.1-codex"]',
|
|
))
|
|
db.add(DbSession(
|
|
id="sess1", name="chat", endpoint_url="https://old.example/v1",
|
|
model="old-model", owner="alice", headers={},
|
|
))
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
|
|
monkeypatch.setattr(
|
|
endpoint_resolver,
|
|
"resolve_endpoint_runtime",
|
|
lambda ep, owner=None: (_CODEX_BASE, "live-access-token"),
|
|
)
|
|
|
|
sess = types.SimpleNamespace(
|
|
id="sess1", endpoint_url="https://old.example/v1", model="old-model",
|
|
owner="alice", headers={},
|
|
)
|
|
result = chat_helpers.try_fallback_endpoint(sess, "sess1")
|
|
|
|
assert result == {
|
|
"model": "gpt-5.1-codex",
|
|
"endpoint_url": _CODEX_BASE + "/responses",
|
|
"endpoint_name": "ChatGPT Subscription",
|
|
}
|
|
assert sess.headers["Authorization"] == "Bearer live-access-token"
|
|
|
|
db = TestSessionLocal()
|
|
try:
|
|
row = db.query(DbSession).filter(DbSession.id == "sess1").first()
|
|
assert row.model == "gpt-5.1-codex"
|
|
assert row.endpoint_url == _CODEX_BASE + "/responses"
|
|
stored = row.headers or {}
|
|
assert not any(k.lower() == "authorization" for k in stored), (
|
|
f"ChatGPT fallback bearer leaked into sessions table: {stored}"
|
|
)
|
|
finally:
|
|
db.close()
|