feat(provider): support multiple ChatGPT subscriptions with usage

This commit is contained in:
Alexandre Teixeira
2026-09-22 13:12:19 +01:00
parent 45330097b8
commit ed7ccfd584
31 changed files with 3281 additions and 215 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ import { UI_VIS_DEFAULT_OFF, resolveVisibility } from './js/ui_visibility.js?v=2
import tasksModule from './js/tasks.js?v=20260914taskmodel1';
import calendarModule from './js/calendar.js?v=20260914emailsource11';
import notesModule from './js/notes.js?v=20260911notesselectioncancel1';
import adminModule from './js/admin.js?v=20260914toolschemaprofiles1';
import adminModule from './js/admin.js?v=20260921chatgptusage1';
import settingsModule from './js/settings.js?v=20260912writingstyle3';
// Eagerly bind unified minimize/restore behavior across all tool modals.
import './js/modalManager.js';
+1 -1
View File
@@ -246,7 +246,7 @@
real request is discarded and the font fetched a second time. -->
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2">
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2">
<link rel="stylesheet" href="/static/style.css?v=20260914taskbutton1">
<link rel="stylesheet" href="/static/style.css?v=20260921chatgptusage1">
<link rel="modulepreload" href="/static/app.js?v=20260916autoscroll1">
<link rel="modulepreload" href="/static/js/chat.js?v=20260917toolttft1">
<link rel="modulepreload" href="/static/js/ui.js?v=20260916largetoolscroll1">
+179 -47
View File
@@ -6,6 +6,12 @@ import settingsModule from './settings.js?v=20260912writingstyle3';
import { providerLogo, providerLogoFromUrl } from './providers.js';
import { sortModelObjects } from './modelSort.js';
import { PROVIDER_DEVICE_FLOWS, formatDeviceFlowError, runProviderDeviceFlow } from './providerDeviceFlow.js';
import {
accountTitle as chatgptAccountTitle,
buildUsageViewModel as buildChatgptUsageViewModel,
isChatgptSubscriptionEndpoint,
renderUsageCardHtml as renderChatgptUsageCardHtml,
} from './chatgptSubscriptionUsage.js';
import { getSettings, getTools, invalidateSettings, invalidateTools } from './appConfig.js';
let initialized = false;
@@ -652,6 +658,7 @@ async function loadEndpoints() {
const keyLabel = ep.has_key
? (ep.api_key_fingerprint ? ` (key ${esc(ep.api_key_fingerprint)})` : ' (key set)')
: '';
const isChatgptAccount = isChatgptSubscriptionEndpoint(ep);
return `
<div class="admin-user-row${ep.is_enabled ? '' : ' admin-ep-disabled'}${justAddedClass}" data-adm-ep-id="${ep.id}">
<div style="display:flex;align-items:center;justify-content:space-between;${hasModels ? 'cursor:pointer;' : ''}padding:4px 0;" data-adm-ep-header="${ep.id}">
@@ -661,7 +668,7 @@ async function loadEndpoints() {
<span class="adm-check-dot adm-endpoint-select-dot" aria-hidden="true"></span>
</label>` : ''}
<span class="adm-ep-row-logo" style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;flex-shrink:0;opacity:0.9;">${providerLogoFromUrl(ep.base_url) || ''}</span>
<span class="admin-user-name">${esc(ep.name)}</span>
<span class="admin-user-name">${esc(isChatgptAccount ? chatgptAccountTitle(ep) : ep.name)}</span>
${ep.model_type === 'image' ? '<span class="admin-badge" style="background:color-mix(in srgb, var(--accent) 20%, transparent);color:var(--accent);">Image</span>' : ''}
${kindLabel ? `<span class="admin-badge">${esc(kindLabel)}</span>` : ''}
${statusBadge}
@@ -675,6 +682,7 @@ async function loadEndpoints() {
${hasModels ? '<svg class="admin-user-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.3;transition:transform 0.2s,opacity 0.2s;"><polyline points="6 9 12 15 18 9"/></svg>' : ''}`}
</div>
</div>
${isChatgptAccount ? `<div class="adm-chatgpt-usage-host" data-adm-chatgpt-usage-host="${esc(ep.provider_auth_id)}" data-chatgpt-endpoint-id="${esc(ep.id)}"><div class="adm-chatgpt-usage adm-chatgpt-usage-loading"><div class="adm-chatgpt-usage-status">Loading usage...</div></div></div>` : ''}
<div class="admin-ep-detail">${esc(ep.base_url)}${category === 'local' ? `<button type="button" class="admin-ep-copy-btn" data-adm-copy-url="${esc(ep.base_url)}" title="Copy URL" aria-label="Copy URL" style="background:none;border:none;padding:0 2px;margin-left:6px;cursor:pointer;color:inherit;opacity:0.45;vertical-align:-2px;line-height:1;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>` : ''}${keyLabel}</div>
${hasModels ? `<div class="mcp-tools-panel hidden" data-adm-ep-models-panel="${ep.id}"></div>` : ''}
</div>`;
@@ -715,6 +723,12 @@ async function loadEndpoints() {
});
return out;
};
// One usage card per ChatGPT account: each fetch targets that card's own
// auth id, so account A's refresh can never repaint account B.
queryAll('[data-adm-chatgpt-usage-host]').forEach(host => {
host.addEventListener('click', (e) => e.stopPropagation());
_loadChatgptUsage(host, host.dataset.admChatgptUsageHost, host.dataset.chatgptEndpointId);
});
queryAll('[data-adm-toggle-ep]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
@@ -983,6 +997,133 @@ async function _saveEpModelState(epId, panel) {
} catch (e) { /* silent */ }
}
// Render the shared "waiting for authorization" panel (code + manual link).
// Built for both the Add Models flow and per-account Reconnect. Never opens a
// tab automatically; the user clicks the Authorize link.
function _renderDeviceAuthWaitPanel(status, providerKey, start, authUrl) {
if (!status) return;
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="' + esc(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);
});
}
// ── ChatGPT Subscription per-account usage + reconnect ─────────────────────
// Each ChatGPT account (ProviderAuthSession) is addressed by its stable
// auth id; the endpoint id pins which row the action came from. Usage is
// read-only telemetry: a failed read never disables the endpoint.
const _chatgptReconnectInflight = new Set();
async function _loadChatgptUsage(container, authId, epId, { refresh = false } = {}) {
if (!container || !authId) return;
const url = '/api/chatgpt-subscription/accounts/' + encodeURIComponent(authId) + '/usage' + (refresh ? '?refresh=1' : '');
let payload = null;
try {
const res = await fetch(url, { credentials: 'same-origin' });
if (res.ok) {
try { payload = await res.json(); } catch (_) { payload = null; }
} else {
let reason = 'upstream';
if (res.status === 404) reason = 'upstream';
payload = { available: false, reason, account: { auth_id: authId } };
}
} catch (_) {
payload = { available: false, reason: 'network', account: { auth_id: authId } };
}
if (!payload || typeof payload !== 'object') payload = { available: false, reason: 'malformed', account: { auth_id: authId } };
const vm = buildChatgptUsageViewModel(payload);
vm.authId = authId;
container.innerHTML = renderChatgptUsageCardHtml(vm, { esc, endpointId: epId });
const refreshBtn = container.querySelector('[data-adm-chatgpt-usage-refresh]');
if (refreshBtn) refreshBtn.addEventListener('click', async (e) => {
e.stopPropagation();
refreshBtn.disabled = true;
refreshBtn.textContent = 'Refreshing...';
await _loadChatgptUsage(container, refreshBtn.dataset.admChatgptUsageRefresh, refreshBtn.dataset.chatgptEndpointId, { refresh: true });
});
const reconnectBtn = container.querySelector('[data-adm-chatgpt-reconnect]');
if (reconnectBtn) reconnectBtn.addEventListener('click', async (e) => {
e.stopPropagation();
await _reconnectChatgptAccount(container, reconnectBtn.dataset.admChatgptReconnect, reconnectBtn.dataset.chatgptEndpointId);
});
}
async function _reconnectChatgptAccount(container, authId, epId) {
if (!container || !authId) return;
if (_chatgptReconnectInflight.has(authId)) return;
_chatgptReconnectInflight.add(authId);
const status = document.createElement('div');
status.className = 'adm-chatgpt-reconnect-status';
const actions = container.querySelector('.adm-chatgpt-usage-actions');
if (actions) actions.replaceWith(status); else container.appendChild(status);
status.textContent = 'Starting ChatGPT sign-in...';
try {
// Reconnect targets exactly this auth session + endpoint. The backend
// re-checks ownership on start and on every poll.
const formData = new FormData();
formData.append('reconnect_auth_id', authId);
if (epId) formData.append('reconnect_endpoint_id', epId);
const result = await runProviderDeviceFlow('chatgpt-subscription', {
openWindow: () => {},
formData,
onStart: ({ start, authUrl }) => _renderDeviceAuthWaitPanel(status, 'chatgpt-subscription', start, authUrl),
});
if (result.status === 'authorized') {
status.className = 'admin-success';
status.textContent = 'Reconnected.';
await loadEndpoints();
return;
}
status.className = 'admin-error';
status.textContent = result.status === 'expired'
? 'Authorization expired.'
: 'Authorization failed (' + (result.error || 'denied') + ').';
} catch (e) {
status.className = 'admin-error';
status.textContent = formatDeviceFlowError(e);
} finally {
_chatgptReconnectInflight.delete(authId);
// Re-render the card so Refresh/Reconnect buttons come back.
setTimeout(() => { _loadChatgptUsage(container, authId, epId); }, 1200);
}
}
function initEndpointForm() {
const provider = el('adm-epProvider');
const urlInput = el('adm-epUrl');
@@ -997,6 +1138,8 @@ function initEndpointForm() {
const pickerCurrent = picker ? picker.querySelector('.adm-provider-current') : null;
const DEVICE_AUTH_PROVIDER_VALUES = new Set(Object.keys(PROVIDER_DEVICE_FLOWS));
let deviceAuthPolling = false;
// True while the URL box is repurposed as the ChatGPT account-label input.
let _chatgptLabelMode = false;
function _selectedProviderOption() {
return provider && provider.selectedOptions ? provider.selectedOptions[0] : null;
}
@@ -1018,11 +1161,22 @@ function initEndpointForm() {
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 (deviceAuthProvider === 'chatgpt-subscription') {
// The URL box doubles as the optional account label so several
// ChatGPT subscriptions stay distinguishable ("ChatGPT · codex00").
if (!_chatgptLabelMode) urlInput.value = '';
urlInput.placeholder = 'Account label, e.g. codex00 (optional)';
urlInput.readOnly = false;
urlInput.maxLength = 40;
urlInput.setAttribute('aria-label', 'ChatGPT account label (optional)');
_chatgptLabelMode = true;
} else {
_chatgptLabelMode = false;
urlInput.value = '';
urlInput.placeholder = 'GitHub Copilot uses GitHub account sign-in';
urlInput.readOnly = true;
urlInput.removeAttribute('maxlength');
}
if (apiKey) {
apiKey.value = '';
apiKey.placeholder = 'No API key needed';
@@ -1045,8 +1199,12 @@ function initEndpointForm() {
msg.className = '';
}
} else {
if (_chatgptLabelMode) urlInput.value = '';
_chatgptLabelMode = false;
urlInput.placeholder = 'Base URL or pick provider';
urlInput.readOnly = false;
urlInput.removeAttribute('maxlength');
urlInput.setAttribute('aria-label', 'Model endpoint URL');
if (apiKey) {
apiKey.placeholder = 'API key';
apiKey.disabled = false;
@@ -1130,6 +1288,8 @@ function initEndpointForm() {
_setApiFormForProvider();
});
urlInput.addEventListener('input', () => {
// Typing an account label must not flip the picker back to "Custom URL".
if (_isDeviceAuthSelected()) return;
if (provider.value && urlInput.value.trim() !== provider.value) {
provider.value = '';
if (kindSel) kindSel.value = 'api';
@@ -1372,58 +1532,30 @@ function initEndpointForm() {
status.textContent = `Starting ${config.label} sign-in...`;
try {
// New ChatGPT connections carry the optional account label. Only the
// label travels: the backend creates fresh auth/endpoint rows and never
// reuses another account's credentials.
const formData = new FormData();
if (providerKey === 'chatgpt-subscription' && _chatgptLabelMode) {
const label = (urlInput.value || '').trim();
if (label) formData.append('label', label);
}
const result = await runProviderDeviceFlow(providerKey, {
openWindow: () => {},
formData,
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);
});
_renderDeviceAuthWaitPanel(status, providerKey, start, authUrl);
},
});
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.';
const connectedName = endpoint && endpoint.name ? endpoint.name : config.label;
status.textContent = 'Connected ' + connectedName + ' - ' + n + ' model' + (n !== 1 ? 's' : '') + ' available.';
if (endpoint && endpoint.id) _recentlyAddedEpId = String(endpoint.id);
if (_chatgptLabelMode) urlInput.value = '';
await loadEndpoints();
await _selectAddedModelInChat(endpoint || {});
reset();
+1 -1
View File
@@ -19,7 +19,7 @@ import searchModule from './search.js';
import documentModule from './document.js?v=20260916docctx2';
import * as emailInbox from './emailInbox.js?v=20260914aireply4';
import codeRunnerModule from './codeRunner.js?v=20260831richtexttools91';
import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js?v=20260902tuiharness1';
import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js?v=20260921chatgptusage1';
import createResearchSynapse from './researchSynapse.js?v=20260910roundlabels2';
import { createStreamRenderer } from './streamingRenderer.js';
import { createTurnRendering, startsContinuationRound } from './turnRendering.js?v=20260910round1stable1';
+249
View File
@@ -0,0 +1,249 @@
// DOM-free view-model + renderer for per-account ChatGPT Subscription usage.
//
// The backend (/api/chatgpt-subscription/accounts/{auth_id}/usage) returns a
// normalized, credential-free payload. This module turns it into something the
// Settings "Added Models" card can render, defensively: unknown buckets are
// kept, missing windows are tolerated and nothing (reset times, percentages)
// is ever invented.
function _num(value) {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim() !== '') {
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
return null;
}
function _clampPercent(value) {
const n = _num(value);
if (n === null) return null;
return Math.max(0, Math.min(100, n));
}
function _defaultEsc(text) {
return String(text == null ? '' : text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export function formatDuration(totalSeconds) {
const secs = _num(totalSeconds);
if (secs === null) return '';
if (secs <= 0) return 'now';
const days = Math.floor(secs / 86400);
const hours = Math.floor((secs % 86400) / 3600);
const minutes = Math.floor((secs % 3600) / 60);
if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
if (minutes > 0) return `${minutes}m`;
return '<1m';
}
export function formatResetIn(resetsAt, nowSeconds) {
const at = _num(resetsAt);
if (at === null || at <= 0) return '';
const now = _num(nowSeconds);
if (now === null) return '';
const delta = at - now;
if (delta <= 0) return 'resets now';
return `resets in ${formatDuration(delta)}`;
}
export function formatPercent(value) {
const n = _clampPercent(value);
if (n === null) return '';
const rounded = Math.round(n);
return `${rounded}%`;
}
export function planDisplayName(planType) {
if (typeof planType !== 'string' || !planType.trim()) return '';
const key = planType.trim().toLowerCase();
const known = {
free: 'Free', go: 'Go', plus: 'Plus', pro: 'Pro', prolite: 'Pro Lite', team: 'Team',
business: 'Business', enterprise: 'Enterprise', edu: 'Edu', education: 'Education',
guest: 'Guest',
};
if (known[key]) return known[key];
return key.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
function _windowViewModel(win, nowSeconds) {
if (!win || typeof win !== 'object') return null;
const used = _clampPercent(win.used_percent);
const remaining = used === null ? _clampPercent(win.remaining_percent) : Math.round((100 - used) * 100) / 100;
const windowMinutes = _num(win.window_minutes);
const resetsAt = _num(win.resets_at);
return {
kind: typeof win.kind === 'string' ? win.kind : '',
name: typeof win.name === 'string' && win.name ? win.name : 'LIMIT',
usedPercent: used,
remainingPercent: remaining,
usedLabel: used === null ? 'usage unknown' : `${formatPercent(used)} used`,
remainingLabel: remaining === null ? '' : `${formatPercent(remaining)} remaining`,
windowMinutes,
resetsAt,
resetLabel: formatResetIn(resetsAt, nowSeconds),
exhausted: used !== null && used >= 100,
};
}
/**
* Build a render-ready view model from the backend usage payload.
*
* `payload` is the JSON body of the usage route. `nowSeconds` is injected so
* reset countdowns are deterministic in tests.
*/
export function buildUsageViewModel(payload, nowSeconds = Math.floor(Date.now() / 1000)) {
const account = (payload && typeof payload.account === 'object' && payload.account) || {};
const base = {
authId: typeof account.auth_id === 'string' ? account.auth_id : '',
label: typeof account.label === 'string' ? account.label : '',
name: typeof account.name === 'string' ? account.name : '',
};
if (!payload || typeof payload !== 'object' || payload.available !== true || !payload.usage || typeof payload.usage !== 'object') {
const reason = payload && typeof payload.reason === 'string' ? payload.reason : 'unavailable';
const messages = {
reauth: 'Usage unavailable — account may need reconnecting',
rate_limited: 'Usage temporarily unavailable (rate limited)',
timeout: 'Usage unavailable (timed out)',
network: 'Usage unavailable (network)',
malformed: 'Usage unavailable (unexpected response)',
upstream: 'Usage unavailable',
};
return {
...base,
available: false,
reason,
reconnectSuggested: !!(payload && payload.reconnect_suggested),
message: messages[reason] || 'Usage unavailable',
plan: '',
limits: [],
};
}
const usage = payload.usage;
const limits = [];
const rawLimits = Array.isArray(usage.limits) ? usage.limits : [];
rawLimits.forEach((limit) => {
if (!limit || typeof limit !== 'object') return;
const windows = (Array.isArray(limit.windows) ? limit.windows : [])
.map((w) => _windowViewModel(w, nowSeconds))
.filter(Boolean);
const limitId = typeof limit.limit_id === 'string' ? limit.limit_id : '';
const limitName = typeof limit.limit_name === 'string' ? limit.limit_name : '';
limits.push({
limitId,
title: limitId === 'codex' ? '' : (limitName || limitId || 'Additional limit'),
modelSlug: typeof limit.normal_model_slug === 'string' ? limit.normal_model_slug : '',
limitReached: limit.limit_reached === true,
windows,
});
});
return {
...base,
available: true,
reason: '',
reconnectSuggested: false,
message: '',
plan: planDisplayName(usage.plan_type),
ordinaryUsageAllowed: typeof usage.ordinary_usage_allowed === 'boolean' ? usage.ordinary_usage_allowed : null,
rateLimitReachedType: typeof usage.rate_limit_reached_type === 'string' ? usage.rate_limit_reached_type : '',
cached: payload.usage.cached === true,
fetchedAt: _num(usage.fetched_at),
limits,
hasWindows: limits.some((l) => l.windows.length > 0),
};
}
function _barHtml(win, esc) {
const used = win.usedPercent === null ? 0 : win.usedPercent;
const tone = win.usedPercent === null ? 'unknown' : (used >= 90 ? 'critical' : (used >= 70 ? 'warn' : 'ok'));
return (
`<div class="adm-chatgpt-usage-row" data-usage-window="${esc(win.kind || win.name)}">` +
`<div class="adm-chatgpt-usage-head">` +
`<span class="adm-chatgpt-usage-name">${esc(win.name)}</span>` +
`<span class="adm-chatgpt-usage-remaining">${esc(win.remainingLabel || win.usedLabel)}</span>` +
`</div>` +
`<div class="adm-chatgpt-usage-bar adm-chatgpt-usage-${tone}" role="progressbar" aria-valuemin="0" aria-valuemax="100"` +
(win.usedPercent === null ? '' : ` aria-valuenow="${Math.round(used)}"`) +
` aria-label="${esc(win.name)} ${esc(win.usedLabel)}">` +
`<span class="adm-chatgpt-usage-fill" style="width:${Math.max(0, Math.min(100, used)).toFixed(0)}%"></span>` +
`</div>` +
`<div class="adm-chatgpt-usage-meta">` +
`<span>${esc(win.usedLabel)}</span>` +
(win.resetLabel ? `<span>${esc(win.resetLabel)}</span>` : '') +
`</div>` +
`</div>`
);
}
/**
* Render the compact usage card body for one account. `esc` is the host
* page's HTML escaper (defaults to a local one). Buttons carry data
* attributes with the exact auth/endpoint ids so handlers target only this
* account.
*/
export function renderUsageCardHtml(viewModel, options = {}) {
const esc = typeof options.esc === 'function' ? options.esc : _defaultEsc;
const vm = viewModel || {};
const authId = esc(vm.authId || '');
const endpointId = esc(options.endpointId || '');
const buttons =
`<div class="adm-chatgpt-usage-actions">` +
`<button type="button" class="admin-btn-sm" data-adm-chatgpt-usage-refresh="${authId}" data-chatgpt-endpoint-id="${endpointId}">Refresh usage</button>` +
`<button type="button" class="admin-btn-sm" data-adm-chatgpt-reconnect="${authId}" data-chatgpt-endpoint-id="${endpointId}">Reconnect</button>` +
`</div>`;
if (!vm.available) {
return (
`<div class="adm-chatgpt-usage adm-chatgpt-usage-unavailable" data-adm-chatgpt-usage="${authId}">` +
`<div class="adm-chatgpt-usage-status">${esc(vm.message || 'Usage unavailable')}</div>` +
buttons +
`</div>`
);
}
const plan = vm.plan ? `<span class="admin-badge adm-chatgpt-plan">${esc(vm.plan)}</span>` : '';
const limitsHtml = (vm.limits || []).map((limit) => {
const title = limit.title
? `<div class="adm-chatgpt-usage-limit-title">${esc(limit.title)}${limit.modelSlug ? ` <span class="adm-chatgpt-usage-model">${esc(limit.modelSlug)}</span>` : ''}</div>`
: '';
return `<div class="adm-chatgpt-usage-limit" data-usage-limit="${esc(limit.limitId)}">${title}${limit.windows.length ? limit.windows.map((w) => _barHtml(w, esc)).join('') : '<div class="adm-chatgpt-usage-status">No rate-limit windows reported</div>'}</div>`;
}).join('');
const blocked = vm.ordinaryUsageAllowed === false
? `<div class="adm-chatgpt-usage-status adm-chatgpt-usage-blocked">Usage currently blocked${vm.rateLimitReachedType ? ` (${esc(vm.rateLimitReachedType.replace(/_/g, ' '))})` : ''}</div>`
: '';
return (
`<div class="adm-chatgpt-usage" data-adm-chatgpt-usage="${authId}">` +
(plan ? `<div class="adm-chatgpt-usage-plan">${plan}</div>` : '') +
blocked +
limitsHtml +
buttons +
`</div>`
);
}
/** True when an endpoint row from /api/model-endpoints is a ChatGPT account. */
export function isChatgptSubscriptionEndpoint(ep) {
return !!(ep && ep.provider === 'chatgpt-subscription' && ep.provider_auth_id);
}
/** Owner-visible account title, e.g. "ChatGPT · codex00". */
export function accountTitle(ep) {
if (!ep) return '';
if (ep.account_label) return `ChatGPT · ${ep.account_label}`;
return ep.name || 'ChatGPT Subscription';
}
export default {
buildUsageViewModel,
renderUsageCardHtml,
formatResetIn,
formatDuration,
formatPercent,
planDisplayName,
isChatgptSubscriptionEndpoint,
accountTitle,
};
+1 -1
View File
@@ -2,7 +2,7 @@
// Lightweight popup that surfaces the existing /command registry as users
// type. Reads COMMANDS from slashCommands.js — no command logic lives here.
import { COMMANDS, LEGACY_ALIASES } from './slashCommands.js?v=20260902tuiharness1';
import { COMMANDS, LEGACY_ALIASES } from './slashCommands.js?v=20260921chatgptusage1';
const POPUP_ID = 'slash-autocomplete';
const MAX_VISIBLE = 14;
+11 -4
View File
@@ -5035,16 +5035,22 @@ function _clearSetupCommandInput() {
}
}
async function _setupProviderDeviceFlow(providerKey) {
async function _setupProviderDeviceFlow(providerKey, options = {}) {
_clearSetupGuideMessages();
const config = PROVIDER_DEVICE_FLOWS[providerKey];
if (!config) {
await _setupReply('Provider not recognised.');
return;
}
await _setupReply(`Starting ${config.label} sign-in...`);
// `/setup chatgpt-subscription codex00` labels the new account so several
// ChatGPT subscriptions stay distinguishable. The label is cosmetic only.
const accountLabel = providerKey === 'chatgpt-subscription' ? String(options.label || '').trim().slice(0, 40) : '';
const formData = new FormData();
if (accountLabel) formData.append('label', accountLabel);
await _setupReply(`Starting ${config.label} sign-in${accountLabel ? ` for "${accountLabel}"` : ''}...`);
try {
const result = await runProviderDeviceFlow(providerKey, {
formData,
onStart: async ({ start, authUrl }) => {
const place = providerKey === 'copilot' ? 'GitHub' : 'OpenAI';
const action = providerKey === 'copilot' ? 'approve the request' : 'enter the code';
@@ -5067,7 +5073,8 @@ async function _setupProviderDeviceFlow(providerKey) {
});
if (result.status === 'authorized') {
const n = ((result.endpoint && result.endpoint.models) || []).length;
await _setupReply(`Connected - ${n} ${config.label} model${n !== 1 ? 's' : ''} available.`);
const connectedName = (result.endpoint && result.endpoint.name) || config.label;
await _setupReply(`Connected ${connectedName} - ${n} model${n !== 1 ? 's' : ''} available.`);
if (modelsModule) modelsModule.refreshModels(true);
return;
}
@@ -5091,7 +5098,7 @@ async function _cmdSetup(args, ctx) {
const topicArgs = args.slice(1);
const deviceAuthProvider = _setupDeviceAuthProviderFromInput(topic);
if (deviceAuthProvider) {
await _setupProviderDeviceFlow(deviceAuthProvider);
await _setupProviderDeviceFlow(deviceAuthProvider, { label: topicArgs.join(' ') });
return true;
}
const provider = _setupProviderFromInput(topic);
+1 -1
View File
@@ -8,7 +8,7 @@
// Mobile is excluded — tours position halos by rect math that doesn't fit
// the bottom-sheet layout cleanly.
import { handleSlashCommand } from './slashCommands.js?v=20260902tuiharness1';
import { handleSlashCommand } from './slashCommands.js?v=20260921chatgptusage1';
// Modal id → slash command to fire (without the leading "/"). Add to this
// map when a new feature picks up a `tour-*` command.
+73
View File
@@ -49548,6 +49548,79 @@ body.theme-frosted .modal {
line-height: 1.4;
color: color-mix(in srgb, var(--fg) 45%, transparent);
}
/* ChatGPT Subscription per-account usage card (Settings → Added Models) */
.adm-chatgpt-usage-host {
margin: 4px 0 6px 22px;
}
.adm-chatgpt-usage {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 11px;
}
.adm-chatgpt-usage-plan { display: flex; gap: 6px; align-items: center; }
.adm-chatgpt-usage-limit { display: flex; flex-direction: column; gap: 6px; }
.adm-chatgpt-usage-limit-title {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: color-mix(in srgb, var(--fg) 55%, transparent);
}
.adm-chatgpt-usage-model {
text-transform: none;
letter-spacing: 0;
font-family: var(--mono, ui-monospace, monospace);
opacity: 0.8;
}
.adm-chatgpt-usage-row { display: flex; flex-direction: column; gap: 3px; }
.adm-chatgpt-usage-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
}
.adm-chatgpt-usage-name {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
color: color-mix(in srgb, var(--fg) 70%, transparent);
}
.adm-chatgpt-usage-remaining { font-weight: 600; }
.adm-chatgpt-usage-bar {
position: relative;
height: 6px;
border-radius: 3px;
overflow: hidden;
background: color-mix(in srgb, var(--fg) 12%, transparent);
}
.adm-chatgpt-usage-fill {
display: block;
height: 100%;
border-radius: 3px;
background: var(--accent);
transition: width 0.25s ease;
}
.adm-chatgpt-usage-warn .adm-chatgpt-usage-fill { background: #d99a2b; }
.adm-chatgpt-usage-critical .adm-chatgpt-usage-fill { background: #d9534f; }
.adm-chatgpt-usage-unknown .adm-chatgpt-usage-fill { background: color-mix(in srgb, var(--fg) 25%, transparent); }
.adm-chatgpt-usage-meta {
display: flex;
justify-content: space-between;
gap: 8px;
font-size: 10px;
color: color-mix(in srgb, var(--fg) 50%, transparent);
}
.adm-chatgpt-usage-status {
font-size: 11px;
color: color-mix(in srgb, var(--fg) 60%, transparent);
}
.adm-chatgpt-usage-blocked { color: #d9534f; }
.adm-chatgpt-usage-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 2px; }
.adm-chatgpt-reconnect-status { font-size: 11px; }
/* Cookbook serve panel: Launch + ^ split button pair */
.hwfit-serve-launch-group {
display: inline-flex;
+5 -4
View File
@@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes.
const CACHE_NAME = 'odysseus-v649-large-tool-synthesis-scroll';
const CACHE_NAME = 'odysseus-v650-chatgpt-multi-account-usage';
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
// gives offline math fallback glyphs instead of proper typesetting.
@@ -39,7 +39,7 @@ const KATEX_FONTS = [
// exact URL the browser requests, query string included.
const PRECACHE = [
'/',
'/static/style.css?v=20260914pdfstrip1',
'/static/style.css?v=20260921chatgptusage1',
'/static/app.js?v=20260916autoscroll1',
'/static/js/storage.js',
'/static/js/appConfig.js',
@@ -75,9 +75,10 @@ const PRECACHE = [
'/static/js/theme.js?v=20260911organsrain1',
'/static/js/censor.js',
'/static/js/settings.js?v=20260912writingstyle3',
'/static/js/admin.js?v=20260914toolschemaprofiles1',
'/static/js/admin.js?v=20260921chatgptusage1',
'/static/js/chatgptSubscriptionUsage.js',
'/static/js/init.js?v=20260829chatstyle12',
'/static/js/slashCommands.js?v=20260902tuiharness1',
'/static/js/slashCommands.js?v=20260921chatgptusage1',
'/static/js/research/jobs.js?v=20260910researcherrorpersist1',
'/static/js/emailInbox.js?v=20260914aireply4',
'/static/js/emailLibrary/utils.js',