feat: add app-owned Copilot and Grok subscription connections

This commit is contained in:
amosblomqvist
2026-10-25 00:27:52 +03:00
parent e8c81faad9
commit d6e317c6fe
49 changed files with 1709 additions and 248 deletions
+4 -122
View File
@@ -1,122 +1,4 @@
import type { AgentEvent, AgentMessage, PrepareRequestContext } from "@earendil-works/pi-agent-core";
import type { AssistantMessage } from "@earendil-works/pi-ai";
import { nativeInputCause, needsContinuation, type LhMessage } from "../core/native-messages.ts";
export interface AdmissionScope { accountId: string; epoch: number; authorityRevision: number; selectedModelId: string }
export type NoDispatchKind = "background" | "all" | "other";
export interface ChatGPTDispatchLifecycle {
captured(scope: AdmissionScope): void;
beforeDispatch(scope: AdmissionScope): void;
noDispatch(kind: NoDispatchKind): void;
}
interface CycleOptions {
initialMessages: AgentMessage[];
staged?: AgentMessage[];
foregroundOwed: boolean;
purpose: { background: boolean };
scopeCurrent: (scope: AdmissionScope) => boolean;
commitStaged?: () => void;
restoreAutomatic: (automaticIds: ReadonlySet<string>, retainedIds: ReadonlySet<string>) => void;
}
const idOf = (m: AgentMessage) => (m as { id?: string }).id;
/** One logical turn, not an SDK loop. No credentials, persisted provenance or wire protocol. */
export class ChatGPTRequestCycle implements ChatGPTDispatchLifecycle {
private release?: (event: AgentEvent) => void;
private held: AgentEvent[] = [];
private snapshot: AgentMessage[] = [];
private seen: Set<string>;
private automatic = new Set<string>();
private committed = false;
private denied?: NoDispatchKind;
private scope?: AdmissionScope;
private originalScope?: AdmissionScope;
private foregroundOwed: boolean;
private inheritedBackground: boolean;
private resumed = false;
private userAborted = false;
private stagedCommitted = false;
private started = false;
private anyDispatch = false;
private firstResolved = false;
private resolveAdmission!: (admitted: boolean) => void;
readonly admission = new Promise<boolean>((resolve) => { this.resolveAdmission = resolve; });
constructor(private readonly options: CycleOptions) {
this.seen = new Set(options.initialMessages.map(idOf).filter((id): id is string => !!id));
for (const message of options.staged ?? []) { const id = idOf(message); if (id) this.seen.delete(id); }
this.foregroundOwed = options.foregroundOwed;
this.inheritedBackground = options.purpose.background;
}
get dispatched(): boolean { return this.anyDispatch; }
get deniedBeforeDispatch(): boolean { return this.denied !== undefined; }
abort(): void { this.userAborted = true; }
bindRelease(release: (event: AgentEvent) => void): () => void { this.release = release; return () => { this.release = undefined; }; }
prepare(request: PrepareRequestContext): void {
const messages = request.context.messages as AgentMessage[];
this.snapshot = [...messages]; this.automatic = new Set();
let learner = false;
for (const m of messages) {
const id = idOf(m); if (!id || this.seen.has(id)) continue;
const cause = nativeInputCause(m, messages.slice(0, messages.indexOf(m)));
if (cause === "automatic") this.automatic.add(id);
if (cause === "learner") learner = true;
}
if (this.automatic.size > 0) this.options.purpose.background = true;
const retained = messages.filter((m) => !this.automatic.has(idOf(m) ?? ""));
this.foregroundOwed ||= learner || (!this.inheritedBackground && needsContinuation(retained as LhMessage[]));
}
hold(event: AgentEvent): boolean {
if (event.type === "agent_start") { if (this.started) return true; this.started = true; return false; }
if (event.type === "agent_end") return true; // settle only after native activeRun is cleared
if (event.type === "turn_start") {
this.committed = false; this.denied = undefined; this.scope = undefined;
// A retained learner batch from the denied cycle already owns its turn_start.
if (!this.held.some((e) => e.type === "turn_start")) this.held.push(event);
return true;
}
if (!this.committed) { this.held.push(event); return true; }
return false;
}
captured(scope: AdmissionScope): void {
if (this.originalScope && this.resumed && JSON.stringify(scope) !== JSON.stringify(this.originalScope)) throw new Error("ChatGPT recovery scope changed");
this.scope = { ...scope };
this.originalScope ??= { ...scope };
}
beforeDispatch(scope: AdmissionScope): void {
if (this.userAborted || this.denied || this.committed || !this.scope || JSON.stringify(scope) !== JSON.stringify(this.scope) || !this.options.scopeCurrent(scope)) throw new Error("ChatGPT admission cancelled");
if (!this.stagedCommitted && (this.options.staged?.length ?? 0) > 0) {
this.options.commitStaged?.(); // synchronous durable prefix, before visibility and fetch
this.stagedCommitted = true;
const stagedEvents: AgentEvent[] = this.options.staged!.flatMap((message) => [{ type: "message_start" as const, message }, { type: "message_end" as const, message }]);
const boundary = this.held.findIndex((event) => event.type === "turn_start");
this.held.splice(boundary < 0 ? 0 : boundary + 1, 0, ...stagedEvents);
}
this.committed = true; this.anyDispatch = true; this.foregroundOwed = false;
this.inheritedBackground = this.options.purpose.background;
for (const m of this.snapshot) { const id = idOf(m); if (id) this.seen.add(id); }
const events = this.held; this.held = [];
for (const event of events) this.release?.(event);
this.resolveFirst(true);
}
noDispatch(kind: NoDispatchKind): void { if (!this.committed) this.denied = this.userAborted ? "all" : kind; }
acceptsUsage(_message: AssistantMessage): boolean { return this.committed; }
private resolveFirst(value: boolean) { if (!this.firstResolved) { this.firstResolved = true; this.resolveAdmission(value); } }
finishNativeCycle(): { action: "settle" | "resumeForeground"; messages: AgentMessage[] } {
if (!this.denied) { this.resolveFirst(this.anyDispatch); return { action: "settle", messages: [] }; }
const messages = this.snapshot.filter((m) => !this.automatic.has(idOf(m) ?? ""));
const retainedIds = new Set(messages.map(idOf).filter((id): id is string => !!id));
this.options.restoreAutomatic(this.automatic, retainedIds);
this.resolveFirst(false); // queue deferral is installed before initial fire cargo returns
this.held = this.held.filter((e) => (e.type === "turn_start") || ((e.type === "message_start" || e.type === "message_end") && e.message.role !== "assistant" && !this.automatic.has(idOf(e.message) ?? "")));
const retry = this.denied === "background" && !this.userAborted && !this.resumed && this.foregroundOwed && !!this.scope && this.options.scopeCurrent(this.scope);
if (retry) {
this.resumed = true; this.options.purpose.background = false;
this.seen = retainedIds;
this.denied = undefined;
// Staged automatic input must not be resurrected on foreground recovery.
this.options.staged = undefined;
return { action: "resumeForeground", messages };
}
this.held = [];
return { action: "settle", messages };
}
}
/** Compatibility surface: ChatGPT keeps its scope/behavior while all subscription
* connections share the same provider-neutral request-admission state machine. */
export { SubscriptionRequestCycle as ChatGPTRequestCycle } from "./subscription-request-cycle.ts";
export type { AdmissionScope, NoDispatchKind, SubscriptionDispatchLifecycle as ChatGPTDispatchLifecycle } from "./subscription-request-cycle.ts";
+6 -2
View File
@@ -15,6 +15,8 @@ import packageMetadata from "../../package.json" with { type: "json" };
import { chatGPTModel } from "./chatgpt-bridge.ts";
import { chatGPTStream } from "./chatgpt-stream.ts";
import type { ChatGPTDispatchLifecycle } from "./chatgpt-request-cycle.ts";
import { nativeSubscriptionId, nativeSubscriptionModel } from "./subscription-bridge.ts";
import { nativeSubscriptionStream } from "./subscription-stream.ts";
/** App identity is immutable and shared by Models and every native Agent.
* Agent's default Pi attribution otherwise overrides the Models identity. */
@@ -98,7 +100,8 @@ function defaultThinkingLevel(model: Model<Api>): ThinkingLevel {
* hardcodes OpenRouter's. */
export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | (() => boolean) = true, lifecycle?: ChatGPTDispatchLifecycle): PiEngine {
const models: Models = piModels();
const model = spec.provider === "chatgpt" ? chatGPTModel(spec.id) : models.getModel(spec.provider, spec.id);
const nativeId = nativeSubscriptionId(spec.provider);
const model = spec.provider === "chatgpt" ? chatGPTModel(spec.id) : nativeId ? nativeSubscriptionModel(spec.provider, spec.id) : models.getModel(spec.provider, spec.id);
if (!model) {
throw new Error(`${spec.provider} catalog has no model ${spec.id}`);
}
@@ -115,7 +118,8 @@ export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | (
// `reasoning` (undefined = off on pi-ai's scale); the clamp above keeps
// the pin honest even when config or settings drift off the map.
streamFn: (requestModel, context, options) => {
const stream = spec.provider === "chatgpt" ? chatGPTStream(typeof background === "function" ? background : () => background, lifecycle) : models.streamSimple.bind(models);
const purpose = typeof background === "function" ? background : () => background;
const stream = spec.provider === "chatgpt" ? chatGPTStream(purpose, lifecycle) : nativeId ? nativeSubscriptionStream(models.getProvider(nativeId)!, purpose, lifecycle) : models.streamSimple.bind(models);
return stream(requestModel, context, { ...options, reasoning: level === "off" ? undefined : level });
},
};
+2
View File
@@ -2,6 +2,7 @@ import { Agent, type AgentEvent, type AgentMessage, type AgentTool } from "@eare
import type { AssistantMessage, ToolResultMessage, Usage } from "@earendil-works/pi-ai";
import { randomUUID } from "node:crypto";
import { APP_IDENTITY, piEngineFor, type PiEngine } from "../models.ts";
import { usageWasAdmitted } from "../subscription-request-cycle.ts";
import { providerEventObserver } from "../provider-events.ts";
import type { ModelRole } from "../../../config.ts";
@@ -107,6 +108,7 @@ export async function runSubagent(tools: AgentTool<any, any>[], opts: SubagentRu
aborted = true;
break;
}
if (!usageWasAdmitted(m.usage)) { if (m.stopReason === "error") errorMessage = m.errorMessage; break; }
step++;
opts.onUsage?.(m.usage);
opts.onAssistantMessage?.(m, step);
+132
View File
@@ -0,0 +1,132 @@
import type { Api, Model, ModelAuth } from "@earendil-works/pi-ai";
import { NATIVE_SUBSCRIPTIONS, type NativeSubscriptionProviderId, type NativeSubscriptionScope, type NativeSubscriptionSnapshot, type NativeSubscriptionStatus } from "../core/contracts/subscriptions.ts";
import type { AdmissionScope, NoDispatchKind } from "./chatgpt-request-cycle.ts";
interface Pending { op: string; resolve(value: unknown): void; reject(error: Error): void; timer: ReturnType<typeof setTimeout> }
interface State {
statuses: Map<NativeSubscriptionProviderId, NativeSubscriptionStatus>;
models: Map<NativeSubscriptionProviderId, Model<Api>[]>;
pending: Map<string, Pending>;
controllers: Map<AbortController, { providerId: NativeSubscriptionProviderId; background: boolean }>;
listeners: Set<() => void>;
counter: number;
}
const KEY = Symbol.for("lh.subscriptions.bridge");
export function nativeSubscriptionId(logicalId: string): NativeSubscriptionProviderId | undefined {
return NATIVE_SUBSCRIPTIONS.find((entry) => entry.logicalId === logicalId)?.providerId;
}
function desktopConnected(): boolean {
return process.env.LH_DESKTOP_SERVER === "1" && (process.env.LH_DESKTOP_API_SECRET?.length ?? 0) >= 64 && typeof process.send === "function" && process.connected === true;
}
function notify(state: State) { for (const listener of state.listeners) { try { listener(); } catch { /* observer isolation */ } } }
function invalidate(state: State, providerId?: NativeSubscriptionProviderId) {
for (const [controller, owner] of state.controllers) if (!providerId || owner.providerId === providerId) controller.abort("all");
if (providerId) { state.models.delete(providerId); state.statuses.delete(providerId); }
else { state.models.clear(); state.statuses.clear(); }
}
function accept(state: State, snapshot: NativeSubscriptionSnapshot) {
if (!snapshot || !Array.isArray(snapshot.statuses) || !snapshot.models) return;
let changed = false;
for (const entry of snapshot.statuses) {
if (!NATIVE_SUBSCRIPTIONS.some((p) => p.providerId === entry.providerId) || ![entry.revision, entry.epoch, entry.authorityRevision].every((n) => Number.isSafeInteger(n) && n >= 0)) continue;
const previous = state.statuses.get(entry.providerId);
if (previous && entry.revision < previous.revision) continue;
const all = !!previous && (entry.connectionId !== previous.connectionId || entry.epoch !== previous.epoch || entry.authorityRevision !== previous.authorityRevision || !entry.available || !entry.connected);
for (const [controller, owner] of state.controllers) {
if (owner.providerId !== entry.providerId) continue;
if (all) controller.abort("all");
else if (owner.background && !entry.allowBackgroundUse) controller.abort("background");
}
state.statuses.set(entry.providerId, entry);
const descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.providerId === entry.providerId)!;
state.models.set(entry.providerId, entry.available && entry.connected ? (snapshot.models[entry.providerId] ?? []).filter((m) => m.provider === entry.providerId).map((m) => ({ ...m, provider: descriptor.logicalId })) : []);
changed ||= !previous || entry.revision !== previous.revision;
}
if (changed) notify(state);
}
function bridge(): State {
const global = globalThis as typeof globalThis & { [KEY]?: State };
if (!global[KEY]) {
const state: State = { statuses: new Map(), models: new Map(), pending: new Map(), controllers: new Map(), listeners: new Set(), counter: 0 };
global[KEY] = state;
process.on("message", (raw: unknown) => {
const message = raw as { channel?: string; op?: string; id?: string; ok?: boolean; value?: unknown; error?: string; snapshot?: NativeSubscriptionSnapshot };
if (!desktopConnected() || message?.channel !== "lh:subscriptions") return;
if (message.op === "changed" && message.snapshot) accept(state, message.snapshot);
if (!message.id) return;
const pending = state.pending.get(message.id);
if (!pending || pending.op !== message.op) return;
clearTimeout(pending.timer); state.pending.delete(message.id);
if (message.ok) pending.resolve(message.value);
else pending.reject(new Error(message.error ?? "Subscription connection failed."));
});
process.on("disconnect", () => {
invalidate(state);
for (const pending of state.pending.values()) { clearTimeout(pending.timer); pending.reject(new Error("The desktop connection closed.")); }
state.pending.clear(); notify(state);
});
}
return global[KEY];
}
export function subscriptionRequest<T>(op: "snapshot" | "lease", fields: Record<string, unknown> = {}): Promise<T> {
const state = bridge();
if (!desktopConnected()) return Promise.reject(new Error("Subscription sign-in is available only in the desktop app with a private server connection."));
return new Promise<T>((resolve, reject) => {
const id = `${process.pid}:${++state.counter}`;
const timer = setTimeout(() => { state.pending.delete(id); reject(new Error("The subscription desktop connection timed out.")); }, 45000);
state.pending.set(id, { op, timer, resolve: (value) => resolve(value as T), reject });
process.send!({ ...fields, channel: "lh:subscriptions", id, op }, (error) => {
if (error) { clearTimeout(timer); state.pending.delete(id); reject(new Error("The subscription desktop connection closed.")); }
});
});
}
export async function refreshSubscriptionModels(): Promise<void> {
const state = bridge();
if (!desktopConnected()) { invalidate(state); return; }
try { accept(state, await subscriptionRequest<NativeSubscriptionSnapshot>("snapshot")); }
catch { invalidate(state); notify(state); }
}
export function nativeSubscriptionStatus(providerId: NativeSubscriptionProviderId): NativeSubscriptionStatus | undefined { return bridge().statuses.get(providerId); }
export function nativeSubscriptionConnected(providerId: NativeSubscriptionProviderId): boolean {
const status = nativeSubscriptionStatus(providerId);
return desktopConnected() && status?.available === true && status.connected && !!status.connectionId;
}
export function nativeSubscriptionBackgroundAllowed(providerId: NativeSubscriptionProviderId): boolean { return nativeSubscriptionConnected(providerId) && nativeSubscriptionStatus(providerId)?.allowBackgroundUse === true; }
export function nativeSubscriptionModels(): Model<Api>[] { return desktopConnected() ? [...bridge().models.values()].flat() : []; }
export function nativeSubscriptionModel(logicalId: string, modelId: string): Model<Api> | undefined { return nativeSubscriptionModels().find((m) => m.provider === logicalId && m.id === modelId); }
export function onNativeSubscriptionChange(listener: () => void): () => void { const state = bridge(); state.listeners.add(listener); return () => { state.listeners.delete(listener); }; }
export function subscriptionScope(providerId: NativeSubscriptionProviderId, selectedModelId: string): NativeSubscriptionScope {
const state = nativeSubscriptionStatus(providerId);
const logicalId = NATIVE_SUBSCRIPTIONS.find((p) => p.providerId === providerId)!.logicalId;
if (!state?.connectionId || !nativeSubscriptionConnected(providerId) || !nativeSubscriptionModel(logicalId, selectedModelId)) throw new Error("Subscription model catalog unavailable");
return { providerId, connectionId: state.connectionId, epoch: state.epoch, authorityRevision: state.authorityRevision, selectedModelId };
}
export function subscriptionScopeCurrent(scope: NativeSubscriptionScope): boolean {
try { return JSON.stringify(subscriptionScope(scope.providerId, scope.selectedModelId)) === JSON.stringify(scope); } catch { return false; }
}
/** Compatibility shape for the shared admission cycle; accountId is a local connection ID here. */
export function subscriptionAdmissionScope(scope: NativeSubscriptionScope): AdmissionScope {
return { accountId: scope.connectionId, epoch: scope.epoch, authorityRevision: scope.authorityRevision, selectedModelId: scope.selectedModelId };
}
export function nativeAdmissionCurrent(providerId: NativeSubscriptionProviderId, scope: AdmissionScope): boolean {
return subscriptionScopeCurrent({ providerId, connectionId: scope.accountId, epoch: scope.epoch, authorityRevision: scope.authorityRevision, selectedModelId: scope.selectedModelId });
}
export async function subscriptionLease(scope: NativeSubscriptionScope, background: boolean): Promise<ModelAuth> {
if (!subscriptionScopeCurrent(scope)) throw new Error("Subscription connection changed");
const result = await subscriptionRequest<{ scope: NativeSubscriptionScope; auth: ModelAuth }>("lease", { scope, background });
if (!result?.auth || !subscriptionScopeCurrent(scope) || JSON.stringify(result.scope) !== JSON.stringify(scope) || (!result.auth.apiKey && !Object.values(result.auth.headers ?? {}).some((value) => !!value))) throw new Error("Subscription lease is stale");
return result.auth;
}
export function subscriptionAbortController(providerId: NativeSubscriptionProviderId, background: boolean) {
const state = bridge(); const controller = new AbortController(); state.controllers.set(controller, { providerId, background });
return { controller, release: () => { state.controllers.delete(controller); } };
}
export async function subscriptionDenial(scope: NativeSubscriptionScope | undefined, controller: AbortController, callerAborted: boolean, background: boolean): Promise<NoDispatchKind> {
if (callerAborted || controller.signal.reason === "all") return "all";
if (!scope || !subscriptionScopeCurrent(scope)) return "other";
if (controller.signal.reason === "background") return "background";
if (!background) return "other";
await refreshSubscriptionModels();
if (!subscriptionScopeCurrent(scope)) return "all";
return nativeSubscriptionBackgroundAllowed(scope.providerId) ? "other" : "background";
}
+23
View File
@@ -0,0 +1,23 @@
import { chatGPTBackgroundAllowed, chatGPTConnected, chatGPTScopeCurrent, onChatGPTChange } from "./chatgpt-bridge.ts";
import { nativeSubscriptionId, nativeSubscriptionConnected, nativeSubscriptionBackgroundAllowed, nativeAdmissionCurrent, onNativeSubscriptionChange } from "./subscription-bridge.ts";
import type { AdmissionScope } from "./subscription-request-cycle.ts";
export function isSubscriptionProvider(provider: string | undefined): boolean { return provider === "chatgpt" || !!(provider && nativeSubscriptionId(provider)); }
export function subscriptionConnected(provider: string): boolean {
if (provider === "chatgpt") return chatGPTConnected();
const native = nativeSubscriptionId(provider); return !!native && nativeSubscriptionConnected(native);
}
/** Ordinary API connections have no subscription background-consent gate. */
export function connectionBackgroundAllowed(provider: string | undefined): boolean {
if (provider === "chatgpt") return chatGPTBackgroundAllowed();
const native = provider ? nativeSubscriptionId(provider) : undefined;
return native ? nativeSubscriptionBackgroundAllowed(native) : true;
}
export function connectionScopeCurrent(provider: string, scope: AdmissionScope): boolean {
if (provider === "chatgpt") return chatGPTScopeCurrent(scope);
const native = nativeSubscriptionId(provider); return !!native && nativeAdmissionCurrent(native, scope);
}
export function onSubscriptionChange(listener: () => void): () => void {
const stopChatGPT = onChatGPTChange(listener), stopNative = onNativeSubscriptionChange(listener);
return () => { stopChatGPT(); stopNative(); };
}
+128
View File
@@ -0,0 +1,128 @@
import type { AgentEvent, AgentMessage, PrepareRequestContext } from "@earendil-works/pi-agent-core";
import type { AssistantMessage } from "@earendil-works/pi-ai";
import { nativeInputCause, needsContinuation, type LhMessage } from "../core/native-messages.ts";
export interface AdmissionScope { accountId: string; epoch: number; authorityRevision: number; selectedModelId: string }
export type NoDispatchKind = "background" | "all" | "other";
const USAGE_KEY = Symbol.for("lh.subscription.unadmittedUsage");
const usageGlobal = globalThis as typeof globalThis & { [USAGE_KEY]?: WeakSet<object> };
const unadmittedUsage = usageGlobal[USAGE_KEY] ??= new WeakSet<object>();
/** Transient identity only: no new provenance field enters saved native messages. */
export function markUnadmittedUsage(usage: object): void { unadmittedUsage.add(usage); }
export function usageWasAdmitted(usage: object): boolean { return !unadmittedUsage.has(usage); }
export interface SubscriptionDispatchLifecycle {
captured(scope: AdmissionScope): void;
beforeDispatch(scope: AdmissionScope): void;
noDispatch(kind: NoDispatchKind): void;
}
interface CycleOptions {
initialMessages: AgentMessage[];
staged?: AgentMessage[];
foregroundOwed: boolean;
purpose: { background: boolean };
scopeCurrent: (scope: AdmissionScope) => boolean;
commitStaged?: () => void;
restoreAutomatic: (automaticIds: ReadonlySet<string>, retainedIds: ReadonlySet<string>) => void;
}
const idOf = (m: AgentMessage) => (m as { id?: string }).id;
/** One logical turn, not an SDK loop. No credentials, persisted provenance or wire protocol. */
export class SubscriptionRequestCycle implements SubscriptionDispatchLifecycle {
private release?: (event: AgentEvent) => void;
private held: AgentEvent[] = [];
private snapshot: AgentMessage[] = [];
private seen: Set<string>;
private automatic = new Set<string>();
private committed = false;
private denied?: NoDispatchKind;
private scope?: AdmissionScope;
private originalScope?: AdmissionScope;
private foregroundOwed: boolean;
private inheritedBackground: boolean;
private resumed = false;
private userAborted = false;
private stagedCommitted = false;
private started = false;
private anyDispatch = false;
private firstResolved = false;
private resolveAdmission!: (admitted: boolean) => void;
readonly admission = new Promise<boolean>((resolve) => { this.resolveAdmission = resolve; });
constructor(private readonly options: CycleOptions) {
this.seen = new Set(options.initialMessages.map(idOf).filter((id): id is string => !!id));
for (const message of options.staged ?? []) { const id = idOf(message); if (id) this.seen.delete(id); }
this.foregroundOwed = options.foregroundOwed;
this.inheritedBackground = options.purpose.background;
}
get dispatched(): boolean { return this.anyDispatch; }
get deniedBeforeDispatch(): boolean { return this.denied !== undefined; }
abort(): void { this.userAborted = true; }
bindRelease(release: (event: AgentEvent) => void): () => void { this.release = release; return () => { this.release = undefined; }; }
prepare(request: PrepareRequestContext): void {
const messages = request.context.messages as AgentMessage[];
this.snapshot = [...messages]; this.automatic = new Set();
let learner = false;
for (const m of messages) {
const id = idOf(m); if (!id || this.seen.has(id)) continue;
const cause = nativeInputCause(m, messages.slice(0, messages.indexOf(m)));
if (cause === "automatic") this.automatic.add(id);
if (cause === "learner") learner = true;
}
if (this.automatic.size > 0) this.options.purpose.background = true;
const retained = messages.filter((m) => !this.automatic.has(idOf(m) ?? ""));
this.foregroundOwed ||= learner || (!this.inheritedBackground && needsContinuation(retained as LhMessage[]));
}
hold(event: AgentEvent): boolean {
if (event.type === "agent_start") { if (this.started) return true; this.started = true; return false; }
if (event.type === "agent_end") return true; // settle only after native activeRun is cleared
if (event.type === "turn_start") {
this.committed = false; this.denied = undefined; this.scope = undefined;
// A retained learner batch from the denied cycle already owns its turn_start.
if (!this.held.some((e) => e.type === "turn_start")) this.held.push(event);
return true;
}
if (!this.committed) { this.held.push(event); return true; }
return false;
}
captured(scope: AdmissionScope): void {
if (this.originalScope && this.resumed && JSON.stringify(scope) !== JSON.stringify(this.originalScope)) throw new Error("Subscription recovery scope changed");
this.scope = { ...scope };
this.originalScope ??= { ...scope };
}
beforeDispatch(scope: AdmissionScope): void {
if (this.userAborted || this.denied || this.committed || !this.scope || JSON.stringify(scope) !== JSON.stringify(this.scope) || !this.options.scopeCurrent(scope)) throw new Error("Subscription admission cancelled");
if (!this.stagedCommitted && (this.options.staged?.length ?? 0) > 0) {
this.options.commitStaged?.(); // synchronous durable prefix, before visibility and fetch
this.stagedCommitted = true;
const stagedEvents: AgentEvent[] = this.options.staged!.flatMap((message) => [{ type: "message_start" as const, message }, { type: "message_end" as const, message }]);
const boundary = this.held.findIndex((event) => event.type === "turn_start");
this.held.splice(boundary < 0 ? 0 : boundary + 1, 0, ...stagedEvents);
}
this.committed = true; this.anyDispatch = true; this.foregroundOwed = false;
this.inheritedBackground = this.options.purpose.background;
for (const m of this.snapshot) { const id = idOf(m); if (id) this.seen.add(id); }
const events = this.held; this.held = [];
for (const event of events) this.release?.(event);
this.resolveFirst(true);
}
noDispatch(kind: NoDispatchKind): void { if (!this.committed) this.denied = this.userAborted ? "all" : kind; }
acceptsUsage(_message: AssistantMessage): boolean { return this.committed; }
private resolveFirst(value: boolean) { if (!this.firstResolved) { this.firstResolved = true; this.resolveAdmission(value); } }
finishNativeCycle(): { action: "settle" | "resumeForeground"; messages: AgentMessage[] } {
if (!this.denied) { this.resolveFirst(this.anyDispatch); return { action: "settle", messages: [] }; }
const messages = this.snapshot.filter((m) => !this.automatic.has(idOf(m) ?? ""));
const retainedIds = new Set(messages.map(idOf).filter((id): id is string => !!id));
this.options.restoreAutomatic(this.automatic, retainedIds);
this.resolveFirst(false); // queue deferral is installed before initial fire cargo returns
this.held = this.held.filter((e) => (e.type === "turn_start") || ((e.type === "message_start" || e.type === "message_end") && e.message.role !== "assistant" && !this.automatic.has(idOf(e.message) ?? "")));
const retry = this.denied === "background" && !this.userAborted && !this.resumed && this.foregroundOwed && !!this.scope && this.options.scopeCurrent(this.scope);
if (retry) {
this.resumed = true; this.options.purpose.background = false;
this.seen = retainedIds;
this.denied = undefined;
// Staged automatic input must not be resurrected on foreground recovery.
this.options.staged = undefined;
return { action: "resumeForeground", messages };
}
this.held = [];
return { action: "settle", messages };
}
}
+85
View File
@@ -0,0 +1,85 @@
import { normalizeContext, type Api, type AssistantMessage, type Model, type Provider, type SimpleStreamOptions, type StreamFunction, type TranscriptContext } from "@earendil-works/pi-ai";
import { AssistantMessageEventStream } from "@earendil-works/pi-ai/utils/event-stream";
import { transformMessages } from "@earendil-works/pi-ai/api/transform-messages";
import { NATIVE_SUBSCRIPTIONS, type NativeSubscriptionScope } from "../core/contracts/subscriptions.ts";
import { nativeSubscriptionId, nativeSubscriptionBackgroundAllowed, subscriptionScope, subscriptionScopeCurrent, subscriptionAdmissionScope, subscriptionLease, subscriptionAbortController, subscriptionDenial } from "./subscription-bridge.ts";
import { markUnadmittedUsage, type SubscriptionDispatchLifecycle } from "./subscription-request-cycle.ts";
export function nativeSubscriptionError(provider: string, message: string): string {
const label = NATIVE_SUBSCRIPTIONS.find((p) => p.logicalId === provider)?.label ?? "Subscription";
const suffix = " No API-key fallback was used.";
if (/background/i.test(message)) return `${label} background use is off in this app. Enable it in Providers to run this work.${suffix}`;
if (/quota|429|limit/i.test(message)) return `${label} usage limit reached. Check your provider account and try again later.${suffix}`;
if (/expired|invalid_grant|401|refresh/i.test(message)) return `${label} authorization could not be refreshed. Retry or reconnect in Providers.${suffix}`;
if (/cancel|abort|closed|disconnect/i.test(message)) return `${label} request cancelled or connection closed.${suffix}`;
if (/catalog|model|stale|changed/i.test(message)) return `The selected ${label} model or connection is unavailable. Refresh Providers and explicitly choose an available model.${suffix}`;
return `${label} request failed. Check the connection, account access and provider limits, then retry.${suffix}`;
}
export function subscriptionHistory(context: TranscriptContext, logical: Model<Api>, nativeId: string): TranscriptContext {
const messages = transformMessages(context.messages, logical);
return { ...context, messages: messages.map((message) => message.role === "assistant" && message.provider === logical.provider ? { ...message, provider: nativeId } : message) };
}
function empty(model: Model<Api>): AssistantMessage {
return { role: "assistant", api: model.api, provider: model.provider, model: model.id, content: [], stopReason: "error", timestamp: Date.now(), usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
}
/** Direct native provider dispatch bypasses Models' ambient credential resolution entirely. */
export function nativeSubscriptionStream(provider: Provider, background: () => boolean, lifecycle?: SubscriptionDispatchLifecycle): StreamFunction<Api, SimpleStreamOptions> {
return (logicalModel, context, options) => {
const output = new AssistantMessageEventStream();
const nativeId = nativeSubscriptionId(logicalModel.provider);
if (!nativeId || provider.id !== nativeId) {
const error = { ...empty(logicalModel), errorMessage: nativeSubscriptionError(logicalModel.provider, "model unavailable") }; markUnadmittedUsage(error.usage); lifecycle?.noDispatch("other");
output.push({ type: "error", reason: "error", error }); output.end(); return output;
}
const purpose = background();
const { controller, release } = subscriptionAbortController(nativeId, purpose);
const signal = options?.signal ? AbortSignal.any([controller.signal, options.signal]) : controller.signal;
void (async () => {
let partial = empty(logicalModel), scope: NativeSubscriptionScope | undefined;
let committed = false, terminal = false;
const check = () => {
if (signal.aborted || !scope || !subscriptionScopeCurrent(scope)) throw new Error("cancelled or connection changed");
if (purpose && !nativeSubscriptionBackgroundAllowed(nativeId)) throw new Error("background use disabled");
};
try {
if (Object.keys(options?.headers ?? {}).some((key) => ["authorization", "x-api-key", "api-key", "host"].includes(key.toLowerCase()))) throw new Error("Authorization override forbidden");
scope = subscriptionScope(nativeId, logicalModel.id); lifecycle?.captured(subscriptionAdmissionScope(scope));
const auth = await subscriptionLease(scope, purpose); check();
const authKeys = new Set(Object.keys(auth.headers ?? {}).map((key) => key.toLowerCase()));
if (Object.keys(options?.headers ?? {}).some((key) => authKeys.has(key.toLowerCase()))) throw new Error("Authorization override forbidden");
const nativeModel = provider.getModels().find((model) => model.id === logicalModel.id && model.api === logicalModel.api);
if (!nativeModel) throw new Error("Native model unavailable");
const transport = { ...nativeModel, ...(auth.baseUrl ? { baseUrl: auth.baseUrl } : {}) };
const fetch = options?.fetch ?? globalThis.fetch;
const source = provider.streamSimple(transport, subscriptionHistory(normalizeContext(context), logicalModel, nativeId), {
...options, apiKey: auth.apiKey, headers: { ...options?.headers, ...auth.headers }, signal, maxRetries: 0,
fetch: (input, init) => {
check(); if (committed) throw new Error("Subscription requests do not retry after dispatch");
lifecycle?.beforeDispatch(subscriptionAdmissionScope(scope!)); committed = true; return fetch(input, init);
},
onPayload: async (raw, model) => {
const replacement = await options?.onPayload?.(raw, model); check();
if (replacement === undefined) return raw;
if (!replacement || typeof replacement !== "object" || Array.isArray(replacement)) throw new Error("Invalid model payload override");
return { ...replacement, model: (raw as { model: string }).model };
},
});
for await (const event of source) {
check();
const message = "partial" in event ? event.partial : event.type === "done" ? event.message : event.error;
partial = { ...message, provider: logicalModel.provider };
if (event.type === "error") throw new Error(event.error.errorMessage ?? "Native stream failed");
if (event.type === "done") { if (!terminal) { terminal = true; output.push({ ...event, message: partial }); } }
else if (!terminal) output.push({ ...event, partial });
}
if (!terminal) throw new Error("Native stream ended without a terminal response");
} catch (error) {
if (terminal) return;
if (!committed) { markUnadmittedUsage(partial.usage); lifecycle?.noDispatch(await subscriptionDenial(scope, controller, options?.signal?.aborted === true, purpose)); }
partial = { ...partial, stopReason: signal.aborted ? "aborted" : "error", errorMessage: nativeSubscriptionError(logicalModel.provider, error instanceof Error ? error.message : "") };
output.push({ type: "error", reason: partial.stopReason as "error" | "aborted", error: partial });
} finally { release(); output.end(); }
})();
return output;
};
}
+2 -1
View File
@@ -20,6 +20,7 @@ import { quizInputObject, quizInputProblems } from "../core/question-core.ts";
import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts";
import { toLlmMessages, nativeInputCause } from "../core/native-messages.ts";
import type { ChatGPTRequestCycle } from "./chatgpt-request-cycle.ts";
import { usageWasAdmitted } from "./subscription-request-cycle.ts";
/**
* The two teaching-side agents (docs/probe-teach-split.md): the PROBER and
@@ -239,7 +240,7 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
if (event.type === "message_end" && event.message.role === "assistant" && "usage" in event.message) {
// A cancelled call is not a billed step (partial/estimated
// usage on an aborted message is not money spent).
if (event.message.stopReason === "aborted" || (opts.requestCycle && !opts.requestCycle.acceptsUsage(event.message))) return;
if (event.message.stopReason === "aborted" || !usageWasAdmitted(event.message.usage) || (opts.requestCycle && !opts.requestCycle.acceptsUsage(event.message))) return;
opts.onUsage!(event.message.usage);
}
});
+68
View File
@@ -0,0 +1,68 @@
import type { Api, Model } from "@earendil-works/pi-ai";
/** Native SDK IDs stay distinct from the app's persisted connection routes. */
export type NativeSubscriptionProviderId = "github-copilot" | "xai";
export type NativeSubscriptionLogicalId = "github-copilot-subscription" | "xai-subscription";
export const NATIVE_SUBSCRIPTIONS = [
{ providerId: "github-copilot", logicalId: "github-copilot-subscription", label: "GitHub Copilot" },
{ providerId: "xai", logicalId: "xai-subscription", label: "Grok" },
] as const;
export interface SubscriptionPrompt {
id: string;
type: "text" | "secret" | "select" | "manual_code";
message: string;
placeholder?: string;
options?: readonly { id: string; label: string; description?: string }[];
}
/** Only public device user codes, never the private device_code or tokens. */
export interface SubscriptionInteraction {
attemptId: string;
prompt?: SubscriptionPrompt;
browser?: { id: string; url: string };
userCode?: string;
progress?: string;
}
export interface NativeSubscriptionStatus {
providerId: NativeSubscriptionProviderId;
available: boolean;
connected: boolean;
pending: boolean;
committing?: boolean;
allowBackgroundUse: boolean;
connectionId: string | null;
epoch: number;
authorityRevision: number;
revision: number;
error?: string;
interaction?: SubscriptionInteraction;
}
/** Private inherited IPC also carries safe, SDK-supported catalog metadata. */
export interface NativeSubscriptionSnapshot {
statuses: NativeSubscriptionStatus[];
models: Partial<Record<NativeSubscriptionProviderId, Model<Api>[]>>;
}
export interface NativeSubscriptionScope {
providerId: NativeSubscriptionProviderId;
connectionId: string;
epoch: number;
authorityRevision: number;
selectedModelId: string;
}
export interface NativeSubscriptionControlBridge {
status(): Promise<NativeSubscriptionStatus[]>;
login(providerId: NativeSubscriptionProviderId, enabled?: boolean): Promise<void>;
cancel(providerId: NativeSubscriptionProviderId): Promise<void>;
respond(providerId: NativeSubscriptionProviderId, attemptId: string, promptId: string, value: string): Promise<void>;
open(providerId: NativeSubscriptionProviderId, attemptId: string, actionId: string): Promise<void>;
retry(providerId: NativeSubscriptionProviderId): Promise<void>;
disconnect(providerId: NativeSubscriptionProviderId): Promise<void>;
consent(providerId: NativeSubscriptionProviderId, enabled: boolean): Promise<void>;
onChange(listener: () => void): () => void;
}
+17 -8
View File
@@ -18,12 +18,13 @@
*/
import type { CallUsage, EmissionStats, ExtractionRunUsage } from "./contracts.ts";
import { sumSteps, isChatGPTPlan, type CallPricer } from "./prices.ts";
import { sumSteps, isSubscriptionModel, subscriptionLabels, type CallPricer } from "./prices.ts";
/** One pipeline stage's cumulative cost across every run. */
export interface StageCost {
/** ChatGPT plan stage-runs, excluded from API dollars and unpriced counts. */
/** Subscription stage-runs, excluded from API dollars and unpriced counts. */
subscriptionRuns?: number;
subscriptionProviders?: string[];
/** Inferred USD summed over PRICED runs (partial when unpricedRuns > 0). */
dollars: number;
/** Stage-runs whose model is missing from Pi's catalog (render "—", never zero). */
@@ -37,6 +38,7 @@ export interface StageCost {
/** One run in the recent-runs list (extraction or naming). */
export interface RecentRun {
subscriptionRuns?: number;
subscriptionProviders?: string[];
startedAt: number;
/** ISO wall-clock. */
at: string;
@@ -55,6 +57,7 @@ export interface RecentRun {
/** The cost pill's whole payload: stage totals + recent runs. */
export interface GraphCostSummary {
subscriptionRuns?: number;
subscriptionProviders?: string[];
/** Inferred USD across all priced stages of all runs. */
totalDollars: number;
/** Runs with no priced API stage and at least one unknown stage. */
@@ -93,7 +96,7 @@ const emptyStage = (): StageCost => ({ dollars: 0, unpricedRuns: 0, runs: 0, tok
/** One stage's dollars over its steps; null when the model is unpriced
* (all-or-nothing: a stage runs one model). */
function stageDollars(model: string, steps: readonly CallUsage[], price: CallPricer): number | null {
if (isChatGPTPlan(model) || steps.length === 0) return null;
if (isSubscriptionModel(model) || steps.length === 0) return null;
let total = 0;
for (const step of steps) {
const c = price(step, model);
@@ -129,14 +132,20 @@ export function graphCostSummary(input: CostSummaryInput, price: CallPricer): Gr
extraction.runs++;
mergeTokens(extraction.tokens, stages.extractor.steps);
const extDollars = stageDollars(stages.extractor.model, stages.extractor.steps, price);
if (isChatGPTPlan(stages.extractor.model)) extraction.subscriptionRuns = (extraction.subscriptionRuns ?? 0) + 1;
if (isSubscriptionModel(stages.extractor.model)) {
extraction.subscriptionRuns = (extraction.subscriptionRuns ?? 0) + 1;
extraction.subscriptionProviders = [...new Set([...(extraction.subscriptionProviders ?? []), ...subscriptionLabels([stages.extractor.model])])];
}
else if (extDollars === null) extraction.unpricedRuns++;
else extraction.dollars += extDollars;
audit.runs++;
mergeTokens(audit.tokens, stages.auditor.steps);
const audDollars = stageDollars(stages.auditor.model, stages.auditor.steps, price);
if (isChatGPTPlan(stages.auditor.model)) audit.subscriptionRuns = (audit.subscriptionRuns ?? 0) + 1;
if (isSubscriptionModel(stages.auditor.model)) {
audit.subscriptionRuns = (audit.subscriptionRuns ?? 0) + 1;
audit.subscriptionProviders = [...new Set([...(audit.subscriptionProviders ?? []), ...subscriptionLabels([stages.auditor.model])])];
}
else if (audDollars === null) audit.unpricedRuns++;
else audit.dollars += audDollars;
@@ -145,10 +154,10 @@ export function graphCostSummary(input: CostSummaryInput, price: CallPricer): Gr
const runDollars = allStages.every((d) => d === null)
? null
: allStages.reduce<number>((a, d) => a + (d ?? 0), 0);
const subscriptionRuns = [stages.extractor, stages.auditor].filter((stage) => isChatGPTPlan(stage.model)).length;
const subscriptionRuns = [stages.extractor, stages.auditor].filter((stage) => isSubscriptionModel(stage.model)).length;
const allSteps = [...stages.extractor.steps, ...stages.auditor.steps];
runs.push({
...(subscriptionRuns ? { subscriptionRuns } : {}),
...(subscriptionRuns ? { subscriptionRuns, subscriptionProviders: subscriptionLabels([stages.extractor.model, stages.auditor.model]) } : {}),
startedAt: run.startedAt,
at: run.at,
durationMs: run.durationMs,
@@ -174,7 +183,7 @@ export function graphCostSummary(input: CostSummaryInput, price: CallPricer): Gr
const unpricedRuns = runs.filter((r) => r.dollars === null && (r.subscriptionRuns ?? 0) < 2).length;
const subscriptionRuns = (extraction.subscriptionRuns ?? 0) + (audit.subscriptionRuns ?? 0);
return {
...(subscriptionRuns ? { subscriptionRuns } : {}),
...(subscriptionRuns ? { subscriptionRuns, subscriptionProviders: [...new Set([...(extraction.subscriptionProviders ?? []), ...(audit.subscriptionProviders ?? [])])] } : {}),
totalDollars: extraction.dollars + audit.dollars,
unpricedRuns,
stages: { extraction, audit },
+2
View File
@@ -50,6 +50,8 @@ export * from "./tool-parts.ts";
export * from "./prices.ts";
export * from "./context-usage.ts";
export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.ts";
export { NATIVE_SUBSCRIPTIONS } from "./contracts/subscriptions.ts";
export type { NativeSubscriptionProviderId, NativeSubscriptionLogicalId, NativeSubscriptionStatus, NativeSubscriptionSnapshot, NativeSubscriptionScope, NativeSubscriptionControlBridge, SubscriptionInteraction, SubscriptionPrompt } from "./contracts/subscriptions.ts";
export * from "./cost-summary.ts";
export * from "./fuzzy.ts";
// settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19):
+14 -4
View File
@@ -1,12 +1,14 @@
/** Client-safe accounting folds. Rates and calculation live on the server,
* using Pi's catalog and calculateCost; no copied pricing table lives here. */
import type { CallUsage, ChatRecord, JobRecord, TurnUsage } from "./contracts.ts";
import { NATIVE_SUBSCRIPTIONS } from "./contracts/subscriptions.ts";
export type CallPricer = (usage: Partial<CallUsage>, model: string) => number | null;
export interface SessionPricing {
cost: number;
unpriced: number;
subscription?: number;
subscriptionProviders?: string[];
/** Derived at read time, keyed by the persisted turn's id. Never stored. */
turnCosts: Record<string, number | null>;
}
@@ -14,6 +16,14 @@ export interface SessionPricing {
export function isChatGPTPlan(model: string | undefined): boolean {
return model?.startsWith("chatgpt:") === true;
}
export function subscriptionLabel(model: string | undefined): string | undefined {
if (isChatGPTPlan(model)) return "ChatGPT";
return NATIVE_SUBSCRIPTIONS.find((p) => model?.startsWith(`${p.logicalId}:`))?.label;
}
export function isSubscriptionModel(model: string | undefined): boolean { return subscriptionLabel(model) !== undefined; }
export function subscriptionLabels(models: readonly (string | undefined)[]): string[] {
return [...new Set(models.flatMap((model) => { const label = subscriptionLabel(model); return label ? [label] : []; }))];
}
/** Absent stays absent. Long-cache precision is unknown if any write-bearing
* step lacks the split; never silently turn that missing detail into zero. */
@@ -32,7 +42,7 @@ export function sumSteps(steps: readonly CallUsage[]): CallUsage {
}
export function turnCost(turn: Pick<TurnUsage, "model" | "steps">, price: CallPricer): number | null {
if (isChatGPTPlan(turn.model) || turn.steps.length === 0) return null;
if (isSubscriptionModel(turn.model) || turn.steps.length === 0) return null;
let total = 0;
for (const step of turn.steps) {
const cost = price(step, turn.model);
@@ -50,16 +60,16 @@ export function sessionCost(record: Pick<ChatRecord, "turns">, price: CallPricer
for (const turn of record.turns ?? []) {
const dollars = turnCost(turn, price);
turnCosts[turn.id] = dollars;
if (isChatGPTPlan(turn.model)) subscription++;
if (isSubscriptionModel(turn.model)) subscription++;
else if (dollars === null) unpriced++;
else cost += dollars;
}
return { cost, unpriced, ...(subscription ? { subscription } : {}), turnCosts };
return { cost, unpriced, ...(subscription ? { subscription, subscriptionProviders: subscriptionLabels((record.turns ?? []).map((turn) => turn.model)) } : {}), turnCosts };
}
export function jobCost(job: Pick<JobRecord, "usage">, price: CallPricer): number | null {
const usage = job.usage;
if (!usage || isChatGPTPlan(usage.model)) return null;
if (!usage || isSubscriptionModel(usage.model)) return null;
if (usage.calls && usage.calls.length === usage.steps) {
return turnCost({ model: usage.model, steps: usage.calls }, price);
}
+17 -15
View File
@@ -7,9 +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 { connectionBackgroundAllowed, connectionScopeCurrent, isSubscriptionProvider } from "../agent/subscription-connections.ts";
import { nativeSubscriptionError } from "../agent/subscription-stream.ts";
import { chatGPTStreamError } from "../agent/chatgpt-stream.ts";
import { ChatGPTRequestCycle, type ChatGPTDispatchLifecycle } from "../agent/chatgpt-request-cycle.ts";
import { SubscriptionRequestCycle, type SubscriptionDispatchLifecycle } from "../agent/subscription-request-cycle.ts";
import { reconstructRequestCause } from "../core/native-messages.ts";
import { createJobRunner } from "../agent/subagents/runner.ts";
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
@@ -206,10 +207,10 @@ 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: "ChatGPT background use is off in this app. Automatic follow-up remains deferred in its queue." }, { status: 403 });
if (isSubscriptionProvider(conversation.provider)) {
await refreshConnectionCatalog(conversation.provider);
if (input.type === "steers" && !connectionBackgroundAllowed(conversation.provider)) {
return Response.json({ error: "Background use is off for this connection. Automatic follow-up remains deferred in its queue." }, { status: 403 });
}
}
if (!providerKeyPresent(conversation.provider)) {
@@ -228,21 +229,21 @@ export async function handleChatInput(
// beginTurn, no liveness effects — and the record re-reads after
// because compaction appends the marker.
const callPurpose = { background: input.type === "steers" || (input.type === "continue" && reconstructRequestCause(record.messages) !== "foreground") };
let requestCycle: ChatGPTRequestCycle | undefined;
const lifecycle: ChatGPTDispatchLifecycle = {
let requestCycle: SubscriptionRequestCycle | undefined;
const lifecycle: SubscriptionDispatchLifecycle = {
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 }); }
catch { return Response.json({ error: conversation.provider === "chatgpt" ? chatGPTStreamError("model catalog") : isSubscriptionProvider(conversation.provider) ? nativeSubscriptionError(conversation.provider, "model catalog") : "The selected model is unavailable." }, { status: 503 }); }
const modelIdForCompaction = `${conversationEngine.model.provider}:${conversationEngine.model.id}`;
await ensureCompacted(
{
chatStore: stores.chatStore,
engine: piEngineForSpec(conversation, true),
canStart: () => conversation.provider !== "chatgpt" || chatGPTBackgroundAllowed(),
canStart: () => connectionBackgroundAllowed(conversation.provider),
modelId: modelIdForCompaction,
kind: record.kind === "practice" ? "practice" : hasProbeDone(record.messages) ? "teach" : "probe",
thresholdTokens: effectiveThresholdTokens(
@@ -308,7 +309,7 @@ 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";
const stagedAutomatic = isSubscriptionProvider(conversation.provider) && input.type === "steers";
let persistBaseCount = stagedAutomatic ? record.messages.length : merged.length;
try {
if (!stagedAutomatic) stores.chatStore.saveMessages(chatId, merged);
@@ -363,12 +364,12 @@ 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({
if (isSubscriptionProvider(conversation.provider)) requestCycle = new SubscriptionRequestCycle({
initialMessages: asAgentMessages(merged),
staged: stagedAutomatic ? asAgentMessages(appends) : undefined,
foregroundOwed: !callPurpose.background,
purpose: callPurpose,
scopeCurrent: chatGPTScopeCurrent,
scopeCurrent: (scope) => connectionScopeCurrent(conversation.provider, scope),
commitStaged: () => { stores.chatStore.saveMessages(chatId, merged); persistBaseCount = merged.length; },
restoreAutomatic: (automaticIds, retainedIds) => restoreUncommittedAutomatic(chatId, constructed, automaticIds, retainedIds),
});
@@ -491,6 +492,7 @@ export async function handleChatInput(
// transcript directly (the retry context the model saw).
onError: (error) => {
if (conversation.provider === "chatgpt") return chatGPTStreamError(String(error));
if (isSubscriptionProvider(conversation.provider)) return nativeSubscriptionError(conversation.provider, String(error));
console.error(`[chat ${chatId}] turn error:`, error);
return "An error occurred.";
},
@@ -561,7 +563,7 @@ export async function handleChatInput(
kickCompaction(() => {
const fresh = effectiveSettings();
const spec = resolveConversationSpec(stores.chatStore.getChat(chatId)?.settings, fresh.models.strong);
if (!spec || (spec.provider === "chatgpt" && !chatGPTBackgroundAllowed())) return null;
if (!spec || !connectionBackgroundAllowed(spec.provider)) return null;
let kickEngine: PiEngine;
try { kickEngine = piEngineForSpec(spec, true); } catch { return null; }
const kickModelId = `${kickEngine.model.provider}:${kickEngine.model.id}`;
@@ -570,7 +572,7 @@ export async function handleChatInput(
return {
chatStore: stores.chatStore,
engine: kickEngine,
canStart: () => spec.provider !== "chatgpt" || chatGPTBackgroundAllowed(),
canStart: () => connectionBackgroundAllowed(spec.provider),
modelId: kickModelId,
kind: freshRecord.kind === "practice" ? "practice" : hasProbeDone(freshRecord.messages) ? "teach" : "probe",
thresholdTokens: effectiveThresholdTokens(
+4 -4
View File
@@ -68,7 +68,7 @@ import { danglingQuestionOf, endsInDismissal, type LhMessage } from "../core/nat
import { fireJobAbort, isJobLive } from "../agent/subagents/aborts.ts";
import { stores, effectiveSettings } from "./stores.ts";
import { resolveConversationSpec } from "../core/settings-resolution.ts";
import { chatGPTBackgroundAllowed, onChatGPTChange } from "../agent/chatgpt-bridge.ts";
import { connectionBackgroundAllowed, isSubscriptionProvider, onSubscriptionChange } from "../agent/subscription-connections.ts";
import { isTurnRunning } from "./turn-liveness.ts";
import type { Agent } from "@earendil-works/pi-agent-core";
@@ -217,7 +217,7 @@ export function deliverJobResult(steer: SteerMessage): Promise<void> {
export function automaticFollowUpAllowed(chatId: string, agent?: Agent): boolean {
if (agent && state().automaticDeferred.has(agent)) return false;
const provider = agent?.state?.model?.provider ?? resolveConversationSpec(stores.chatStore.getChat(chatId)?.settings, effectiveSettings().models.strong)?.provider;
return provider !== "chatgpt" || chatGPTBackgroundAllowed();
return connectionBackgroundAllowed(provider);
}
function handOffEligible(chatId: string, agent: Agent): void {
@@ -621,10 +621,10 @@ async function pump(chatId: string): Promise<void> {
// Connection changes are the only wake for consent-deferred automatic work.
// No retry timer, inference, or API-key fallback is started while consent is off.
state().stopConnectionWatch?.();
state().stopConnectionWatch = onChatGPTChange(() => {
state().stopConnectionWatch = onSubscriptionChange(() => {
const s = state();
for (const [chatId, agent] of s.liveAgents) {
if (agent.state?.model?.provider !== "chatgpt" || automaticFollowUpAllowed(chatId, agent)) continue;
if (!isSubscriptionProvider(agent.state?.model?.provider) || automaticFollowUpAllowed(chatId, agent)) continue;
const consumed = new Set(agent.state.messages.map((m) => (m as { id?: string }).id));
const pending = (s.queues.get(chatId) ?? []).filter((entry) => entry.handedTo === agent && !consumed.has(entry.steer.id));
if (pending.length === 0) continue;
+2 -2
View File
@@ -52,7 +52,7 @@ import {
validateKeyShape,
} from "../core/auth-keys.ts";
import { authStore } from "../store/auth.ts";
import { chatGPTConnected } from "../agent/chatgpt-bridge.ts";
import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.ts";
// The once-per-process boot record — globalThis anchor (HMR re-imports the
// module; the snapshot must not re-run and misread fill-gapped vars as real
@@ -201,7 +201,7 @@ export function keyValue(id: string): string | undefined {
* (gate by what would run). Providers absent from AUTH_PROVIDER_KEYS take
* no key at all: nothing to gate, present by definition. */
export function providerKeyPresent(provider: string): boolean {
if (provider === "chatgpt") return chatGPTConnected();
if (isSubscriptionProvider(provider)) return subscriptionConnected(provider);
const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider);
return info ? keyValue(info.envVar) !== undefined : true;
}
+15 -7
View File
@@ -21,14 +21,19 @@ import { clampThinkingLevel, getSupportedThinkingLevels, type Api, type Model }
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { piModels } from "../agent/models.ts";
import { chatGPTConnected, chatGPTModel, chatGPTModels, refreshChatGPTModels } from "../agent/chatgpt-bridge.ts";
import { NATIVE_SUBSCRIPTIONS } from "../core/contracts/subscriptions.ts";
import { nativeSubscriptionId, nativeSubscriptionConnected, nativeSubscriptionModel, nativeSubscriptionModels, refreshSubscriptionModels } from "../agent/subscription-bridge.ts";
export async function refreshConnectionCatalog(): Promise<void> {
await refreshChatGPTModels(piModels().getModels("openai").filter((m): m is Model<"openai-responses"> => m.api === "openai-responses"));
export async function refreshConnectionCatalog(provider?: string): Promise<void> {
await Promise.all([
!provider || provider === "chatgpt" ? refreshChatGPTModels(piModels().getModels("openai").filter((m): m is Model<"openai-responses"> => m.api === "openai-responses")) : Promise.resolve(),
!provider || nativeSubscriptionId(provider) ? refreshSubscriptionModels() : Promise.resolve(),
]);
}
function connectionModel(provider: string, id: string): Model<Api> | undefined {
return provider === "chatgpt" ? chatGPTModel(id) : piModels().getModel(provider, id);
return provider === "chatgpt" ? chatGPTModel(id) : nativeSubscriptionId(provider) ? nativeSubscriptionModel(provider, id) : piModels().getModel(provider, id);
}
function connectionProviderIds(): string[] { return [...piModels().getProviders().map((p) => p.id), "chatgpt"]; }
function connectionProviderIds(): string[] { return [...piModels().getProviders().map((p) => p.id), "chatgpt", ...NATIVE_SUBSCRIPTIONS.map((p) => p.logicalId)]; }
import { settingsStore } from "../store/settings.ts";
import { effectiveSettings } from "./stores.ts";
import { keyValue } from "./keys.ts";
@@ -134,7 +139,10 @@ export function catalogProviders(): CatalogProvider[] {
id: p.id,
envVar: p.envVar,
keyPresent: keyValue(p.envVar) !== undefined,
})).concat([{ id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() }]).sort((a, b) => a.id.localeCompare(b.id));
})).concat([
{ id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() },
...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })),
]).sort((a, b) => a.id.localeCompare(b.id));
}
/** The providers whose key is present — the picker federates exactly
@@ -180,7 +188,7 @@ export function catalogContextLimits(): Map<string, number> {
index.set(`${provider.id}:${model.id}`, model.contextWindow);
}
}
for (const model of chatGPTModels()) index.set(`chatgpt:${model.id}`, model.contextWindow);
for (const model of [...chatGPTModels(), ...nativeSubscriptionModels()]) index.set(`${model.provider}:${model.id}`, model.contextWindow);
return index;
}
@@ -196,7 +204,7 @@ export function modelCatalog(): ModelCatalogResponse {
.flatMap((p) => piModels().getModels(p.id))
.map(toModelDto)
.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)))
.concat(chatGPTModels().map(toModelDto));
.concat([...chatGPTModels(), ...nativeSubscriptionModels()].map(toModelDto));
return {
models,
defaults: catalogDefaults(),
+2 -2
View File
@@ -5,11 +5,11 @@
import { calculateCost, type Usage } from "@earendil-works/pi-ai";
import { piModels } from "../agent/models.ts";
import type { CallUsage, ChatRecord, JobRecord, TurnUsage } from "../core/contracts.ts";
import { isChatGPTPlan, sessionCost as foldSessionCost, turnCost as foldTurnCost, jobCost as foldJobCost } from "../core/prices.ts";
import { isSubscriptionModel, sessionCost as foldSessionCost, turnCost as foldTurnCost, jobCost as foldJobCost } from "../core/prices.ts";
function catalogModel(id: string) {
const colon = id.indexOf(":");
if (colon < 0 || isChatGPTPlan(id)) return undefined;
if (colon < 0 || isSubscriptionModel(id)) return undefined;
return piModels().getModel(id.slice(0, colon), id.slice(colon + 1));
}