feat: add app-owned Copilot and Grok subscription connections
This commit is contained in:
@@ -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
@@ -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,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);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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(); };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user