feat: add verified desktop ChatGPT subscription connection

This commit is contained in:
amosblomqvist
2026-10-24 12:17:08 +03:00
parent ae2fad56c4
commit 0fc6d4ab94
53 changed files with 2557 additions and 140 deletions
+49 -10
View File
@@ -7,6 +7,10 @@ import {
type TeachingAgent,
} from "../agent/teacher-agent.ts";
import { piEngineForSpec, type PiEngine } from "../agent/models.ts";
import { chatGPTBackgroundAllowed, chatGPTScopeCurrent } from "../agent/chatgpt-bridge.ts";
import { chatGPTStreamError } from "../agent/chatgpt-stream.ts";
import { ChatGPTRequestCycle, type ChatGPTDispatchLifecycle } from "../agent/chatgpt-request-cycle.ts";
import { reconstructRequestCause } from "../core/native-messages.ts";
import { createJobRunner } from "../agent/subagents/runner.ts";
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
import type { ModelRole } from "../../config.ts";
@@ -15,6 +19,7 @@ import {
noteSteerVisible,
reconcileUserSteers,
registerLiveAgent,
restoreUncommittedAutomatic,
setJobRespawn,
setTurnRunner,
settleSteers,
@@ -41,7 +46,7 @@ import { persistTurn, triggerExtraction } from "./turn-service.ts";
import { hasProbeDone, danglingQuestionOf } from "../core/native-messages.ts";
import { composeInstructions } from "../agent/teacher-agent.ts";
import { ensureCompacted, kickCompaction, effectiveThresholdTokens } from "./compaction.ts";
import { catalogContextLimits } from "./model-catalog.ts";
import { catalogContextLimits, refreshConnectionCatalog } from "./model-catalog.ts";
import { emit } from "../core/events.ts";
import { settledTurnResponse } from "./turn-runs.ts";
import { asAgentMessages } from "../core/native-messages.ts";
@@ -201,6 +206,12 @@ export async function handleChatInput(
// persist (nothing durable changes); wake-pipeline turns ride the same
// path. The affected tier is strong — the conversation chain's own tier
// (a chat.model override still resolves through it).
if (conversation.provider === "chatgpt") {
await refreshConnectionCatalog();
if (input.type === "steers" && !chatGPTBackgroundAllowed()) {
return Response.json({ error: "Background use is off for this ChatGPT account. Automatic follow-up remains deferred in its queue." }, { status: 403 });
}
}
if (!providerKeyPresent(conversation.provider)) {
const envelope = providerKeyMissingEnvelope(conversation.provider, ["strong"]);
console.info(`[chat ${chatId}] turn refused — ${JSON.stringify(envelope)}`);
@@ -216,12 +227,22 @@ export async function handleChatInput(
// placement mirrors the BYOK/key gates — BEFORE appends and
// beginTurn, no liveness effects — and the record re-reads after
// because compaction appends the marker.
const conversationEngine = piEngineForSpec(conversation);
const callPurpose = { background: input.type === "steers" || (input.type === "continue" && reconstructRequestCause(record.messages) !== "foreground") };
let requestCycle: ChatGPTRequestCycle | undefined;
const lifecycle: ChatGPTDispatchLifecycle = {
captured: (scope) => requestCycle?.captured(scope),
beforeDispatch: (scope) => requestCycle?.beforeDispatch(scope),
noDispatch: (kind) => requestCycle?.noDispatch(kind),
};
let conversationEngine: PiEngine;
try { conversationEngine = piEngineForSpec(conversation, () => callPurpose.background, lifecycle); }
catch { return Response.json({ error: conversation.provider === "chatgpt" ? chatGPTStreamError("model catalog") : "The selected model is unavailable." }, { status: 503 }); }
const modelIdForCompaction = `${conversationEngine.model.provider}:${conversationEngine.model.id}`;
await ensureCompacted(
{
chatStore: stores.chatStore,
engine: conversationEngine,
engine: piEngineForSpec(conversation, true),
canStart: () => conversation.provider !== "chatgpt" || chatGPTBackgroundAllowed(),
modelId: modelIdForCompaction,
kind: record.kind === "practice" ? "practice" : hasProbeDone(record.messages) ? "teach" : "probe",
thresholdTokens: effectiveThresholdTokens(
@@ -287,12 +308,14 @@ export async function handleChatInput(
// ride this same write-ahead - the pump de-queued them at fire, so this
// is the ONLY copy ever appended.)
const merged = [...record.messages, ...appends];
const stagedAutomatic = conversation.provider === "chatgpt" && input.type === "steers";
let persistBaseCount = stagedAutomatic ? record.messages.length : merged.length;
try {
stores.chatStore.saveMessages(chatId, merged);
if (!stagedAutomatic) stores.chatStore.saveMessages(chatId, merged);
// Dock fix (owner ruling 2026-09-26): persisted markers are visible
// in the record — flip their jobs now (the wake's steers ride this
// write-ahead; the delivering turn may run for minutes).
for (const appended of appends) {
for (const appended of stagedAutomatic ? [] : appends) {
if ((appended as { role?: string }).role === "custom") noteSteerVisible(appended as SteerMessage);
}
} catch (err) {
@@ -339,8 +362,19 @@ export async function handleChatInput(
// enabled). The definitive toggleable set is TOGGLEABLE_TOOLS — the
// sub-agent JOB tools (research/demo/visual/pdf; adjudicated with the
// user: quiz/ask/fs/plan/probe_done are always-on).
if (conversation.provider === "chatgpt") requestCycle = new ChatGPTRequestCycle({
initialMessages: asAgentMessages(merged),
staged: stagedAutomatic ? asAgentMessages(appends) : undefined,
foregroundOwed: !callPurpose.background,
purpose: callPurpose,
scopeCurrent: chatGPTScopeCurrent,
commitStaged: () => { stores.chatStore.saveMessages(chatId, merged); persistBaseCount = merged.length; },
restoreAutomatic: (automaticIds, retainedIds) => restoreUncommittedAutomatic(chatId, constructed, automaticIds, retainedIds),
});
const agentOpts: AgentBuildOptions = {
engine,
callPurpose,
requestCycle,
messages: asAgentMessages(merged),
onUsage: collectUsage,
toolToggles: effective.agent.tools,
@@ -439,6 +473,7 @@ export async function handleChatInput(
providerEventNote(chatId, "turn", `run start (base: ${merged.length} messages)`);
const handle = runPiTurn({
agent: constructed,
requestCycle,
// The coalescing window seam (COMPACTION LAW, 2026-10-09): tests
// tighten it via LH_UPDATE_FLUSH_MS; production rides the 100ms
// default in ui-stream.
@@ -454,13 +489,14 @@ export async function handleChatInput(
// route through here: they ride the error-result path and reach the
// transcript directly (the retry context the model saw).
onError: (error) => {
if (conversation.provider === "chatgpt") return chatGPTStreamError(String(error));
console.error(`[chat ${chatId}] turn error:`, error);
return "An error occurred.";
},
onFinish: (turnMessages, isAborted) => {
// Wave B: the record write is the agent's own final state —
// the new messages are the slice past the merged base.
const stamped = persistTurn(stores.chatStore, chatId, merged.length, turnMessages, isAborted);
const stamped = persistTurn(stores.chatStore, chatId, persistBaseCount, turnMessages, isAborted);
// usage log append (idempotent per turn id) — the stats bar
// refolds this on the client after the turn settles. The
// provenance message is the turn's FIRST new assistant message.
@@ -479,7 +515,7 @@ export async function handleChatInput(
// PRACTICE (owner, 2026-10-04): practice sessions write nothing —
// no extraction, no graph, no coverage (v1: the session itself
// is the deliverable).
if (record.kind !== "practice" && extractionEnabled(effective.graph)) {
if ((!requestCycle || requestCycle.dispatched) && record.kind !== "practice" && extractionEnabled(effective.graph)) {
// Fresh per-turn read of the graph section's chunk budget
// (extraction-UX redesign, 2026-09-24): the trigger compares
// against it and the pipeline's batches share it.
@@ -520,18 +556,20 @@ export async function handleChatInput(
// beneath-threshold record is a silent no-op. Skipped under
// tests (the extraction precedent — scripted chats are
// fixtures; the service itself is pinned directly).
if (process.env.NODE_ENV !== "test")
if (process.env.NODE_ENV !== "test" && (!requestCycle || requestCycle.dispatched))
kickCompaction(() => {
const fresh = effectiveSettings();
const spec = resolveConversationSpec(stores.chatStore.getChat(chatId)?.settings, fresh.models.strong);
if (!spec) return null;
const kickEngine = piEngineForSpec(spec);
if (!spec || (spec.provider === "chatgpt" && !chatGPTBackgroundAllowed())) return null;
let kickEngine: PiEngine;
try { kickEngine = piEngineForSpec(spec, true); } catch { return null; }
const kickModelId = `${kickEngine.model.provider}:${kickEngine.model.id}`;
const freshRecord = stores.chatStore.getChat(chatId);
if (!freshRecord) return null;
return {
chatStore: stores.chatStore,
engine: kickEngine,
canStart: () => spec.provider !== "chatgpt" || chatGPTBackgroundAllowed(),
modelId: kickModelId,
kind: freshRecord.kind === "practice" ? "practice" : hasProbeDone(freshRecord.messages) ? "teach" : "probe",
thresholdTokens: effectiveThresholdTokens(
@@ -558,6 +596,7 @@ export async function handleChatInput(
// must cost zero permanently-held slots beyond the one live view. The
// turn itself is a server-side process (ruling 2026-09-25) — it never
// needed this connection, only a view did.
if (stagedAutomatic && requestCycle && !(await requestCycle.admission)) return Response.json({ error: "Automatic follow-up remains deferred in its queue." }, { status: 403 });
return Response.json({ ok: true });
} catch (err) {
// The stream never started, so no onFinish will fire — clear the registry