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:
stocky789
2026-06-08 18:19:18 +10:00
committed by GitHub
parent ac94885c84
commit 1e0d9b92af
37 changed files with 3425 additions and 485 deletions
+49 -3
View File
@@ -5,7 +5,7 @@
import { COMMANDS, LEGACY_ALIASES } from './slashCommands.js';
const POPUP_ID = 'slash-autocomplete';
const MAX_VISIBLE = 12;
const MAX_VISIBLE = 14;
// Flatten the registry into a searchable list of leaf entries. Each entry is
// either a top-level command or a "cmd sub" pair (so subcommands get their
@@ -81,6 +81,23 @@ function _flatten() {
return out;
}
async function _loadSkillEntries() {
try {
const res = await fetch('/api/skills/slash-catalog', { credentials: 'same-origin' });
if (!res.ok) return [];
const data = await res.json();
return (Array.isArray(data.skills) ? data.skills : []).map(s => ({
token: s.token || `/${s.name}`,
aliases: [],
category: s.category || 'Skills',
help: s.help || 'Run skill',
usage: s.usage || `${s.token || `/${s.name}`} <request>`,
})).filter(e => e.token && e.token.startsWith('/'));
} catch {
return [];
}
}
function _scoreMatch(entry, query) {
// query already starts with "/". Match against token + aliases. Prefix wins
// over substring; alias match scores slightly lower than token match.
@@ -98,6 +115,17 @@ function _scoreMatch(entry, query) {
return 0;
}
function _exactCommandGroupItems(all, query) {
const q = query.toLowerCase();
if (!/^\/[a-z0-9_-]+$/i.test(q)) return [];
const parent = all.find(entry => entry.token.toLowerCase() === q);
if (!parent) return [];
const prefix = q + ' ';
const children = all.filter(entry => entry.token.toLowerCase().startsWith(prefix));
if (!children.length) return [];
return children.concat(parent);
}
function _ensurePopup(textarea) {
let el = document.getElementById(POPUP_ID);
if (el) return el;
@@ -164,7 +192,7 @@ export function initSlashAutocomplete(textarea) {
if (!textarea || textarea._slashAcWired) return;
textarea._slashAcWired = true;
const all = _flatten();
let all = _flatten();
let popup = null;
let visible = false;
let items = [];
@@ -191,12 +219,17 @@ export function initSlashAutocomplete(textarea) {
// the menu hides — we don't autocomplete mid-sentence.
if (!v.startsWith('/') || v.includes('\n')) { hide(); return; }
const query = v.trim();
items = all
const groupItems = _exactCommandGroupItems(all, query);
if (groupItems.length) {
items = groupItems.slice(0, MAX_VISIBLE);
} else {
items = all
.map(e => ({ e, s: _scoreMatch(e, query) }))
.filter(x => x.s > 0)
.sort((a, b) => b.s - a.s)
.slice(0, MAX_VISIBLE)
.map(x => x.e);
}
if (!items.length && query.length > 1) { hide(); return; }
if (!items.length) {
// Just "/" with no matches — fall back to showing everything up to MAX_VISIBLE
@@ -207,6 +240,19 @@ export function initSlashAutocomplete(textarea) {
_render(popup, items, selectedIdx, query);
};
_loadSkillEntries().then(skillEntries => {
if (!skillEntries.length) return;
const seen = new Set(all.map(e => e.token));
const merged = all.slice();
for (const entry of skillEntries) {
if (seen.has(entry.token)) continue;
seen.add(entry.token);
merged.push(entry);
}
all = merged;
if (visible) refresh();
});
const insert = (token) => {
textarea.value = token + ' ';
textarea.dispatchEvent(new Event('input', { bubbles: true }));