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>
129 lines
4.1 KiB
JavaScript
129 lines
4.1 KiB
JavaScript
// Shared DOM-free provider device-flow runner.
|
|
|
|
export const PROVIDER_DEVICE_FLOWS = {
|
|
copilot: {
|
|
label: 'GitHub Copilot',
|
|
startUrl: '/api/copilot/device/start',
|
|
pollUrl: '/api/copilot/device/poll',
|
|
authUrl(start) {
|
|
return start?.verification_uri_complete || start?.verification_uri || '';
|
|
},
|
|
},
|
|
'chatgpt-subscription': {
|
|
label: 'ChatGPT Subscription',
|
|
startUrl: '/api/chatgpt-subscription/device/start',
|
|
pollUrl: '/api/chatgpt-subscription/device/poll',
|
|
authUrl(start) {
|
|
return start?.verification_uri || '';
|
|
},
|
|
},
|
|
};
|
|
|
|
function _formData() {
|
|
if (typeof FormData !== 'undefined') return new FormData();
|
|
return new URLSearchParams();
|
|
}
|
|
|
|
async function _jsonOrEmpty(response) {
|
|
try {
|
|
return await response.json();
|
|
} catch (_) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function _messageFromPayload(payload, fallback) {
|
|
if (payload && typeof payload.detail === 'string' && payload.detail.trim()) {
|
|
return payload.detail.trim();
|
|
}
|
|
if (payload && typeof payload.error === 'string' && payload.error.trim()) {
|
|
return payload.error.trim();
|
|
}
|
|
if (payload && typeof payload.message === 'string' && payload.message.trim()) {
|
|
return payload.message.trim();
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
export function formatDeviceFlowError(error, fallback = 'Request failed') {
|
|
if (!error) return fallback;
|
|
if (typeof error === 'string') return error;
|
|
if (error.detail) return String(error.detail);
|
|
if (error.message) return String(error.message);
|
|
return fallback;
|
|
}
|
|
|
|
async function _fetchJson(fetchImpl, url, options, fallback) {
|
|
const response = await fetchImpl(url, options);
|
|
const payload = await _jsonOrEmpty(response);
|
|
if (!response.ok) {
|
|
throw new Error(_messageFromPayload(payload, fallback || `Request failed (HTTP ${response.status})`));
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
function _defaultSleep(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function _callCallback(fn, payload) {
|
|
if (typeof fn === 'function') await fn(payload);
|
|
}
|
|
|
|
export async function runProviderDeviceFlow(provider, options = {}) {
|
|
const cfg = PROVIDER_DEVICE_FLOWS[provider];
|
|
if (!cfg) throw new Error(`Unknown device-flow provider: ${provider}`);
|
|
|
|
const fetchImpl = options.fetchImpl || globalThis.fetch?.bind(globalThis);
|
|
if (!fetchImpl) throw new Error('Fetch API is unavailable');
|
|
|
|
const openWindow = options.openWindow || ((url) => {
|
|
if (globalThis.window && typeof globalThis.window.open === 'function') {
|
|
globalThis.window.open(url, '_blank', 'noopener');
|
|
}
|
|
});
|
|
const sleep = options.sleep || _defaultSleep;
|
|
const now = options.now || (() => Date.now());
|
|
const formData = options.formData || _formData();
|
|
|
|
const start = await _fetchJson(fetchImpl, cfg.startUrl, {
|
|
method: 'POST',
|
|
body: formData,
|
|
credentials: 'same-origin',
|
|
}, `Failed to start ${cfg.label} sign-in`);
|
|
|
|
if (!start.poll_id) throw new Error(`${cfg.label} sign-in did not return a poll id`);
|
|
const authUrl = cfg.authUrl(start);
|
|
await _callCallback(options.onStart, { provider, config: cfg, start, authUrl });
|
|
if (authUrl) openWindow(authUrl);
|
|
|
|
const deadline = now() + Number(start.expires_in || 900) * 1000;
|
|
let stepMs = Math.max(Number(start.interval || 5), 2) * 1000;
|
|
|
|
while (true) {
|
|
if (now() > deadline) return { status: 'expired' };
|
|
await _callCallback(options.onWaiting, { provider, config: cfg, start, authUrl });
|
|
await sleep(stepMs);
|
|
if (now() > deadline) return { status: 'expired' };
|
|
|
|
const fd = _formData();
|
|
fd.append('poll_id', start.poll_id);
|
|
const poll = await _fetchJson(fetchImpl, cfg.pollUrl, {
|
|
method: 'POST',
|
|
body: fd,
|
|
credentials: 'same-origin',
|
|
}, `${cfg.label} sign-in poll failed`);
|
|
await _callCallback(options.onPoll, { provider, config: cfg, start, poll });
|
|
|
|
if (poll.status === 'authorized') {
|
|
return { status: 'authorized', endpoint: poll.endpoint || {} };
|
|
}
|
|
if (poll.status === 'failed') {
|
|
return { status: 'failed', error: poll.error || 'denied' };
|
|
}
|
|
if (poll.interval) {
|
|
stepMs = Math.max(Number(poll.interval || 5), 2) * 1000;
|
|
}
|
|
}
|
|
}
|