mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-30 17:52:26 +01:00
feat(provider): support multiple ChatGPT subscriptions with usage
This commit is contained in:
+84
-34
@@ -794,6 +794,52 @@ def _session_url_matches_endpoint(session_url: str, endpoint_base: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _endpoint_created_sort_key(ep) -> tuple:
|
||||
created = getattr(ep, "created_at", None)
|
||||
try:
|
||||
ts = float(created.timestamp()) if created else 0.0
|
||||
except Exception:
|
||||
ts = 0.0
|
||||
return (ts, str(getattr(ep, "id", "") or ""))
|
||||
|
||||
|
||||
def _select_session_endpoint(sess, target_url: str, endpoints) -> tuple:
|
||||
"""Pick the endpoint a session should use for credential resolution.
|
||||
|
||||
Two endpoints may share one provider URL but not credentials (e.g. two
|
||||
ChatGPT Subscription accounts), so an explicit ``sess.endpoint_id`` binding
|
||||
wins whenever it still matches the session URL. Without a binding the
|
||||
oldest URL-matching endpoint is chosen deterministically and persisted.
|
||||
|
||||
Returns ``(endpoint, bound_by_fallback)``; ``bound_by_fallback`` is True
|
||||
when the choice came from URL matching and may be persisted as a binding.
|
||||
"""
|
||||
matching = [ep for ep in endpoints if _session_url_matches_endpoint(target_url, getattr(ep, "base_url", "") or "")]
|
||||
if not matching:
|
||||
return None, False
|
||||
bound_id = getattr(sess, "endpoint_id", None) or None
|
||||
if bound_id:
|
||||
for ep in matching:
|
||||
if str(ep.id) == str(bound_id):
|
||||
sess.endpoint_id = ep.id
|
||||
return ep, False
|
||||
# The bound endpoint is gone or disabled. Never silently borrow another
|
||||
# endpoint's credentials when several routes share this URL.
|
||||
return None, False
|
||||
matching.sort(key=_endpoint_created_sort_key)
|
||||
chosen = matching[0]
|
||||
if len(matching) > 1:
|
||||
logger.warning(
|
||||
"Session %s has no endpoint binding and %d endpoints share its URL; using oldest endpoint %s",
|
||||
getattr(sess, "id", "?"), len(matching), chosen.id,
|
||||
)
|
||||
try:
|
||||
sess.endpoint_id = chosen.id
|
||||
except Exception:
|
||||
pass
|
||||
return chosen, True
|
||||
|
||||
|
||||
def _has_auth_keys(headers) -> bool:
|
||||
"""True if a headers dict carries an Authorization/x-api-key entry."""
|
||||
return isinstance(headers, dict) and any(
|
||||
@@ -803,6 +849,7 @@ def _has_auth_keys(headers) -> bool:
|
||||
|
||||
def resolve_session_auth(sess, session_id: str, owner: Optional[str] = None):
|
||||
"""Ensure session has auth headers — resolve from endpoint DB if missing."""
|
||||
owner = owner or getattr(sess, "owner", None)
|
||||
try:
|
||||
from src.chatgpt_subscription import is_chatgpt_subscription_base
|
||||
is_chatgpt_subscription = is_chatgpt_subscription_base(getattr(sess, "endpoint_url", "") or "")
|
||||
@@ -811,11 +858,22 @@ def resolve_session_auth(sess, session_id: str, owner: Optional[str] = None):
|
||||
has_auth = _has_auth_keys(sess.headers)
|
||||
if has_auth and not is_chatgpt_subscription:
|
||||
return
|
||||
if is_chatgpt_subscription:
|
||||
# Never reuse a stale bearer after deletion, disablement or failed refresh.
|
||||
sess.headers = {}
|
||||
|
||||
try:
|
||||
from src.endpoint_resolver import build_headers, resolve_endpoint_runtime
|
||||
db = SessionLocal()
|
||||
try:
|
||||
stored_q = db.query(DBSession).filter(DBSession.id == session_id)
|
||||
if owner:
|
||||
stored_q = stored_q.filter(DBSession.owner == owner)
|
||||
if is_chatgpt_subscription:
|
||||
stored = stored_q.first()
|
||||
if stored is not None and _has_auth_keys(stored.headers):
|
||||
stored_q.update({"headers": {}})
|
||||
db.commit()
|
||||
target_url = getattr(sess, "endpoint_url", "") or ""
|
||||
if not target_url:
|
||||
return
|
||||
@@ -826,44 +884,36 @@ def resolve_session_auth(sess, session_id: str, owner: Optional[str] = None):
|
||||
# with similar endpoint URLs can borrow each other's API key.
|
||||
from src.auth_helpers import owner_filter
|
||||
q = owner_filter(q, ModelEndpoint, owner)
|
||||
for ep in q.all():
|
||||
if not _session_url_matches_endpoint(target_url, ep.base_url or ""):
|
||||
continue
|
||||
try:
|
||||
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to resolve provider auth for session %s: %s", session_id, e)
|
||||
return
|
||||
if not api_key:
|
||||
# No usable key (e.g. ChatGPT Subscription needs re-auth).
|
||||
return
|
||||
sess.headers = build_headers(api_key, base)
|
||||
if is_chatgpt_subscription:
|
||||
# The bearer is short-lived and re-resolved per request, so it
|
||||
# stays request-local and is never written to the plaintext
|
||||
# sessions.headers column. Proactively strip any bearer an
|
||||
# older code path may have persisted so it does not linger.
|
||||
stale_q = db.query(DBSession).filter(DBSession.id == session_id)
|
||||
if owner:
|
||||
stale_q = stale_q.filter(DBSession.owner == owner)
|
||||
stored = stale_q.first()
|
||||
if stored is not None and _has_auth_keys(stored.headers):
|
||||
stale_q.update({"headers": {}})
|
||||
db.commit()
|
||||
logger.info(f"Cleared persisted ChatGPT Subscription bearer from session {session_id}")
|
||||
logger.debug(f"Resolved request-local ChatGPT Subscription auth for session {session_id}")
|
||||
return
|
||||
update_q = db.query(DBSession).filter(DBSession.id == session_id)
|
||||
if owner:
|
||||
update_q = update_q.filter(DBSession.owner == owner)
|
||||
update_q.update({"headers": sess.headers})
|
||||
db.commit()
|
||||
logger.info(f"Resolved and persisted auth headers for session {session_id} from endpoint {ep.name}")
|
||||
ep, bound_here = _select_session_endpoint(sess, target_url, q.all())
|
||||
if ep is None:
|
||||
return
|
||||
if bound_here:
|
||||
# Bind before authentication, including failed/expired credentials.
|
||||
stored_q.filter(DBSession.endpoint_id == None).update({"endpoint_id": ep.id})
|
||||
db.commit()
|
||||
try:
|
||||
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to resolve provider auth for session %s: %s", session_id, type(e).__name__)
|
||||
return
|
||||
if not api_key:
|
||||
# No usable key (e.g. ChatGPT Subscription needs re-auth).
|
||||
return
|
||||
sess.headers = build_headers(api_key, base)
|
||||
if is_chatgpt_subscription:
|
||||
# Request-local only; persistence was cleaned before resolution.
|
||||
return
|
||||
update_q = db.query(DBSession).filter(DBSession.id == session_id)
|
||||
if owner:
|
||||
update_q = update_q.filter(DBSession.owner == owner)
|
||||
update_q.update({"headers": sess.headers})
|
||||
db.commit()
|
||||
logger.info(f"Resolved and persisted auth headers for session {session_id} from endpoint {ep.name}")
|
||||
return
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to resolve session headers: {e}")
|
||||
logger.warning("Failed to resolve session headers: %s", type(e).__name__)
|
||||
|
||||
|
||||
def _match_cached_model_id(requested: str, models) -> Optional[str]:
|
||||
|
||||
+63
-8
@@ -1701,9 +1701,21 @@ def _clear_orphaned_session_endpoint(sess, owner: str | None = None) -> bool:
|
||||
from src.auth_helpers import owner_filter
|
||||
q = owner_filter(q, ModelEndpoint, owner)
|
||||
endpoints = q.all()
|
||||
bound_id = getattr(sess, "endpoint_id", None)
|
||||
for ep in endpoints:
|
||||
if bound_id and ep.id != bound_id:
|
||||
continue
|
||||
if _session_url_matches_endpoint(sess.endpoint_url or "", ep.base_url or ""):
|
||||
return False
|
||||
if bound_id:
|
||||
# Keep the identity so re-enabling/reconnecting A can recover A.
|
||||
# Returning True stops chat; B must never replace a missing A.
|
||||
sess.headers = {}
|
||||
stored = db.query(DBSession).filter(DBSession.id == sess.id, DBSession.owner == owner).first()
|
||||
if stored is not None:
|
||||
stored.headers = {}
|
||||
db.commit()
|
||||
return True
|
||||
db_session = db.query(DBSession).filter(DBSession.id == sess.id).first()
|
||||
if db_session:
|
||||
db_session.endpoint_url = ""
|
||||
@@ -1803,6 +1815,13 @@ def _first_image_attachment(chat_handler, att_ids: List[str], owner: str | None
|
||||
return None
|
||||
|
||||
|
||||
def _ts_or_zero(value) -> float:
|
||||
try:
|
||||
return float(value.timestamp()) if value else 0.0
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _recover_empty_session_model(sess, session_id: str, owner: str | None = None) -> bool:
|
||||
"""Re-populate sess.model from the matching endpoint's cached models.
|
||||
|
||||
@@ -1835,10 +1854,19 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
|
||||
from src.auth_helpers import owner_filter
|
||||
q = owner_filter(q, ModelEndpoint, owner)
|
||||
endpoints = q.all()
|
||||
for cand in endpoints:
|
||||
if _session_url_matches_endpoint(sess.endpoint_url or "", cand.base_url or ""):
|
||||
ep = cand
|
||||
break
|
||||
# Honour the session's exact endpoint binding first: two endpoints
|
||||
# can share a provider URL (e.g. two ChatGPT Subscription accounts).
|
||||
bound_id = getattr(sess, "endpoint_id", None) or None
|
||||
if bound_id:
|
||||
for cand in endpoints:
|
||||
if cand.id == bound_id and _session_url_matches_endpoint(sess.endpoint_url or "", cand.base_url or ""):
|
||||
ep = cand
|
||||
break
|
||||
if ep is None and not bound_id:
|
||||
for cand in sorted(endpoints, key=lambda row: (_ts_or_zero(getattr(row, "created_at", None)), str(row.id))):
|
||||
if _session_url_matches_endpoint(sess.endpoint_url or "", cand.base_url or ""):
|
||||
ep = cand
|
||||
break
|
||||
if not ep:
|
||||
return False
|
||||
if not is_chatgpt_subscription:
|
||||
@@ -1938,6 +1966,7 @@ def _reconcile_selected_route_from_request(
|
||||
|
||||
endpoint_url = ""
|
||||
headers = None
|
||||
resolved_endpoint_id = None
|
||||
if selected_endpoint_id or selected_endpoint_url:
|
||||
try:
|
||||
from src.auth_helpers import owner_filter
|
||||
@@ -1949,7 +1978,15 @@ def _reconcile_selected_route_from_request(
|
||||
q = q.filter(ModelEndpoint.id == selected_endpoint_id)
|
||||
if owner:
|
||||
q = owner_filter(q, ModelEndpoint, owner)
|
||||
candidates = q.all() if selected_endpoint_url and not selected_endpoint_id else [q.first()]
|
||||
if selected_endpoint_url and not selected_endpoint_id:
|
||||
candidates = [row for row in q.all() if _session_url_matches_endpoint(selected_endpoint_url, row.base_url or "")]
|
||||
bound_id = getattr(sess, "endpoint_id", None)
|
||||
if bound_id:
|
||||
candidates = [row for row in candidates if row.id == bound_id]
|
||||
if len(candidates) != 1:
|
||||
return False
|
||||
else:
|
||||
candidates = [q.first()]
|
||||
ep = None
|
||||
for cand in candidates:
|
||||
if not cand:
|
||||
@@ -1961,6 +1998,7 @@ def _reconcile_selected_route_from_request(
|
||||
return False
|
||||
endpoint_url = build_chat_url(normalize_base(ep.base_url or ""))
|
||||
headers = build_headers(ep.api_key or "", ep.base_url or "") if ep.api_key else {}
|
||||
resolved_endpoint_id = ep.id
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
@@ -1975,18 +2013,29 @@ def _reconcile_selected_route_from_request(
|
||||
and endpoint_url == (getattr(sess, "endpoint_url", "") or "")
|
||||
)
|
||||
headers_changed = dict(getattr(sess, "headers", None) or {}) != dict(headers or {})
|
||||
if not route_changed and not headers_changed:
|
||||
binding_changed = bool(
|
||||
resolved_endpoint_id
|
||||
and resolved_endpoint_id != (getattr(sess, "endpoint_id", None) or None)
|
||||
)
|
||||
if not route_changed and not headers_changed and not binding_changed:
|
||||
return False
|
||||
|
||||
sess.model = selected_model
|
||||
sess.endpoint_url = endpoint_url
|
||||
sess.headers = headers or {}
|
||||
if resolved_endpoint_id:
|
||||
sess.endpoint_id = resolved_endpoint_id
|
||||
elif route_changed:
|
||||
# The route moved without an explicit endpoint id: drop a stale binding
|
||||
# rather than keep pointing at an endpoint the session no longer uses.
|
||||
sess.endpoint_id = None
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db_session = db.query(DBSession).filter(DBSession.id == session_id).first()
|
||||
if db_session:
|
||||
db_session.model = selected_model
|
||||
db_session.endpoint_url = endpoint_url
|
||||
db_session.endpoint_id = getattr(sess, "endpoint_id", None) or None
|
||||
db_session.headers = sess.headers or {}
|
||||
db_session.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
@@ -2087,6 +2136,10 @@ def setup_chat_routes(
|
||||
if not supports_user_thinking_toggle(sess.model):
|
||||
thinking_mode = "off"
|
||||
owner = effective_user(request)
|
||||
_reconcile_selected_route_from_request(request, sess, session, {
|
||||
"selected_model": sess.model,
|
||||
"selected_endpoint_id": chat_request.selected_endpoint_id,
|
||||
}, owner=owner)
|
||||
if _clear_orphaned_session_endpoint(sess, owner=owner):
|
||||
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
|
||||
|
||||
@@ -2102,6 +2155,8 @@ def setup_chat_routes(
|
||||
if not (getattr(sess, "endpoint_url", "") or "").strip():
|
||||
raise HTTPException(400, "Selected model endpoint is not configured")
|
||||
|
||||
resolve_session_auth(sess, session, owner=owner)
|
||||
|
||||
# Same allowed_models + daily-cap gate as chat_stream (mirror so the
|
||||
# non-streaming path can't be used to bypass).
|
||||
_enforce_chat_privileges(request, sess)
|
||||
@@ -2169,7 +2224,7 @@ def setup_chat_routes(
|
||||
sess.headers,
|
||||
owner=owner,
|
||||
policy=foreground_policy,
|
||||
selected_endpoint_id=chat_request.selected_endpoint_id,
|
||||
selected_endpoint_id=chat_request.selected_endpoint_id or getattr(sess, "endpoint_id", None),
|
||||
)
|
||||
candidate_request_factory = None
|
||||
selected_context_length = getattr(ctx, "context_length", 0)
|
||||
@@ -3704,7 +3759,7 @@ def setup_chat_routes(
|
||||
sess.headers,
|
||||
owner=_user,
|
||||
policy=_foreground_policy,
|
||||
selected_endpoint_id=selected_endpoint_id,
|
||||
selected_endpoint_id=selected_endpoint_id or getattr(sess, "endpoint_id", None),
|
||||
)
|
||||
_chat_request_factory = None
|
||||
_selected_context_length = getattr(ctx, "context_length", 0)
|
||||
|
||||
@@ -1,28 +1,123 @@
|
||||
"""ChatGPT Subscription device-flow setup routes."""
|
||||
"""ChatGPT Subscription device-flow setup, multi-account and usage routes.
|
||||
|
||||
One Odysseus owner may connect several independent ChatGPT subscriptions. Each
|
||||
connection is its own ``ProviderAuthSession`` + ``ModelEndpoint`` pair; the
|
||||
endpoint id decides which account a request is billed to. Labels are cosmetic
|
||||
only — stable ids drive lookup, reconnect, deletion and usage reads.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Dict, Optional
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi import HTTPException, Request, Response
|
||||
|
||||
from core.database import ModelEndpoint, ProviderAuthSession, SessionLocal, utcnow_naive
|
||||
from core.middleware import require_admin
|
||||
from routes.device_flow import (
|
||||
DeviceFlowPoll,
|
||||
DeviceFlowStart,
|
||||
PendingDeviceFlowStore,
|
||||
create_device_flow_router,
|
||||
)
|
||||
from src.auth_helpers import get_current_user
|
||||
from src.auth_helpers import effective_user, get_current_user
|
||||
from src import chatgpt_subscription
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEVICE_FLOW_STORE = PendingDeviceFlowStore()
|
||||
|
||||
_PROVIDER = chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER
|
||||
|
||||
def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict:
|
||||
|
||||
def _owner_scope(query, model_cls, owner: Optional[str]):
|
||||
return query.filter(model_cls.owner == owner)
|
||||
|
||||
|
||||
def _owner_chatgpt_auths(db, owner: Optional[str]):
|
||||
q = db.query(ProviderAuthSession).filter(ProviderAuthSession.provider == _PROVIDER)
|
||||
return _owner_scope(q, ProviderAuthSession, owner).order_by(ProviderAuthSession.created_at).all()
|
||||
|
||||
|
||||
def _endpoints_for_auth(db, auth_id: str):
|
||||
return db.query(ModelEndpoint).filter(ModelEndpoint.provider_auth_id == auth_id).all()
|
||||
|
||||
|
||||
def _display_label(auth, ep) -> str:
|
||||
"""Display label from persisted metadata (never from credentials)."""
|
||||
label = chatgpt_subscription.account_label_from_name(getattr(auth, "label", None))
|
||||
if not label and ep is not None:
|
||||
label = chatgpt_subscription.account_label_from_name(getattr(ep, "name", None))
|
||||
return label
|
||||
|
||||
|
||||
def _assert_label_available(db, owner: Optional[str], label: str, *, exclude_auth_id: Optional[str] = None) -> None:
|
||||
"""Reject a label already used by another ChatGPT account of this owner."""
|
||||
if not label:
|
||||
return
|
||||
for auth in _owner_chatgpt_auths(db, owner):
|
||||
if exclude_auth_id and auth.id == exclude_auth_id:
|
||||
continue
|
||||
existing = _display_label(auth, None)
|
||||
if not existing:
|
||||
for ep in _endpoints_for_auth(db, auth.id):
|
||||
existing = _display_label(auth, ep)
|
||||
if existing:
|
||||
break
|
||||
if chatgpt_subscription.labels_conflict(existing, label):
|
||||
raise ValueError(f"A ChatGPT subscription labelled '{label}' is already connected.")
|
||||
|
||||
|
||||
def _default_new_label(db, owner: Optional[str]) -> str:
|
||||
"""Label for a new connection when the user did not supply one.
|
||||
|
||||
The first account keeps the legacy unlabelled name so existing single
|
||||
account setups look unchanged; later accounts get a distinguishable
|
||||
``account N`` label that is unique for this owner.
|
||||
"""
|
||||
existing = _owner_chatgpt_auths(db, owner)
|
||||
if not existing:
|
||||
return ""
|
||||
taken = set()
|
||||
for auth in existing:
|
||||
label = _display_label(auth, None)
|
||||
if not label:
|
||||
for ep in _endpoints_for_auth(db, auth.id):
|
||||
label = _display_label(auth, ep)
|
||||
if label:
|
||||
break
|
||||
if label:
|
||||
taken.add(label.casefold())
|
||||
n = len(existing) + 1
|
||||
while f"account {n}".casefold() in taken:
|
||||
n += 1
|
||||
return f"account {n}"
|
||||
|
||||
|
||||
def _new_id(db, model_cls) -> str:
|
||||
for _ in range(8):
|
||||
candidate = str(uuid.uuid4())[:8]
|
||||
if db.query(model_cls).filter(model_cls.id == candidate).first() is None:
|
||||
return candidate
|
||||
return uuid.uuid4().hex[:12]
|
||||
|
||||
|
||||
def _provision_endpoint(
|
||||
tokens: Dict,
|
||||
owner: Optional[str],
|
||||
*,
|
||||
label: str = "",
|
||||
reconnect_auth_id: Optional[str] = None,
|
||||
reconnect_endpoint_id: Optional[str] = None,
|
||||
) -> Dict:
|
||||
"""Create a new ChatGPT account (auth + endpoint) or refresh exactly one.
|
||||
|
||||
Without ``reconnect_auth_id`` a brand-new ``ProviderAuthSession`` and
|
||||
``ModelEndpoint`` are created even when the owner already has other ChatGPT
|
||||
subscriptions. With it, only that owner-scoped auth row (and its endpoint)
|
||||
is updated; every other account is left untouched.
|
||||
"""
|
||||
access_token = tokens.get("access_token")
|
||||
refresh_token = tokens.get("refresh_token")
|
||||
if not access_token or not refresh_token:
|
||||
@@ -32,22 +127,28 @@ def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict:
|
||||
models = chatgpt_subscription.fetch_available_models(access_token)
|
||||
if not models:
|
||||
raise ValueError("ChatGPT Subscription connected, but no usable Codex models were discovered for this account.")
|
||||
label = chatgpt_subscription.normalize_account_label(label)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
auth = (
|
||||
db.query(ProviderAuthSession)
|
||||
.filter(
|
||||
ProviderAuthSession.provider == chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER,
|
||||
ProviderAuthSession.owner == owner,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if auth is None:
|
||||
auth = None
|
||||
if reconnect_auth_id:
|
||||
auth = chatgpt_subscription.find_owned_auth_session(db, reconnect_auth_id, owner)
|
||||
if auth is None:
|
||||
raise chatgpt_subscription.ChatGPTSubscriptionAuthNotFound(
|
||||
"The ChatGPT subscription being reconnected no longer exists for this user."
|
||||
)
|
||||
# A reconnect keeps the existing label unless a new one was given.
|
||||
if label:
|
||||
_assert_label_available(db, owner, label, exclude_auth_id=auth.id)
|
||||
else:
|
||||
if not label:
|
||||
label = _default_new_label(db, owner)
|
||||
_assert_label_available(db, owner, label)
|
||||
auth = ProviderAuthSession(
|
||||
id=str(uuid.uuid4())[:8],
|
||||
provider=chatgpt_subscription.CHATGPT_SUBSCRIPTION_PROVIDER,
|
||||
id=_new_id(db, ProviderAuthSession),
|
||||
provider=_PROVIDER,
|
||||
owner=owner,
|
||||
label="ChatGPT Subscription",
|
||||
label=chatgpt_subscription.endpoint_name_for_label(label),
|
||||
base_url=base,
|
||||
auth_mode="chatgpt",
|
||||
)
|
||||
@@ -57,31 +158,39 @@ def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict:
|
||||
auth.refresh_token = refresh_token
|
||||
auth.last_refresh = utcnow_naive()
|
||||
auth.auth_mode = "chatgpt"
|
||||
if label:
|
||||
auth.label = chatgpt_subscription.endpoint_name_for_label(label)
|
||||
|
||||
ep = (
|
||||
db.query(ModelEndpoint)
|
||||
.filter(
|
||||
ModelEndpoint.base_url == base,
|
||||
ModelEndpoint.provider_auth_id == auth.id,
|
||||
ModelEndpoint.owner == owner,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
ep = None
|
||||
if reconnect_auth_id:
|
||||
ep_q = db.query(ModelEndpoint).filter(ModelEndpoint.provider_auth_id == auth.id)
|
||||
ep_q = _owner_scope(ep_q, ModelEndpoint, owner)
|
||||
if reconnect_endpoint_id:
|
||||
ep = ep_q.filter(ModelEndpoint.id == reconnect_endpoint_id).first()
|
||||
if ep is None:
|
||||
raise chatgpt_subscription.ChatGPTSubscriptionAuthNotFound(
|
||||
"The ChatGPT subscription endpoint no longer exists for this user."
|
||||
)
|
||||
else:
|
||||
ep = ep_q.order_by(ModelEndpoint.created_at).first()
|
||||
if ep is None:
|
||||
ep = ModelEndpoint(
|
||||
id=str(uuid.uuid4())[:8],
|
||||
name="ChatGPT Subscription",
|
||||
id=_new_id(db, ModelEndpoint),
|
||||
name=chatgpt_subscription.endpoint_name_for_label(label),
|
||||
base_url=base,
|
||||
model_type="llm",
|
||||
endpoint_kind="api",
|
||||
owner=owner,
|
||||
)
|
||||
db.add(ep)
|
||||
ep.name = "ChatGPT Subscription"
|
||||
if label or not (ep.name or "").strip():
|
||||
ep.name = chatgpt_subscription.endpoint_name_for_label(label)
|
||||
ep.base_url = base
|
||||
ep.api_key = None
|
||||
ep.provider_auth_id = auth.id
|
||||
ep.is_enabled = True
|
||||
# ChatGPT provides inference only. Odysseus is the only agent: no
|
||||
# provider-native tool schemas are ever sent on this route.
|
||||
ep.supports_tools = False
|
||||
ep.model_type = "llm"
|
||||
ep.endpoint_kind = "api"
|
||||
@@ -93,10 +202,14 @@ def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict:
|
||||
"name": ep.name,
|
||||
"base_url": ep.base_url,
|
||||
"models": models,
|
||||
"provider_auth_id": auth.id,
|
||||
"account_label": _display_label(auth, ep),
|
||||
"reconnected": bool(reconnect_auth_id),
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
chatgpt_subscription.USAGE_CACHE.invalidate(result["provider_auth_id"])
|
||||
try:
|
||||
from routes.model_routes import _invalidate_models_cache
|
||||
|
||||
@@ -106,7 +219,53 @@ def _provision_endpoint(tokens: Dict, owner: Optional[str]) -> Dict:
|
||||
return result
|
||||
|
||||
|
||||
def _start_device_flow(request: Request, _form) -> DeviceFlowStart:
|
||||
def _form_value(form, key: str) -> str:
|
||||
try:
|
||||
value = form.get(key) if form is not None else None
|
||||
except Exception:
|
||||
value = None
|
||||
return str(value).strip() if value is not None else ""
|
||||
|
||||
|
||||
def _start_device_flow(request: Request, form) -> DeviceFlowStart:
|
||||
owner = effective_user(request) or None
|
||||
try:
|
||||
label = chatgpt_subscription.normalize_account_label(_form_value(form, "label"))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
reconnect_auth_id = _form_value(form, "reconnect_auth_id") or None
|
||||
reconnect_endpoint_id = _form_value(form, "reconnect_endpoint_id") or None
|
||||
if reconnect_endpoint_id and not reconnect_auth_id:
|
||||
raise HTTPException(400, "Reconnect requires an account id")
|
||||
|
||||
# Validate the intended operation up front so the user is not sent through
|
||||
# OAuth for a request that can never be provisioned.
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if reconnect_auth_id:
|
||||
auth = chatgpt_subscription.find_owned_auth_session(db, reconnect_auth_id, owner)
|
||||
if auth is None:
|
||||
raise HTTPException(404, "ChatGPT subscription account not found")
|
||||
if reconnect_endpoint_id:
|
||||
ep_q = db.query(ModelEndpoint).filter(
|
||||
ModelEndpoint.id == reconnect_endpoint_id,
|
||||
ModelEndpoint.provider_auth_id == auth.id,
|
||||
)
|
||||
if _owner_scope(ep_q, ModelEndpoint, owner).first() is None:
|
||||
raise HTTPException(404, "ChatGPT subscription endpoint not found")
|
||||
if label:
|
||||
try:
|
||||
_assert_label_available(db, owner, label, exclude_auth_id=auth.id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(409, str(exc))
|
||||
else:
|
||||
try:
|
||||
_assert_label_available(db, owner, label)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(409, str(exc))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
try:
|
||||
data = chatgpt_subscription.request_device_code()
|
||||
except Exception as exc:
|
||||
@@ -116,37 +275,82 @@ def _start_device_flow(request: Request, _form) -> DeviceFlowStart:
|
||||
user_code = data.get("user_code")
|
||||
if not device_auth_id or not user_code:
|
||||
raise HTTPException(502, "ChatGPT did not return a complete device code")
|
||||
verification_uri = data.get("verification_uri") or f"{chatgpt_subscription.CHATGPT_OAUTH_ISSUER}/codex/device"
|
||||
# Never pass an arbitrary upstream URL into an authorization link.
|
||||
from urllib.parse import urlsplit
|
||||
fallback_uri = f"{chatgpt_subscription.CHATGPT_OAUTH_ISSUER}/codex/device"
|
||||
verification_uri = data.get("verification_uri") or fallback_uri
|
||||
try:
|
||||
parsed = urlsplit(verification_uri)
|
||||
if parsed.scheme != "https" or parsed.netloc != "auth.openai.com":
|
||||
verification_uri = fallback_uri
|
||||
except (TypeError, ValueError):
|
||||
verification_uri = fallback_uri
|
||||
# The pending payload carries only what provisioning needs: the device
|
||||
# handle, the owner and the intended operation. No access/refresh tokens.
|
||||
pending: Dict[str, Any] = {
|
||||
"device_auth_id": device_auth_id,
|
||||
"user_code": user_code,
|
||||
"owner": owner,
|
||||
"label": label,
|
||||
"reconnect_auth_id": reconnect_auth_id,
|
||||
"reconnect_endpoint_id": reconnect_endpoint_id,
|
||||
}
|
||||
response: Dict[str, Any] = {
|
||||
"user_code": user_code,
|
||||
"verification_uri": verification_uri,
|
||||
"mode": "reconnect" if reconnect_auth_id else "connect",
|
||||
}
|
||||
if label:
|
||||
response["account_label"] = label
|
||||
return DeviceFlowStart(
|
||||
pending={
|
||||
"device_auth_id": device_auth_id,
|
||||
"user_code": user_code,
|
||||
"owner": get_current_user(request) or None,
|
||||
},
|
||||
response={
|
||||
"user_code": user_code,
|
||||
"verification_uri": verification_uri,
|
||||
},
|
||||
pending=pending,
|
||||
response=response,
|
||||
interval=int(data.get("interval") or 5),
|
||||
expires_in=int(data.get("expires_in") or 900),
|
||||
)
|
||||
|
||||
|
||||
def _poll_device_flow(_request: Request, pending: Dict) -> DeviceFlowPoll:
|
||||
def _poll_device_flow(request: Request, pending: Dict) -> DeviceFlowPoll:
|
||||
# The poller must be the same user who started the flow: a poll id is not
|
||||
# a bearer for provisioning into someone else's account list.
|
||||
current_owner = effective_user(request) or None
|
||||
if (pending.get("owner") or None) != current_owner:
|
||||
raise HTTPException(403, "This sign-in belongs to another user")
|
||||
if pending.get("reconnect_auth_id"):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
auth = chatgpt_subscription.find_owned_auth_session(db, pending["reconnect_auth_id"], current_owner)
|
||||
if auth is None:
|
||||
raise HTTPException(404, "ChatGPT subscription account not found")
|
||||
if pending.get("reconnect_endpoint_id"):
|
||||
ep = _owner_scope(db.query(ModelEndpoint).filter(
|
||||
ModelEndpoint.id == pending["reconnect_endpoint_id"],
|
||||
ModelEndpoint.provider_auth_id == auth.id,
|
||||
), ModelEndpoint, current_owner).first()
|
||||
if ep is None:
|
||||
raise HTTPException(404, "ChatGPT subscription endpoint not found")
|
||||
finally:
|
||||
db.close()
|
||||
try:
|
||||
data = chatgpt_subscription.poll_device_auth(pending["device_auth_id"], pending["user_code"])
|
||||
except Exception as exc:
|
||||
logger.debug("ChatGPT device poll failed: %s", exc)
|
||||
return DeviceFlowPoll.pending(str(exc))
|
||||
logger.debug("ChatGPT device poll failed: %s", type(exc).__name__)
|
||||
return DeviceFlowPoll.pending("Sign-in status temporarily unavailable")
|
||||
|
||||
authorization_code = data.get("authorization_code")
|
||||
code_verifier = data.get("code_verifier")
|
||||
if authorization_code and code_verifier:
|
||||
try:
|
||||
tokens = chatgpt_subscription.exchange_authorization_code(authorization_code, code_verifier)
|
||||
result = _provision_endpoint(tokens, pending["owner"])
|
||||
result = _provision_endpoint(
|
||||
tokens,
|
||||
pending.get("owner"),
|
||||
label=pending.get("label") or "",
|
||||
reconnect_auth_id=pending.get("reconnect_auth_id") or None,
|
||||
reconnect_endpoint_id=pending.get("reconnect_endpoint_id") or None,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("ChatGPT Subscription endpoint provisioning failed")
|
||||
logger.warning("ChatGPT Subscription endpoint provisioning failed: %s", type(exc).__name__)
|
||||
raise chatgpt_subscription.to_http_exception(exc)
|
||||
return DeviceFlowPoll.authorized(result)
|
||||
|
||||
@@ -157,14 +361,97 @@ def _poll_device_flow(_request: Request, pending: Dict) -> DeviceFlowPoll:
|
||||
return DeviceFlowPoll.slow_down(int(data.get("interval") or 0) or None)
|
||||
if err in ("expired_token", "access_denied", "denied"):
|
||||
return DeviceFlowPoll.failed(err)
|
||||
return DeviceFlowPoll.pending(err or "unknown")
|
||||
return DeviceFlowPoll.pending("unknown")
|
||||
|
||||
|
||||
# ── Account listing / usage ─────────────────────────────────────────────────
|
||||
|
||||
def _account_summary(auth, endpoints) -> Dict[str, Any]:
|
||||
ep = endpoints[0] if endpoints else None
|
||||
return {
|
||||
"auth_id": auth.id,
|
||||
"label": _display_label(auth, ep),
|
||||
"name": (ep.name if ep is not None else None) or auth.label or chatgpt_subscription.CHATGPT_SUBSCRIPTION_LEGACY_NAME,
|
||||
"endpoint_ids": [row.id for row in endpoints],
|
||||
"connected_at": auth.created_at.isoformat() if getattr(auth, "created_at", None) else None,
|
||||
"last_refresh": auth.last_refresh.isoformat() if getattr(auth, "last_refresh", None) else None,
|
||||
"connected": bool(auth.refresh_token),
|
||||
}
|
||||
|
||||
|
||||
def usage_error_payload(exc: chatgpt_subscription.ChatGPTUsageUnavailable) -> Dict[str, Any]:
|
||||
"""Safe, credential-free payload for a failed usage read."""
|
||||
return {
|
||||
"available": False,
|
||||
"reason": exc.reason,
|
||||
"message": str(exc),
|
||||
"status_code": exc.status_code,
|
||||
"reconnect_suggested": exc.reason == "reauth",
|
||||
}
|
||||
|
||||
|
||||
def _load_owned_account(request: Request, auth_id: str):
|
||||
"""Admin gate + owner scope for account-level operations."""
|
||||
require_admin(request)
|
||||
owner = effective_user(request) or get_current_user(request) or None
|
||||
db = SessionLocal()
|
||||
try:
|
||||
auth = chatgpt_subscription.find_owned_auth_session(db, auth_id, owner)
|
||||
if auth is None:
|
||||
raise HTTPException(404, "ChatGPT subscription account not found")
|
||||
endpoints = _endpoints_for_auth(db, auth.id)
|
||||
return owner, _account_summary(auth, endpoints)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def setup_chatgpt_subscription_routes():
|
||||
return create_device_flow_router(
|
||||
router = create_device_flow_router(
|
||||
prefix="/api/chatgpt-subscription",
|
||||
tags=["chatgpt-subscription"],
|
||||
store=_DEVICE_FLOW_STORE,
|
||||
start_flow=_start_device_flow,
|
||||
poll_flow=_poll_device_flow,
|
||||
)
|
||||
|
||||
@router.get("/accounts")
|
||||
def list_accounts(request: Request):
|
||||
require_admin(request)
|
||||
owner = effective_user(request) or get_current_user(request) or None
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return [
|
||||
_account_summary(auth, _endpoints_for_auth(db, auth.id))
|
||||
for auth in _owner_chatgpt_auths(db, owner)
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.get("/accounts/{auth_id}/usage")
|
||||
def account_usage(auth_id: str, request: Request, refresh: bool = False, response: Response = None):
|
||||
"""Read-only, owner-scoped usage for exactly one ChatGPT account.
|
||||
|
||||
Failures here are telemetry failures only: the model endpoint is never
|
||||
disabled, credentials are never destroyed and no reconnect is started.
|
||||
"""
|
||||
if response is not None:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
owner, account = _load_owned_account(request, auth_id)
|
||||
try:
|
||||
usage = chatgpt_subscription.get_account_usage(account["auth_id"], owner=owner, force_refresh=refresh)
|
||||
except chatgpt_subscription.ChatGPTSubscriptionAuthNotFound:
|
||||
raise HTTPException(404, "ChatGPT subscription account not found")
|
||||
except chatgpt_subscription.ChatGPTUsageUnavailable as exc:
|
||||
payload = usage_error_payload(exc)
|
||||
payload["account"] = account
|
||||
return payload
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.warning("ChatGPT usage read failed for auth %s: %s", auth_id, type(exc).__name__)
|
||||
payload = usage_error_payload(
|
||||
chatgpt_subscription.ChatGPTUsageUnavailable("upstream", "ChatGPT usage is unavailable.")
|
||||
)
|
||||
payload["account"] = account
|
||||
return payload
|
||||
return {"available": True, "account": account, "usage": usage}
|
||||
|
||||
return router
|
||||
|
||||
@@ -315,6 +315,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
"history": history_dict,
|
||||
"model": db_session.model,
|
||||
"endpoint_url": db_session.endpoint_url,
|
||||
"endpoint_id": getattr(db_session, "endpoint_id", None),
|
||||
"name": db_session.name,
|
||||
"offset": page_offset,
|
||||
"limit": page_limit,
|
||||
@@ -378,6 +379,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
"history": history_dict,
|
||||
"model": session.model,
|
||||
"endpoint_url": session.endpoint_url,
|
||||
"endpoint_id": getattr(session, "endpoint_id", None),
|
||||
"name": session.name,
|
||||
}
|
||||
|
||||
@@ -802,6 +804,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
model=source.model,
|
||||
rag=False,
|
||||
owner=getattr(source, 'owner', None),
|
||||
endpoint_id=getattr(source, 'endpoint_id', None),
|
||||
)
|
||||
|
||||
# Copy messages up to keep_count
|
||||
@@ -820,8 +823,6 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
except Exception:
|
||||
logger.debug("session_created event dispatch failed", exc_info=True)
|
||||
|
||||
from src.model_profiles import supports_user_thinking_toggle
|
||||
thinking_supported = supports_user_thinking_toggle(session.model)
|
||||
return {
|
||||
"status": "ok",
|
||||
"id": new_id,
|
||||
@@ -882,6 +883,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
"session_id": session_id,
|
||||
"model": session.model,
|
||||
"endpoint_url": session.endpoint_url,
|
||||
"endpoint_id": getattr(session, "endpoint_id", None),
|
||||
"used_tokens": used,
|
||||
"context_length": ctx_len,
|
||||
"context_percent": pct,
|
||||
|
||||
+77
-20
@@ -695,6 +695,11 @@ def _delete_orphaned_provider_auth(db, auth_id: Optional[str], exclude_ep_id: Op
|
||||
if auth_row is None:
|
||||
return False
|
||||
db.delete(auth_row)
|
||||
try:
|
||||
from src.chatgpt_subscription import USAGE_CACHE
|
||||
USAGE_CACHE.invalidate(auth_id)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
@@ -738,7 +743,7 @@ def _resolve_probe_key(ep) -> Optional[str]:
|
||||
_base, key = resolve_endpoint_runtime(ep, owner=getattr(ep, "owner", None))
|
||||
return key
|
||||
except Exception as exc:
|
||||
logger.warning("Probe key resolution failed for %s: %s", getattr(ep, "id", "?"), exc)
|
||||
logger.warning("Probe key resolution failed for %s: %s", getattr(ep, "id", "?"), type(exc).__name__)
|
||||
return None
|
||||
|
||||
|
||||
@@ -1410,6 +1415,35 @@ def _picker_models_for_endpoint(ep, base_url: str, kind: str):
|
||||
), pinned
|
||||
|
||||
|
||||
def _chatgpt_endpoint_visible(ep: Any, request: Request) -> bool:
|
||||
from src.chatgpt_subscription import is_chatgpt_subscription_base
|
||||
if not is_chatgpt_subscription_base(getattr(ep, "base_url", "") or ""):
|
||||
return True
|
||||
try:
|
||||
user = effective_user(request)
|
||||
except AttributeError:
|
||||
user = getattr(getattr(request, "state", None), "current_user", None)
|
||||
return (getattr(ep, "owner", None) or None) == (user or None)
|
||||
|
||||
|
||||
def _provider_account_metadata(ep: Any) -> Dict[str, Any]:
|
||||
"""Non-secret account metadata for session-backed provider endpoints."""
|
||||
auth_id = getattr(ep, "provider_auth_id", None)
|
||||
if not auth_id:
|
||||
return {"provider_auth_id": None, "provider": None, "account_label": None}
|
||||
base = getattr(ep, "base_url", "") or ""
|
||||
provider = None
|
||||
account_label = None
|
||||
try:
|
||||
from src.chatgpt_subscription import account_label_from_name, is_chatgpt_subscription_base
|
||||
if is_chatgpt_subscription_base(base):
|
||||
provider = "chatgpt-subscription"
|
||||
account_label = account_label_from_name(getattr(ep, "name", None)) or None
|
||||
except Exception:
|
||||
provider = None
|
||||
return {"provider_auth_id": auth_id, "provider": provider, "account_label": account_label}
|
||||
|
||||
|
||||
def _api_key_fingerprint(api_key: Optional[str]) -> str:
|
||||
"""Stable, non-secret label for distinguishing same-URL credentials."""
|
||||
key = (api_key or "").strip()
|
||||
@@ -1444,8 +1478,10 @@ def setup_model_routes(model_discovery):
|
||||
_REFRESH_FAILURE_BASE = 300.0
|
||||
_REFRESH_FAILURE_MAX = 3600.0
|
||||
|
||||
def _refresh_key(base: str, api_key: Optional[str]) -> str:
|
||||
return f"{base.rstrip('/')}\x00{api_key or ''}"
|
||||
def _refresh_key(base: str, api_key: Optional[str], provider_auth_id: Optional[str] = None) -> str:
|
||||
# Session-backed endpoints carry no static key; include their auth id
|
||||
# so two accounts on one provider URL never share refresh state.
|
||||
return f"{base.rstrip('/')}\x00{api_key or ''}\x00{provider_auth_id or ''}"
|
||||
|
||||
def _ts(value: Any) -> float:
|
||||
try:
|
||||
@@ -1466,7 +1502,7 @@ def setup_model_routes(model_discovery):
|
||||
category = _classify_endpoint(base, kind)
|
||||
mode = _endpoint_refresh_mode(ep, kind)
|
||||
cached = _cached_model_ids(ep)
|
||||
key = _refresh_key(base, getattr(ep, "api_key", None))
|
||||
key = _refresh_key(base, getattr(ep, "api_key", None), getattr(ep, "provider_auth_id", None))
|
||||
state = _refresh_state.get(key, {})
|
||||
|
||||
info = {
|
||||
@@ -1528,9 +1564,10 @@ def setup_model_routes(model_discovery):
|
||||
ok, info = _should_refresh_endpoint(ep, now, force=force)
|
||||
if not ok:
|
||||
continue
|
||||
credential = _resolve_probe_key(ep)
|
||||
groups.setdefault(info["key"], {
|
||||
"base": info["base"],
|
||||
"api_key": info["api_key"],
|
||||
"api_key": credential,
|
||||
"timeout": info["timeout"],
|
||||
"endpoint_ids": [],
|
||||
})["endpoint_ids"].append(info["id"])
|
||||
@@ -1606,6 +1643,9 @@ def setup_model_routes(model_discovery):
|
||||
db.close()
|
||||
|
||||
for ep in endpoints:
|
||||
from src.chatgpt_subscription import is_chatgpt_subscription_base
|
||||
if is_chatgpt_subscription_base(ep.base_url or "") and (ep.owner or None) != (owner or None):
|
||||
continue
|
||||
base = _normalize_base(ep.base_url)
|
||||
provider = _safe_detect_provider(base)
|
||||
ep_model_type = getattr(ep, "model_type", None) or "llm"
|
||||
@@ -1858,8 +1898,8 @@ def setup_model_routes(model_discovery):
|
||||
# Cache endpoint lookups
|
||||
if ep_id and ep_id not in endpoints_cache:
|
||||
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
|
||||
if ep:
|
||||
endpoints_cache[ep_id] = {"base_url": ep.base_url, "api_key": ep.api_key}
|
||||
if ep and _chatgpt_endpoint_visible(ep, request):
|
||||
endpoints_cache[ep_id] = {"base_url": ep.base_url, "api_key": _resolve_probe_key(ep)}
|
||||
ep_data = endpoints_cache.get(ep_id)
|
||||
if not ep_data:
|
||||
# Try to find by base_url from the model's endpoint field
|
||||
@@ -1894,11 +1934,13 @@ def setup_model_routes(model_discovery):
|
||||
# Detach from session
|
||||
ep_data = []
|
||||
for ep in endpoints:
|
||||
if not _chatgpt_endpoint_visible(ep, request):
|
||||
continue
|
||||
ep_data.append({
|
||||
"id": ep.id,
|
||||
"name": ep.name,
|
||||
"base_url": ep.base_url,
|
||||
"api_key": ep.api_key,
|
||||
"api_key": _resolve_probe_key(ep),
|
||||
})
|
||||
finally:
|
||||
db.close()
|
||||
@@ -1983,6 +2025,8 @@ def setup_model_routes(model_discovery):
|
||||
results = []
|
||||
upgraded_legacy_pins = False
|
||||
for r in rows:
|
||||
if not _chatgpt_endpoint_visible(r, request):
|
||||
continue
|
||||
all_models = _cached_model_ids(r)
|
||||
hidden = _hidden_model_ids(r)
|
||||
pinned = _normalize_model_ids(getattr(r, "pinned_models", None))
|
||||
@@ -2022,6 +2066,7 @@ def setup_model_routes(model_discovery):
|
||||
"model_refresh_mode": _endpoint_refresh_mode(r, kind),
|
||||
"model_refresh_interval": getattr(r, "model_refresh_interval", None),
|
||||
"model_refresh_timeout": getattr(r, "model_refresh_timeout", None),
|
||||
**_provider_account_metadata(r),
|
||||
})
|
||||
if upgraded_legacy_pins:
|
||||
db.commit()
|
||||
@@ -2096,6 +2141,8 @@ def setup_model_routes(model_discovery):
|
||||
existing = None
|
||||
_empty_key_existing = None
|
||||
for _candidate in _same_url_rows:
|
||||
if getattr(_candidate, "provider_auth_id", None):
|
||||
continue # OAuth account mutations require its explicit identity.
|
||||
_candidate_key = (getattr(_candidate, "api_key", None) or "").strip()
|
||||
if _candidate_key == _incoming_api_key:
|
||||
existing = _candidate
|
||||
@@ -2192,6 +2239,9 @@ def setup_model_routes(model_discovery):
|
||||
try:
|
||||
_st_raw = (supports_tools or "").strip().lower()
|
||||
_st = True if _st_raw in ("true", "1", "yes") else (False if _st_raw in ("false", "0", "no") else None)
|
||||
from src.chatgpt_subscription import is_chatgpt_subscription_base
|
||||
if is_chatgpt_subscription_base(base_url):
|
||||
_st = False
|
||||
_pinned = _normalize_model_ids(pinned_models)
|
||||
# Stamp owner so the picker only shows this endpoint to the admin
|
||||
# who added it. Pass `shared=true` to mark it null-owner (visible
|
||||
@@ -2305,9 +2355,9 @@ def setup_model_routes(model_discovery):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
|
||||
if not ep:
|
||||
if not ep or not _chatgpt_endpoint_visible(ep, request):
|
||||
raise HTTPException(404, "Endpoint not found")
|
||||
ep_data = {"id": ep.id, "name": ep.name, "base_url": ep.base_url, "api_key": ep.api_key}
|
||||
ep_data = {"id": ep.id, "name": ep.name, "base_url": ep.base_url, "api_key": _resolve_probe_key(ep)}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -2361,7 +2411,7 @@ def setup_model_routes(model_discovery):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
|
||||
if not ep:
|
||||
if not ep or not _chatgpt_endpoint_visible(ep, request):
|
||||
raise HTTPException(404, "Endpoint not found")
|
||||
hidden = _hidden_model_ids(ep)
|
||||
all_models = _cached_model_ids(ep)
|
||||
@@ -2372,7 +2422,7 @@ def setup_model_routes(model_discovery):
|
||||
category = _classify_endpoint(base, kind)
|
||||
timeout = _manual_refresh_timeout(ep, category, refresh_timeout)
|
||||
try:
|
||||
probed = _probe_endpoint(base, ep.api_key, timeout=timeout)
|
||||
probed = _probe_endpoint(base, _resolve_probe_key(ep), timeout=timeout)
|
||||
except Exception as exc:
|
||||
logger.warning("Manual model refresh failed for endpoint %s at %s: %s", ep_id, base, exc)
|
||||
probed = []
|
||||
@@ -2416,7 +2466,7 @@ def setup_model_routes(model_discovery):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
|
||||
if not ep:
|
||||
if not ep or not _chatgpt_endpoint_visible(ep, request):
|
||||
raise HTTPException(404, "Endpoint not found")
|
||||
body = await request.json()
|
||||
if not isinstance(body, dict):
|
||||
@@ -2573,7 +2623,7 @@ def setup_model_routes(model_discovery):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
|
||||
if not ep:
|
||||
if not ep or not _chatgpt_endpoint_visible(ep, request):
|
||||
raise HTTPException(404, "Endpoint not found")
|
||||
if body:
|
||||
if "supports_tools" in body:
|
||||
@@ -2627,6 +2677,9 @@ def setup_model_routes(model_discovery):
|
||||
ep.base_url = _new_base
|
||||
else:
|
||||
ep.is_enabled = not ep.is_enabled
|
||||
from src.chatgpt_subscription import is_chatgpt_subscription_base
|
||||
if is_chatgpt_subscription_base(ep.base_url or ""):
|
||||
ep.supports_tools = False
|
||||
db.commit()
|
||||
_invalidate_models_cache()
|
||||
_local_probe_cache["data"] = None
|
||||
@@ -2684,7 +2737,7 @@ def setup_model_routes(model_discovery):
|
||||
}
|
||||
return sess in variants or sess.startswith(base + "/")
|
||||
|
||||
def _clear_sessions_for_endpoint(db, base_url: str) -> int:
|
||||
def _clear_sessions_for_endpoint(db, base_url: str, endpoint_id: str, owner) -> int:
|
||||
"""Drop stored auth for sessions using an endpoint being deleted.
|
||||
|
||||
Keep the session's endpoint URL and model intact. If the admin is
|
||||
@@ -2694,15 +2747,17 @@ def setup_model_routes(model_discovery):
|
||||
matching enabled endpoint exists.
|
||||
"""
|
||||
cleared = 0
|
||||
rows = db.query(DbSession).filter(DbSession.endpoint_url.isnot(None)).all()
|
||||
rows = db.query(DbSession).filter(DbSession.endpoint_url.isnot(None), DbSession.owner == owner).all()
|
||||
for row in rows:
|
||||
if getattr(row, "endpoint_id", None) not in (None, endpoint_id):
|
||||
continue
|
||||
if _session_uses_endpoint_url(row.endpoint_url or "", base_url):
|
||||
row.headers = {}
|
||||
row.updated_at = datetime.utcnow()
|
||||
cleared += 1
|
||||
return cleared
|
||||
|
||||
def _clear_loaded_sessions_for_endpoint(base_url: str) -> int:
|
||||
def _clear_loaded_sessions_for_endpoint(base_url: str, endpoint_id: str, owner) -> int:
|
||||
try:
|
||||
from src.ai_interaction import get_session_manager
|
||||
manager = get_session_manager()
|
||||
@@ -2713,6 +2768,8 @@ def setup_model_routes(model_discovery):
|
||||
cleared = 0
|
||||
try:
|
||||
for sess in list(getattr(manager, "sessions", {}).values()):
|
||||
if getattr(sess, "owner", None) != owner or getattr(sess, "endpoint_id", None) not in (None, endpoint_id):
|
||||
continue
|
||||
if _session_uses_endpoint_url(getattr(sess, "endpoint_url", "") or "", base_url):
|
||||
sess.headers = {}
|
||||
cleared += 1
|
||||
@@ -2732,13 +2789,13 @@ def setup_model_routes(model_discovery):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
|
||||
if not ep:
|
||||
if not ep or not _chatgpt_endpoint_visible(ep, request):
|
||||
raise HTTPException(404, "Endpoint not found")
|
||||
# Clean up any settings that reference this endpoint
|
||||
cleared = _clear_settings_for_endpoint(ep_id)
|
||||
cleared_user_preferences = _clear_user_prefs_for_endpoint(ep_id)
|
||||
cleared_sessions = _clear_sessions_for_endpoint(db, ep.base_url)
|
||||
cleared_loaded_sessions = _clear_loaded_sessions_for_endpoint(ep.base_url)
|
||||
cleared_sessions = _clear_sessions_for_endpoint(db, ep.base_url, ep.id, ep.owner)
|
||||
cleared_loaded_sessions = _clear_loaded_sessions_for_endpoint(ep.base_url, ep.id, ep.owner)
|
||||
auth_id = getattr(ep, "provider_auth_id", None)
|
||||
db.delete(ep)
|
||||
cleared_provider_auth = _delete_orphaned_provider_auth(db, auth_id, exclude_ep_id=ep_id)
|
||||
|
||||
@@ -455,13 +455,17 @@ def setup_session_routes(
|
||||
else (s.created_at.isoformat() if s.created_at else None))
|
||||
)
|
||||
matches = endpoint_routes.get((s.endpoint_url or '').rstrip('/'), [])
|
||||
selected_endpoint = matches[0] if len(matches) == 1 else None
|
||||
bound_id = getattr(s, "endpoint_id", None)
|
||||
selected_endpoint = (
|
||||
next((ep for ep in matches if ep.id == bound_id), None)
|
||||
if bound_id else (matches[0] if len(matches) == 1 else None)
|
||||
)
|
||||
sessions.append({
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"model": _public_model(s.name, s.model),
|
||||
"endpoint_url": s.endpoint_url,
|
||||
"endpoint_id": selected_endpoint.id if selected_endpoint else None,
|
||||
"endpoint_id": bound_id or (selected_endpoint.id if selected_endpoint else None),
|
||||
"endpoint_name": selected_endpoint.name if selected_endpoint else None,
|
||||
"rag": s.rag,
|
||||
"archived": s.archived,
|
||||
@@ -590,6 +594,7 @@ def setup_session_routes(
|
||||
rag=str(rag).lower() == "true" if rag else False,
|
||||
owner=user,
|
||||
cwd=cwd or None,
|
||||
endpoint_id=endpoint_id.strip() if endpoint_id else None,
|
||||
)
|
||||
# Set auth headers for custom API-key endpoints
|
||||
resolved_key = request_api_key
|
||||
@@ -597,7 +602,8 @@ def setup_session_routes(
|
||||
if not resolved_key and endpoint_api_key:
|
||||
resolved_key = endpoint_api_key
|
||||
resolved_base = endpoint_base_url
|
||||
if resolved_key:
|
||||
from src.chatgpt_subscription import is_chatgpt_subscription_base
|
||||
if resolved_key and not is_chatgpt_subscription_base(endpoint_url):
|
||||
from src.endpoint_resolver import build_headers
|
||||
session.headers = build_headers(resolved_key, resolved_base)
|
||||
_persist_session_headers(sid, session.headers)
|
||||
@@ -685,8 +691,14 @@ def setup_session_routes(
|
||||
endpoint_url = build_chat_url(normalize_base(endpoint_base_url))
|
||||
finally:
|
||||
_db.close()
|
||||
previous_url = session.endpoint_url
|
||||
session.model = model
|
||||
session.endpoint_url = endpoint_url
|
||||
# A registered endpoint id pins the exact route; a raw URL switch
|
||||
# (admin only) clears any previous binding.
|
||||
session.endpoint_id = (endpoint_id or "").strip() or (
|
||||
getattr(session, "endpoint_id", None) if endpoint_url == previous_url else None
|
||||
)
|
||||
# Update auth headers from the endpoint's stored API key
|
||||
if endpoint_api_key:
|
||||
from src.endpoint_resolver import build_headers
|
||||
@@ -700,6 +712,7 @@ def setup_session_routes(
|
||||
if db_session:
|
||||
db_session.model = model
|
||||
db_session.endpoint_url = endpoint_url
|
||||
db_session.endpoint_id = session.endpoint_id
|
||||
db_session.headers = session.headers or {}
|
||||
db_session.updated_at = utcnow_naive()
|
||||
db.commit()
|
||||
|
||||
Reference in New Issue
Block a user