mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-17 02:05:22 -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:
+201
-68
@@ -5,6 +5,7 @@ import uiModule from './ui.js';
|
||||
import settingsModule from './settings.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { sortModelObjects } from './modelSort.js';
|
||||
import { PROVIDER_DEVICE_FLOWS, formatDeviceFlowError, runProviderDeviceFlow } from './providerDeviceFlow.js';
|
||||
|
||||
let initialized = false;
|
||||
let modalEl = null;
|
||||
@@ -707,6 +708,80 @@ function initEndpointForm() {
|
||||
const pickerBtn = el('adm-provider-btn');
|
||||
const pickerMenu = el('adm-provider-menu');
|
||||
const pickerCurrent = picker ? picker.querySelector('.adm-provider-current') : null;
|
||||
const DEVICE_AUTH_PROVIDER_VALUES = new Set(Object.keys(PROVIDER_DEVICE_FLOWS));
|
||||
let deviceAuthPolling = false;
|
||||
function _selectedProviderOption() {
|
||||
return provider && provider.selectedOptions ? provider.selectedOptions[0] : null;
|
||||
}
|
||||
function _selectedDeviceAuthProvider() {
|
||||
const opt = _selectedProviderOption();
|
||||
const flow = opt && opt.dataset ? opt.dataset.authFlow : '';
|
||||
if (flow && DEVICE_AUTH_PROVIDER_VALUES.has(flow)) return flow;
|
||||
return DEVICE_AUTH_PROVIDER_VALUES.has(provider.value) ? provider.value : '';
|
||||
}
|
||||
function _isDeviceAuthSelected() {
|
||||
return !!_selectedDeviceAuthProvider();
|
||||
}
|
||||
function _setApiFormForProvider() {
|
||||
const deviceAuthProvider = _selectedDeviceAuthProvider();
|
||||
const deviceAuthConfig = PROVIDER_DEVICE_FLOWS[deviceAuthProvider] || null;
|
||||
const apiKey = el('adm-epApiKey');
|
||||
const testBtn = el('adm-epApiTestBtn');
|
||||
const addBtn = el('adm-epAddBtn');
|
||||
const status = el('adm-deviceAuthStatus');
|
||||
const msg = _endpointMsg('api');
|
||||
if (deviceAuthConfig) {
|
||||
urlInput.value = '';
|
||||
urlInput.placeholder = deviceAuthProvider === 'copilot'
|
||||
? 'GitHub Copilot uses GitHub account sign-in'
|
||||
: 'ChatGPT Subscription uses OpenAI account sign-in';
|
||||
urlInput.readOnly = true;
|
||||
if (apiKey) {
|
||||
apiKey.value = '';
|
||||
apiKey.placeholder = 'No API key needed';
|
||||
apiKey.disabled = true;
|
||||
}
|
||||
if (testBtn) {
|
||||
testBtn.disabled = true;
|
||||
testBtn.style.opacity = '0.45';
|
||||
testBtn.style.cursor = 'not-allowed';
|
||||
}
|
||||
if (addBtn) {
|
||||
addBtn.disabled = false;
|
||||
addBtn.textContent = 'Add';
|
||||
addBtn.style.width = '55px';
|
||||
addBtn.style.display = '';
|
||||
}
|
||||
if (kindSel) kindSel.value = 'api';
|
||||
if (msg) {
|
||||
msg.textContent = '';
|
||||
msg.className = '';
|
||||
}
|
||||
} else {
|
||||
urlInput.placeholder = 'Base URL or pick provider';
|
||||
urlInput.readOnly = false;
|
||||
if (apiKey) {
|
||||
apiKey.placeholder = 'API key';
|
||||
apiKey.disabled = false;
|
||||
}
|
||||
if (testBtn) {
|
||||
testBtn.disabled = false;
|
||||
testBtn.style.opacity = '';
|
||||
testBtn.style.cursor = '';
|
||||
}
|
||||
if (addBtn) {
|
||||
addBtn.disabled = false;
|
||||
addBtn.textContent = 'Add';
|
||||
addBtn.style.width = '55px';
|
||||
addBtn.style.display = '';
|
||||
}
|
||||
if (msg) {
|
||||
msg.textContent = '';
|
||||
msg.className = '';
|
||||
}
|
||||
if (!deviceAuthPolling && status) status.textContent = '';
|
||||
}
|
||||
}
|
||||
function _renderPickerMenu() {
|
||||
if (!pickerMenu) return;
|
||||
pickerMenu.innerHTML = Array.from(provider.options).map(o => {
|
||||
@@ -748,9 +823,16 @@ function initEndpointForm() {
|
||||
}
|
||||
|
||||
provider.addEventListener('change', () => {
|
||||
if (_isDeviceAuthSelected()) {
|
||||
_setApiFormForProvider();
|
||||
_renderPickerMenu();
|
||||
_syncPickerCurrent();
|
||||
return;
|
||||
}
|
||||
if (provider.value) urlInput.value = provider.value;
|
||||
else urlInput.value = '';
|
||||
if (kindSel) kindSel.value = provider.value ? 'api' : 'proxy';
|
||||
_setApiFormForProvider();
|
||||
});
|
||||
urlInput.addEventListener('input', () => {
|
||||
if (provider.value && urlInput.value.trim() !== provider.value) {
|
||||
@@ -838,6 +920,12 @@ function initEndpointForm() {
|
||||
const apiCancelTestBtn = el('adm-epApiCancelTestBtn');
|
||||
if (apiTestBtn) {
|
||||
apiTestBtn.addEventListener('click', async () => {
|
||||
if (_isDeviceAuthSelected()) {
|
||||
const msg = _endpointMsg('api');
|
||||
msg.textContent = '';
|
||||
msg.className = '';
|
||||
return;
|
||||
}
|
||||
const msg = _endpointMsg('api');
|
||||
msg.textContent = ''; msg.className = '';
|
||||
const rawUrl = (urlInput.value || provider.value).trim();
|
||||
@@ -885,6 +973,11 @@ function initEndpointForm() {
|
||||
}
|
||||
|
||||
el('adm-epAddBtn').addEventListener('click', async () => {
|
||||
const deviceAuthProvider = _selectedDeviceAuthProvider();
|
||||
if (deviceAuthProvider) {
|
||||
await _startProviderDeviceAuth(deviceAuthProvider, el('adm-epAddBtn'));
|
||||
return;
|
||||
}
|
||||
const msg = _endpointMsg('api');
|
||||
msg.textContent = ''; msg.className = '';
|
||||
const rawUrl = (urlInput.value || provider.value).trim();
|
||||
@@ -936,76 +1029,116 @@ function initEndpointForm() {
|
||||
btn.disabled = false; btn.textContent = 'Add';
|
||||
});
|
||||
|
||||
// GitHub Copilot — device-flow login. Starts the flow, shows the user a
|
||||
// code + verification link, and polls until they authorise (or it expires).
|
||||
const copilotBtn = el('adm-copilotConnectBtn');
|
||||
if (copilotBtn) {
|
||||
let copilotPolling = false;
|
||||
copilotBtn.addEventListener('click', async () => {
|
||||
if (copilotPolling) return;
|
||||
const status = el('adm-copilotStatus');
|
||||
const reset = () => { copilotBtn.disabled = false; copilotBtn.textContent = 'Connect GitHub Copilot'; copilotPolling = false; };
|
||||
status.textContent = ''; status.className = 'adm-ep-inline-msg';
|
||||
copilotBtn.disabled = true; copilotBtn.textContent = 'Starting...';
|
||||
copilotPolling = true;
|
||||
let start;
|
||||
try {
|
||||
const res = await fetch('/api/copilot/device/start', { method: 'POST', body: new FormData(), credentials: 'same-origin' });
|
||||
start = await res.json();
|
||||
if (!res.ok) { status.textContent = start.detail || 'Failed to start login'; status.className = 'admin-error'; reset(); return; }
|
||||
} catch (e) { status.textContent = 'Request failed'; status.className = 'admin-error'; reset(); return; }
|
||||
async function _startProviderDeviceAuth(providerKey, triggerEl = null) {
|
||||
if (deviceAuthPolling) return;
|
||||
const config = PROVIDER_DEVICE_FLOWS[providerKey];
|
||||
if (!config) return;
|
||||
const status = el('adm-deviceAuthStatus') || _endpointMsg('api');
|
||||
if (!status) return;
|
||||
const triggerText = triggerEl ? triggerEl.textContent : '';
|
||||
// Render an error with an inline "Try again" (the top button is hidden for
|
||||
// device-auth providers, so retry lives here). Built with DOM methods, not
|
||||
// innerHTML. Call reset() first so the deviceAuthPolling guard is cleared.
|
||||
const showAuthError = (text) => {
|
||||
status.className = 'admin-error';
|
||||
status.textContent = text + ' ';
|
||||
const retry = document.createElement('button');
|
||||
retry.type = 'button';
|
||||
retry.className = 'admin-btn-sm';
|
||||
retry.textContent = 'Try again';
|
||||
retry.addEventListener('click', () => { _startProviderDeviceAuth(providerKey, triggerEl); });
|
||||
status.appendChild(retry);
|
||||
};
|
||||
const reset = () => {
|
||||
if (triggerEl) {
|
||||
triggerEl.disabled = false;
|
||||
triggerEl.textContent = triggerText || 'Add';
|
||||
}
|
||||
deviceAuthPolling = false;
|
||||
_setApiFormForProvider();
|
||||
};
|
||||
status.textContent = '';
|
||||
status.className = 'adm-ep-inline-msg';
|
||||
if (triggerEl) {
|
||||
triggerEl.disabled = true;
|
||||
triggerEl.textContent = 'Starting...';
|
||||
}
|
||||
deviceAuthPolling = true;
|
||||
_setApiFormForProvider();
|
||||
status.textContent = `Starting ${config.label} sign-in...`;
|
||||
|
||||
const { poll_id, user_code, verification_uri, verification_uri_complete, interval, expires_in } = start;
|
||||
// Prefer the "complete" URL — it embeds the code so the user only has to
|
||||
// click "Authorize" (no manual code entry).
|
||||
const authUrl = verification_uri_complete || verification_uri || '';
|
||||
const esc = (s) => String(s || '').replace(/[<>&"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c]));
|
||||
copilotBtn.textContent = 'Waiting…';
|
||||
|
||||
// Cohesive waiting panel: spinner + status line, the device code as a
|
||||
// copyable chip, and a primary "Authorize on GitHub" action.
|
||||
status.className = '';
|
||||
status.innerHTML =
|
||||
'<div class="adm-copilot-panel">' +
|
||||
'<div class="adm-copilot-wait"><span class="admin-spinner"></span>' +
|
||||
'<span>Waiting for GitHub authorization…</span></div>' +
|
||||
'<div class="adm-copilot-coderow">' +
|
||||
'<span class="adm-copilot-code-label">Code</span>' +
|
||||
'<code class="adm-copilot-code">' + esc(user_code) + '</code>' +
|
||||
'<button type="button" class="admin-btn-sm adm-copilot-copy">Copy</button>' +
|
||||
'</div>' +
|
||||
'<a class="admin-btn-add adm-copilot-auth" href="' + encodeURI(authUrl) + '" target="_blank" rel="noopener">Authorize on GitHub ↗</a>' +
|
||||
'<div class="adm-copilot-hint">A new tab opened on GitHub — approve there to finish. Didn\'t open? Use the button above.</div>' +
|
||||
'</div>';
|
||||
const copyBtn = status.querySelector('.adm-copilot-copy');
|
||||
if (copyBtn) copyBtn.addEventListener('click', async () => {
|
||||
try { await navigator.clipboard.writeText(user_code || ''); copyBtn.textContent = 'Copied'; setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500); } catch (e) {}
|
||||
try {
|
||||
const result = await runProviderDeviceFlow(providerKey, {
|
||||
openWindow: () => {},
|
||||
onStart: ({ start, authUrl }) => {
|
||||
if (triggerEl) triggerEl.textContent = 'Waiting...';
|
||||
status.className = '';
|
||||
const authLabel = providerKey === 'copilot' ? 'Authorize on GitHub' : 'Authorize with OpenAI';
|
||||
const waitLabel = providerKey === 'copilot' ? 'Waiting for GitHub authorization...' : 'Waiting for ChatGPT authorization...';
|
||||
status.innerHTML =
|
||||
'<div class="adm-copilot-panel">' +
|
||||
'<div class="adm-copilot-wait"><span class="admin-spinner"></span>' +
|
||||
'<span>' + esc(waitLabel) + '</span></div>' +
|
||||
'<div class="adm-copilot-coderow">' +
|
||||
'<span class="adm-copilot-code-label">Code</span>' +
|
||||
'<code class="adm-copilot-code">' + esc(start.user_code) + '</code>' +
|
||||
'<button type="button" class="admin-btn-sm adm-device-auth-copy">Copy</button>' +
|
||||
'</div>' +
|
||||
'<a class="admin-btn-add adm-copilot-auth" href="' + encodeURI(authUrl || '') + '" target="_blank" rel="noopener">' + esc(authLabel) + ' ↗</a>' +
|
||||
'</div>';
|
||||
const copyBtn = status.querySelector('.adm-device-auth-copy');
|
||||
if (copyBtn) copyBtn.addEventListener('click', async () => {
|
||||
const code = start.user_code || '';
|
||||
let ok = false;
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(code);
|
||||
ok = true;
|
||||
}
|
||||
} catch (e) {}
|
||||
if (!ok) {
|
||||
// navigator.clipboard is unavailable in non-secure contexts (HTTP
|
||||
// self-host over a LAN IP), so fall back to execCommand('copy').
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = code;
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;padding:0;border:0;opacity:0;font-size:16px;';
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
try { ta.setSelectionRange(0, code.length); } catch (e) {}
|
||||
try { ok = document.execCommand('copy'); } catch (e) {}
|
||||
ta.remove();
|
||||
}
|
||||
copyBtn.textContent = ok ? 'Copied' : 'Failed';
|
||||
setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500);
|
||||
});
|
||||
},
|
||||
});
|
||||
try { if (authUrl) window.open(authUrl, '_blank', 'noopener'); } catch (e) {}
|
||||
|
||||
const deadline = Date.now() + (expires_in || 900) * 1000;
|
||||
const stepMs = Math.max((interval || 5), 2) * 1000;
|
||||
const done = (cls, text) => { status.className = cls; status.textContent = text; reset(); };
|
||||
const poll = async () => {
|
||||
if (Date.now() > deadline) { done('admin-error', 'Authorization expired — try again.'); return; }
|
||||
try {
|
||||
const fd = new FormData(); fd.append('poll_id', poll_id);
|
||||
const r = await fetch('/api/copilot/device/poll', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const d = await r.json();
|
||||
if (d.status === 'authorized') {
|
||||
const n = ((d.endpoint && d.endpoint.models) || []).length;
|
||||
done('admin-success', '✓ Connected — ' + n + ' Copilot model' + (n !== 1 ? 's' : '') + ' available.');
|
||||
if (d.endpoint && d.endpoint.id) _recentlyAddedEpId = String(d.endpoint.id);
|
||||
await loadEndpoints();
|
||||
await _selectAddedModelInChat(d.endpoint || {});
|
||||
return;
|
||||
}
|
||||
if (d.status === 'failed') { done('admin-error', 'Authorization failed (' + (d.error || 'denied') + ').'); return; }
|
||||
} catch (e) { /* transient — keep polling */ }
|
||||
setTimeout(poll, stepMs);
|
||||
};
|
||||
setTimeout(poll, stepMs);
|
||||
});
|
||||
if (result.status === 'authorized') {
|
||||
const endpoint = result.endpoint || {};
|
||||
const n = ((endpoint && endpoint.models) || []).length;
|
||||
status.className = 'admin-success';
|
||||
status.textContent = 'Connected - ' + n + ' ' + config.label + ' model' + (n !== 1 ? 's' : '') + ' available.';
|
||||
if (endpoint && endpoint.id) _recentlyAddedEpId = String(endpoint.id);
|
||||
await loadEndpoints();
|
||||
await _selectAddedModelInChat(endpoint || {});
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
if (result.status === 'failed') {
|
||||
reset();
|
||||
showAuthError('Authorization failed (' + (result.error || 'denied') + ').');
|
||||
return;
|
||||
}
|
||||
if (result.status === 'expired') {
|
||||
reset();
|
||||
showAuthError('Authorization expired.');
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
reset();
|
||||
showAuthError(formatDeviceFlowError(e));
|
||||
}
|
||||
}
|
||||
|
||||
// Local "Add" button — sibling form for self-hosted base URLs.
|
||||
|
||||
Reference in New Issue
Block a user