feat: add app-owned Copilot and Grok subscription connections
This commit is contained in:
@@ -4,7 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
|
||||
import { ActivityIcon, BotIcon, FileTextIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CallUsage, JobKind, JobRecord } from "@/src/core";
|
||||
import { isModelsRefusal, isProviderKeyRefusal } from "@/src/core";
|
||||
import { isModelsRefusal, isProviderKeyRefusal, subscriptionLabel, subscriptionLabels } from "@/src/core";
|
||||
import { formatTokens, planCostLabel } from "@/components/usage-format";
|
||||
import { useServerEvent, useServerResync } from "@/components/server-events";
|
||||
|
||||
@@ -350,7 +350,7 @@ export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) {
|
||||
Background agents
|
||||
{blobs.length > 0 && (
|
||||
<span className="ml-auto tabular-nums" data-testid="activity-total-cost" title="Total running cost (inferred)">
|
||||
{planCostLabel(totalCost, blobs.some((blob) => blob.model?.startsWith("chatgpt:")))}
|
||||
{planCostLabel(totalCost, subscriptionLabels(blobs.map((blob) => blob.model)))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -425,7 +425,7 @@ function ActivityBlobRow({
|
||||
)}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2 tabular-nums text-muted-foreground">
|
||||
<span data-testid="activity-blob-cost">{planCostLabel(blob.cost, blob.model?.startsWith("chatgpt:") === true)}</span>
|
||||
<span data-testid="activity-blob-cost">{planCostLabel(blob.cost, subscriptionLabel(blob.model))}</span>
|
||||
<span data-testid="activity-blob-elapsed">{formatElapsed(now - blob.startedAt)}</span>
|
||||
</span>
|
||||
</button>
|
||||
@@ -464,7 +464,7 @@ function ActivityBlobRow({
|
||||
<div className="flex justify-between gap-2">
|
||||
<span>Cost</span>
|
||||
<span className="tabular-nums text-card-foreground">
|
||||
{planCostLabel(blob.cost, blob.model?.startsWith("chatgpt:") === true)}
|
||||
{planCostLabel(blob.cost, subscriptionLabel(blob.model))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
|
||||
@@ -68,6 +68,7 @@ import { NotesImportErrors, useNotesImport } from "@/components/notes-import.tsx
|
||||
import Link from "next/link";
|
||||
import { FileTextIcon, ListTreeIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react";
|
||||
import { incrementalLayout, propositionSessionHref, type GraphCostSummary, type GraphNode, type GraphSnapshot } from "@/src/core";
|
||||
import { planCostLabel } from "@/components/usage-format";
|
||||
import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts";
|
||||
import { usePopoverDismiss } from "@/components/popover-dismiss";
|
||||
import { useActivity } from "@/components/activity-dock";
|
||||
@@ -294,16 +295,18 @@ function timeAgo(startedAt: number): string {
|
||||
|
||||
/** Run/stage cost at 3 decimals (pipeline pennies are real money at this
|
||||
* scale); headline totals use 2. Null (unpriced model) renders "—". */
|
||||
function fmtRunCost(dollars: number | null, subscription = false): string {
|
||||
if (subscription) return dollars === null ? "ChatGPT plan" : `$${dollars.toFixed(3)} API + ChatGPT plan`;
|
||||
return dollars === null ? "—" : `$${dollars.toFixed(3)}`;
|
||||
function fmtRunCost(dollars: number | null, providers: readonly string[] = []): string {
|
||||
return planCostLabel(dollars, providers, (value) => `$${value.toFixed(3)}`);
|
||||
}
|
||||
function graphProviders(value: { subscriptionRuns?: number; subscriptionProviders?: string[] }): string[] {
|
||||
return value.subscriptionProviders ?? ((value.subscriptionRuns ?? 0) > 0 ? ["ChatGPT"] : []);
|
||||
}
|
||||
function graphUsageLabel(costs: Pick<GraphCostSummary, "stages">): string {
|
||||
const stages = [costs.stages.extraction, costs.stages.audit];
|
||||
const plan = stages.some((stage) => (stage.subscriptionRuns ?? 0) > 0);
|
||||
const providers = [...new Set(stages.flatMap(graphProviders))];
|
||||
const priced = stages.some((stage) => stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) > 0);
|
||||
const dollars = displayCostTotals(costs).totalDollars;
|
||||
return plan ? (priced ? `$${dollars.toFixed(2)} API + ChatGPT plan` : "ChatGPT plan") : priced ? `$${dollars.toFixed(2)}` : "—";
|
||||
return planCostLabel(priced ? dollars : null, providers, (value) => `$${value.toFixed(2)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -405,7 +408,7 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
|
||||
<div key={label} className="flex items-baseline justify-between gap-2 text-[11px]">
|
||||
<span className="shrink-0 text-muted-foreground">{label}</span>
|
||||
<span className="min-w-0 text-right tabular-nums">
|
||||
{fmtRunCost(stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) <= 0 ? null : stage.dollars, (stage.subscriptionRuns ?? 0) > 0)}
|
||||
{fmtRunCost(stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) <= 0 ? null : stage.dollars, graphProviders(stage))}
|
||||
{stage.unpricedRuns > 0 && (stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) > 0 || (stage.subscriptionRuns ?? 0) > 0) && (
|
||||
<span className="text-muted-foreground"> +{stage.unpricedRuns} unpriced</span>
|
||||
)}
|
||||
@@ -430,7 +433,7 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums">{fmtRunCost(run.dollars, (run.subscriptionRuns ?? 0) > 0)}{run.dollars === null && run.subscriptionRuns === 1 ? " +1 unpriced" : ""}</span>
|
||||
<span className="shrink-0 tabular-nums">{fmtRunCost(run.dollars, graphProviders(run))}{run.dollars === null && run.subscriptionRuns === 1 ? " +1 unpriced" : ""}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -73,6 +73,7 @@ import { cn } from "@/lib/utils";
|
||||
import { SettingsGroup, SettingsRow, Toggle, CommitInput } from "./controls.tsx";
|
||||
import { ModelMenuList, ThinkingMenuList } from "./model-menu";
|
||||
import { ChatGPTBackgroundAccess } from "./chatgpt-connection";
|
||||
import { NativeSubscriptionBackgroundAccess } from "./subscription-connections";
|
||||
|
||||
/** One slot's display identity — the tier's roster as the row hint rides
|
||||
* with the resolved default (owner: the hint ALWAYS names the default). */
|
||||
@@ -648,7 +649,7 @@ export function AgentSection({
|
||||
ref={thisMenuOpen ? menuAnchorRef : undefined}
|
||||
className="relative flex items-center gap-2"
|
||||
>
|
||||
{slot !== "strong" && <ChatGPTBackgroundAccess provider={coord?.provider} />}
|
||||
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={coord?.provider} /><NativeSubscriptionBackgroundAccess provider={coord?.provider} /></>}
|
||||
<MenuTrigger
|
||||
value={modelId ? prettyModel(modelId) : ""}
|
||||
emptyLabel="—"
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { ChatGPTStatus } from "@/src/core";
|
||||
import { useBackdropDismiss } from "@/components/backdrop-dismiss";
|
||||
import { ConnectionDialog, ConnectionRow } from "./connection-controls";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Toggle } from "./controls";
|
||||
|
||||
@@ -324,31 +323,7 @@ function ContinueButton({ onClick, disabled, testid = "chatgpt-login" }: { onCli
|
||||
}
|
||||
|
||||
function AccountDialog({ children, onClose, busy }: { children: React.ReactNode; onClose: () => void; busy: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const closeRef = useRef(onClose);
|
||||
closeRef.current = onClose;
|
||||
const backdropDismiss = useBackdropDismiss(onClose);
|
||||
useEffect(() => {
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
const focusable = () => [...(ref.current?.querySelectorAll<HTMLElement>('button:not([disabled]), a[href], input:not([disabled]), select:not([disabled])') ?? [])];
|
||||
focusable()[0]?.focus();
|
||||
const key = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault(); event.stopImmediatePropagation(); closeRef.current();
|
||||
} else if (event.key === "Tab") {
|
||||
const items = focusable(), first = items[0], last = items.at(-1);
|
||||
if (!first) { event.preventDefault(); return; }
|
||||
if (event.shiftKey && (document.activeElement === first || !ref.current?.contains(document.activeElement))) { event.preventDefault(); last?.focus(); }
|
||||
else if (!event.shiftKey && (document.activeElement === last || !ref.current?.contains(document.activeElement))) { event.preventDefault(); first.focus(); }
|
||||
}
|
||||
};
|
||||
// Capture claims the nested dialog's Escape before outer Settings/setup.
|
||||
document.addEventListener("keydown", key, true);
|
||||
return () => { document.removeEventListener("keydown", key, true); if (previous?.isConnected) previous.focus(); };
|
||||
}, []);
|
||||
return createPortal(<div className="fixed inset-0 z-[60] flex items-center justify-center bg-background/80" data-testid="chatgpt-dialog-backdrop" {...backdropDismiss}>
|
||||
<div ref={ref} role="dialog" aria-modal="true" aria-label="ChatGPT account" aria-busy={busy} data-testid="chatgpt-account-dialog" onClick={(event) => event.stopPropagation()} className="settings-dialog-pop mx-4 max-h-[85vh] w-full max-w-lg overflow-y-auto rounded-md border border-border bg-card p-5 shadow-xl">{children}</div>
|
||||
</div>, document.body);
|
||||
return <ConnectionDialog label="ChatGPT account" testid="chatgpt-account-dialog" backdropTestid="chatgpt-dialog-backdrop" onClose={onClose} busy={busy}>{children}</ConnectionDialog>;
|
||||
}
|
||||
|
||||
/** Compact settings row, or setup's secondary action only. */
|
||||
@@ -357,15 +332,11 @@ export function ChatGPTConnection({ setup = false }: { setup?: boolean }) {
|
||||
const active = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected);
|
||||
const accounts = ui.status.accounts.filter((a) => a.connected);
|
||||
return <div className="flex flex-col gap-2" data-testid="chatgpt-connection">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{!setup && active && <span className="font-ui text-xs text-foreground">ChatGPT</span>}
|
||||
{active && !setup ? <>
|
||||
<select aria-label="ChatGPT account" value={active.id} disabled={ui.busy || ui.status.pending} onChange={(event) => ui.select(event.target.value)} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1 text-xs text-foreground">
|
||||
{accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
|
||||
</select>
|
||||
<Button type="button" size="xs" variant="ghost" disabled={ui.busy} onClick={() => ui.manage(active.id)}>Manage…</Button>
|
||||
</> : <ContinueButton onClick={ui.connect} disabled={!ui.status.available || ui.busy || ui.status.pending} />}
|
||||
</div>
|
||||
<ConnectionRow label="ChatGPT" actionTestid={active ? undefined : "chatgpt-login"} action={active ? "Manage…" : "Connect"} disabled={!ui.status.available || ui.busy || ui.status.pending} onAction={() => active ? ui.manage(active.id) : ui.connect()}>
|
||||
{active && !setup && <select aria-label="ChatGPT account" value={active.id} disabled={ui.busy || ui.status.pending} onChange={(event) => ui.select(event.target.value)} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1 text-xs text-foreground">
|
||||
{accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
|
||||
</select>}
|
||||
</ConnectionRow>
|
||||
{ui.inlineError && <p role="alert" className="text-xs text-red-400">{ui.inlineError}</p>}
|
||||
{ui.warning && <div role="alert" data-testid="chatgpt-revocation-warning" className="rounded-sm border border-border px-3 py-2 text-xs text-muted-foreground">{ui.warning}<button type="button" className="ml-2 cursor-pointer underline underline-offset-2" onClick={ui.dismissWarning}>Dismiss</button></div>}
|
||||
</div>;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useBackdropDismiss } from "@/components/backdrop-dismiss";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function ConnectionRow({ label, action, disabled, onAction, children, testid, actionTestid }: { label: string; action: string; disabled: boolean; onAction: () => void; children?: React.ReactNode; testid?: string; actionTestid?: string }) {
|
||||
return <div className="flex flex-wrap items-center gap-3" data-testid={testid}>
|
||||
<span className="font-ui text-xs text-foreground">{label}</span>
|
||||
{children}
|
||||
<Button type="button" size="xs" variant={action === "Connect" ? "outline" : "ghost"} className="cursor-pointer disabled:cursor-not-allowed" data-testid={actionTestid} disabled={disabled} onClick={onAction}>{action}</Button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function ConnectionDialog({ children, onClose, busy, label, testid, backdropTestid }: { children: React.ReactNode; onClose: () => void; busy: boolean; label: string; testid: string; backdropTestid: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null), closeRef = useRef(onClose);
|
||||
closeRef.current = onClose;
|
||||
const backdropDismiss = useBackdropDismiss(() => { if (!busy) onClose(); });
|
||||
useEffect(() => {
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
const focusable = () => [...(ref.current?.querySelectorAll<HTMLElement>('button:not([disabled]), a[href], input:not([disabled]), select:not([disabled])') ?? [])];
|
||||
focusable()[0]?.focus();
|
||||
const key = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") { event.preventDefault(); event.stopImmediatePropagation(); closeRef.current(); }
|
||||
else if (event.key === "Tab") {
|
||||
const items = focusable(), first = items[0], last = items.at(-1);
|
||||
if (!first) { event.preventDefault(); return; }
|
||||
if (event.shiftKey && (document.activeElement === first || !ref.current?.contains(document.activeElement))) { event.preventDefault(); last?.focus(); }
|
||||
else if (!event.shiftKey && (document.activeElement === last || !ref.current?.contains(document.activeElement))) { event.preventDefault(); first.focus(); }
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", key, true);
|
||||
return () => { document.removeEventListener("keydown", key, true); if (previous?.isConnected) previous.focus(); };
|
||||
}, []);
|
||||
return createPortal(<div className="fixed inset-0 z-[60] flex items-center justify-center bg-background/80" data-testid={backdropTestid} {...backdropDismiss}>
|
||||
<div ref={ref} role="dialog" aria-modal="true" aria-label={label} aria-busy={busy} data-testid={testid} onClick={(event) => event.stopPropagation()} className="settings-dialog-pop mx-4 max-h-[85vh] w-full max-w-lg overflow-y-auto rounded-md border border-border bg-card p-5 shadow-xl">{children}</div>
|
||||
</div>, document.body);
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core";
|
||||
import { SettingsGroup } from "./controls.tsx";
|
||||
import { ChatGPTConnection } from "./chatgpt-connection.tsx";
|
||||
import { SubscriptionConnections } from "./subscription-connections.tsx";
|
||||
import { ProviderMenuList } from "./model-menu.tsx";
|
||||
import { MenuShell } from "@/components/menu-shell";
|
||||
import { notifySettingsSaved } from "@/components/settings-signal";
|
||||
@@ -456,7 +456,7 @@ export function KeysSection({
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup label="Subscriptions" testid="group-subscriptions">
|
||||
<div data-testid="group-chatgpt"><ChatGPTConnection /></div>
|
||||
<SubscriptionConnections />
|
||||
</SettingsGroup>
|
||||
|
||||
{(["Dictation", "Search"] as const).map((label) => <SettingsGroup key={label} label={label} testid={label === "Dictation" ? "group-dictation" : "group-search"}>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ModelCatalogEntry } from "@/src/core";
|
||||
import { fuzzyScore } from "@/src/core";
|
||||
import { fuzzyScore, subscriptionLabel } from "@/src/core";
|
||||
import { formatTokens } from "@/components/usage-format";
|
||||
|
||||
function priceLabel(cost: ModelCatalogEntry["cost"]): string {
|
||||
@@ -125,12 +125,12 @@ export function ModelMenuList({
|
||||
>
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-card-foreground">{m.name}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground" title="USD per 1M tokens in/out">
|
||||
{m.provider === "chatgpt" ? "ChatGPT plan" : priceLabel(m.cost)}
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground" title={subscriptionLabel(`${m.provider}:${m.id}`) ? "Subscription connection" : "USD per 1M tokens in/out"}>
|
||||
{subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center justify-between gap-2 text-muted-foreground">
|
||||
<span className="truncate">{m.provider} · {m.id}</span>
|
||||
<span className="truncate">{subscriptionLabel(`${m.provider}:${m.id}`) ? `${subscriptionLabel(`${m.provider}:${m.id}`)} (subscription)` : m.provider} · {m.id}</span>
|
||||
<span className="shrink-0 tabular-nums">{formatTokens(m.contextWindow)}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -50,6 +50,7 @@ import { Hint } from "@/components/ui/hint";
|
||||
import { AppearanceSection, GraphSection } from "./appearance-section.tsx";
|
||||
import { KeyboardSection } from "./keyboard-section.tsx";
|
||||
import { ChatGPTUIProvider } from "./chatgpt-connection.tsx";
|
||||
import { NativeSubscriptionUIProvider } from "./subscription-connections.tsx";
|
||||
|
||||
/** Owner (2026-09-20): hints earn their place — mechanics-explaining
|
||||
* prose is out; only the keyboard note stays (storage location is a
|
||||
@@ -92,12 +93,12 @@ export function SettingsDialogProvider({
|
||||
setShow(true);
|
||||
}, []);
|
||||
return (
|
||||
<ChatGPTUIProvider><DialogContext.Provider value={{ open }}>
|
||||
<ChatGPTUIProvider><NativeSubscriptionUIProvider><DialogContext.Provider value={{ open }}>
|
||||
{children}
|
||||
{show && (
|
||||
<SettingsDialog onClose={() => setShow(false)} initialSection={landOn} initialRow={landOnRow} />
|
||||
)}
|
||||
</DialogContext.Provider></ChatGPTUIProvider>
|
||||
</DialogContext.Provider></NativeSubscriptionUIProvider></ChatGPTUIProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||
import { NATIVE_SUBSCRIPTIONS, type NativeSubscriptionProviderId, type NativeSubscriptionStatus } from "@/src/core";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { notifySettingsSaved } from "@/components/settings-signal";
|
||||
import { ConnectionDialog, ConnectionRow } from "./connection-controls";
|
||||
import { ChatGPTConnection } from "./chatgpt-connection";
|
||||
import { Toggle } from "./controls";
|
||||
|
||||
export const COPILOT_POLICY_NOTICE = "Pi's native Copilot sign-in may enable additional models whose policy is not yet configured. Models explicitly disabled by policy are not enabled. Organization restrictions still apply.";
|
||||
export const SUBSCRIPTION_BACKGROUND_HINT = "Allow LearnHarness helper agents and automatic follow-ups to use this connection.";
|
||||
export function subscriptionNotice(label: string): string { return `Your prompts, tool results, and relevant session context are sent to ${label} to run the work you select. Credentials are stored locally in an owner-only plaintext file. Provider terms, data-use policies, and plan limits apply. This connection never falls back to an API key.`; }
|
||||
export function subscriptionDisconnectCopy(label: string): string { return `Disconnect ${label} from LearnHarness? This stops work using this connection and removes its saved credentials from this device. It does not cancel your subscription or revoke access at the provider. Saved model choices will remain unavailable until you reconnect or change them.`; }
|
||||
export function emptySubscriptionStatus(providerId: NativeSubscriptionProviderId): NativeSubscriptionStatus { return { providerId, available: false, connected: false, pending: false, allowBackgroundUse: true, connectionId: null, epoch: 0, authorityRevision: 0, revision: 0 }; }
|
||||
const Context = createContext({ statuses: [] as NativeSubscriptionStatus[], busy: false, open: (_providerId: NativeSubscriptionProviderId) => {} });
|
||||
const CONTROL_ERROR = "The connection could not be changed. Try again.";
|
||||
|
||||
export function NativeSubscriptionUIProvider({ children }: { children: React.ReactNode }) {
|
||||
const [statuses, setStatuses] = useState<NativeSubscriptionStatus[]>([]);
|
||||
const [dialog, setDialog] = useState<NativeSubscriptionProviderId | null>(null);
|
||||
const [confirm, setConfirm] = useState(false), [background, setBackground] = useState(true), [backgroundDirty, setBackgroundDirty] = useState(false), [promptValue, setPromptValue] = useState("");
|
||||
const [operation, setOperation] = useState<"login" | "control" | null>(null), [error, setError] = useState<string | null>(null);
|
||||
const mounted = useRef(false), sequence = useRef(0), generation = useRef(0), statusesRef = useRef(statuses);
|
||||
const descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.providerId === dialog);
|
||||
const status = statuses.find((s) => s.providerId === dialog) ?? (dialog ? emptySubscriptionStatus(dialog) : undefined);
|
||||
const interaction = status?.interaction, prompt = interaction?.prompt;
|
||||
const busy = operation === "control" || status?.committing === true;
|
||||
async function refresh() {
|
||||
const request = ++sequence.current;
|
||||
try {
|
||||
const values = await window.lh?.subscriptions?.status();
|
||||
if (!mounted.current || request !== sequence.current || !Array.isArray(values)) return;
|
||||
const accepted = NATIVE_SUBSCRIPTIONS.map(({ providerId }) => {
|
||||
const next = values.find((s) => s.providerId === providerId), old = statusesRef.current.find((s) => s.providerId === providerId);
|
||||
return next && (!old || next.revision >= old.revision) ? next : old ?? emptySubscriptionStatus(providerId);
|
||||
});
|
||||
statusesRef.current = accepted; setStatuses(accepted);
|
||||
} catch { if (mounted.current && request === sequence.current) setError("The desktop connection is unavailable. Retry when the app is ready."); }
|
||||
}
|
||||
useEffect(() => {
|
||||
mounted.current = true; void refresh();
|
||||
const off = window.lh?.subscriptions?.onChange(() => { void refresh(); notifySettingsSaved("keys"); });
|
||||
return () => { mounted.current = false; generation.current++; off?.(); };
|
||||
// The root-lifetime subscriber reads current state through refs.
|
||||
}, []);
|
||||
useEffect(() => { setPromptValue(""); }, [prompt?.id]);
|
||||
function open(providerId: NativeSubscriptionProviderId) {
|
||||
if (operation || statusesRef.current.some((s) => s.pending)) return;
|
||||
generation.current++; setError(null); setConfirm(false); setBackgroundDirty(false);
|
||||
setBackground(statusesRef.current.find((s) => s.providerId === providerId)?.allowBackgroundUse ?? true); setDialog(providerId);
|
||||
}
|
||||
async function control(action: () => Promise<void>, close = false) {
|
||||
if (busy) return;
|
||||
const owner = ++generation.current; setOperation("control"); setError(null);
|
||||
try { await action(); await refresh(); if (mounted.current && owner === generation.current && close) setDialog(null); }
|
||||
catch { if (mounted.current && owner === generation.current) setError(CONTROL_ERROR); }
|
||||
finally { if (mounted.current && owner === generation.current) setOperation(null); }
|
||||
}
|
||||
function close() {
|
||||
if (!dialog || busy) return;
|
||||
if (confirm) { setConfirm(false); setError(null); return; }
|
||||
const bridge = window.lh?.subscriptions;
|
||||
if ((status?.pending || operation === "login") && bridge) { void control(() => bridge.cancel(dialog), true); return; }
|
||||
generation.current++; setDialog(null); setError(null);
|
||||
}
|
||||
async function login() {
|
||||
const bridge = window.lh?.subscriptions;
|
||||
if (!dialog || !bridge || operation || !status?.available) return;
|
||||
const owner = ++generation.current; setOperation("login"); setError(null);
|
||||
try { await bridge.login(dialog, backgroundDirty ? background : undefined); await refresh(); if (mounted.current && generation.current === owner) setDialog(null); }
|
||||
catch { if (mounted.current && generation.current === owner) setError("Sign-in did not complete. Try connecting again."); }
|
||||
finally { if (mounted.current && generation.current === owner) setOperation(null); }
|
||||
}
|
||||
const bridge = typeof window === "undefined" ? undefined : window.lh?.subscriptions;
|
||||
return <Context.Provider value={{ statuses, busy: !!operation, open }}>
|
||||
{children}
|
||||
{dialog && descriptor && status && <ConnectionDialog label={`${descriptor.label} connection`} testid="subscription-connection-dialog" backdropTestid="subscription-dialog-backdrop" busy={busy} onClose={close}>
|
||||
<h2 className={`text-sm font-semibold ${confirm ? "text-red-400" : "text-foreground"}`}>{confirm ? `Disconnect ${descriptor.label}?` : status.connectionId ? descriptor.label : `Connect to ${descriptor.label}`}</h2>
|
||||
{confirm ? <p className="mt-3 text-xs leading-relaxed text-muted-foreground">{subscriptionDisconnectCopy(descriptor.label)}</p> : <>
|
||||
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="subscription-privacy-notice">{subscriptionNotice(descriptor.label)}</p>
|
||||
{dialog === "github-copilot" && <p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="copilot-policy-notice">{COPILOT_POLICY_NOTICE}</p>}
|
||||
<div className="mt-4 flex items-center gap-3 text-xs text-foreground">
|
||||
<Toggle label="Allow background use" checked={status.connectionId ? status.allowBackgroundUse : background} disabled={busy || !!operation || status.pending || !status.available} onChange={(enabled) => { setBackground(enabled); if (status.connectionId && bridge) void control(() => bridge.consent(dialog, enabled)); else setBackgroundDirty(true); }} />
|
||||
<span>Allow background use<span className="mt-1 block leading-relaxed text-muted-foreground">{SUBSCRIPTION_BACKGROUND_HINT}</span></span>
|
||||
</div>
|
||||
{!status.available && <p className="mt-3 text-xs text-muted-foreground">Subscription sign-in requires the desktop app's private server connection. API-key connections remain available here.</p>}
|
||||
{status.connected && <p className="mt-3 text-xs text-muted-foreground">Connected</p>}
|
||||
{interaction && <div className="mt-3 space-y-3 text-xs" data-testid="subscription-login-interaction">
|
||||
{interaction.progress && <p role="status" className="text-muted-foreground">{interaction.progress}</p>}
|
||||
{interaction.userCode && <p>Device code: <code className="select-text font-mono">{interaction.userCode}</code></p>}
|
||||
{interaction.browser && <Button variant="outline" size="sm" disabled={busy} onClick={() => bridge && void bridge.open(dialog, interaction.attemptId, interaction.browser!.id).catch(() => setError("The browser could not be opened. Try again."))}>Open browser</Button>}
|
||||
{prompt && <form className="space-y-2" onSubmit={(event) => { event.preventDefault(); if (bridge) void bridge.respond(dialog, interaction.attemptId, prompt.id, promptValue).catch(() => setError(CONTROL_ERROR)); }}>
|
||||
<label className="block" htmlFor="subscription-auth-prompt">{prompt.message}</label>
|
||||
{prompt.type === "select" ? <select id="subscription-auth-prompt" value={promptValue} onChange={(event) => setPromptValue(event.target.value)} className="w-full rounded-sm border border-border bg-background px-2 py-1.5"><option value="" disabled>Select…</option>{prompt.options?.map((option) => <option key={option.id} value={option.id}>{option.label}</option>)}</select> : <input id="subscription-auth-prompt" type={prompt.type === "secret" ? "password" : "text"} autoComplete="off" value={promptValue} placeholder={prompt.placeholder} onChange={(event) => setPromptValue(event.target.value)} className="w-full rounded-sm border border-border bg-background px-2 py-1.5" />}
|
||||
<Button type="submit" variant="outline" size="sm" disabled={busy || (prompt.type === "select" && !promptValue)}>Continue</Button>
|
||||
</form>}
|
||||
</div>}
|
||||
</>}
|
||||
{(error || status.error) && <p role="alert" className="mt-3 text-xs text-red-400">{error || status.error}</p>}
|
||||
<div className="mt-4 flex flex-wrap items-center justify-end gap-2">
|
||||
{!confirm && status.connectionId && !status.pending && <>
|
||||
<Button variant="ghost" size="sm" disabled={busy || !!operation} onClick={() => setConfirm(true)}>Disconnect</Button>
|
||||
{status.error && <Button variant="outline" size="sm" disabled={busy || !!operation} onClick={() => bridge && void control(() => bridge.retry(dialog))}>Retry</Button>}
|
||||
<Button variant="outline" size="sm" disabled={!status.available || busy || !!operation} onClick={() => void login()}>Reconnect</Button>
|
||||
</>}
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={close}>{confirm || !status.connectionId || status.pending ? "Cancel" : "Close"}</Button>
|
||||
{confirm ? <Button variant="destructive" size="sm" disabled={busy} onClick={() => bridge && void control(() => bridge.disconnect(dialog), true)}>Disconnect</Button> : !status.connectionId && <Button variant="outline" size="sm" disabled={!status.available || !!operation || status.pending} onClick={() => void login()}>Connect</Button>}
|
||||
</div>
|
||||
</ConnectionDialog>}
|
||||
</Context.Provider>;
|
||||
}
|
||||
|
||||
export function NativeSubscriptionConnection({ providerId }: { providerId: NativeSubscriptionProviderId }) {
|
||||
const ui = useContext(Context), descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.providerId === providerId)!;
|
||||
const status = ui.statuses.find((s) => s.providerId === providerId) ?? emptySubscriptionStatus(providerId);
|
||||
return <div className="flex flex-col gap-2" data-testid={`${providerId}-connection`}>
|
||||
<ConnectionRow label={descriptor.label} action={status.connectionId ? "Manage…" : "Connect"} disabled={ui.busy || !status.available || ui.statuses.some((s) => s.pending)} onAction={() => ui.open(providerId)}>
|
||||
{status.connected && <span className="text-xs text-muted-foreground">Connected</span>}
|
||||
</ConnectionRow>
|
||||
{status.error && <p role="alert" className="text-xs text-red-400">{status.error}</p>}
|
||||
</div>;
|
||||
}
|
||||
export function SubscriptionConnections({ setup = false }: { setup?: boolean }) {
|
||||
return <div className="space-y-3" data-testid="subscription-connections"><ChatGPTConnection setup={setup} />{NATIVE_SUBSCRIPTIONS.map((p) => <NativeSubscriptionConnection key={p.providerId} providerId={p.providerId} />)}</div>;
|
||||
}
|
||||
export function NativeSubscriptionBackgroundAccess({ provider }: { provider: string | undefined }) {
|
||||
const ui = useContext(Context), descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.logicalId === provider);
|
||||
const status = ui.statuses.find((s) => s.providerId === descriptor?.providerId);
|
||||
if (!descriptor || !status?.connected || status.allowBackgroundUse) return null;
|
||||
return <Button type="button" size="xs" variant="ghost" disabled={ui.busy} onClick={() => ui.open(descriptor.providerId)}>Enable background use</Button>;
|
||||
}
|
||||
@@ -44,7 +44,8 @@ import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-sig
|
||||
import { Hint } from "@/components/ui/hint";
|
||||
import { type ProviderKeyInfo } from "@/components/settings/keys-section";
|
||||
import { ProviderMenuList } from "@/components/settings/model-menu";
|
||||
import { ChatGPTBackgroundAccess, ChatGPTConnection } from "@/components/settings/chatgpt-connection";
|
||||
import { ChatGPTBackgroundAccess } from "@/components/settings/chatgpt-connection";
|
||||
import { NativeSubscriptionBackgroundAccess, SubscriptionConnections } from "@/components/settings/subscription-connections";
|
||||
import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
|
||||
import { ThinkingMenuList } from "@/components/settings/model-menu";
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
@@ -211,7 +212,7 @@ export function SetupKeysBody({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 border-t border-border/60 pt-4"><ChatGPTConnection setup /></div>
|
||||
<div className="mt-2 border-t border-border/60 pt-4"><h3 className="mb-3 font-ui text-xs text-muted-foreground">Subscriptions</h3><SubscriptionConnections setup /></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -477,7 +478,7 @@ export function SetupDialog({
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/70">{SLOT_META[slot].roster}</span>
|
||||
{slot !== "strong" && <ChatGPTBackgroundAccess provider={entry?.provider} />}
|
||||
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={entry?.provider} /><NativeSubscriptionBackgroundAccess provider={entry?.provider} /></>}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<button
|
||||
|
||||
@@ -48,7 +48,7 @@ const STORAGE_KEY = "lh:sidebar-open";
|
||||
*
|
||||
* Mirrors SIDEBAR_AUTOCLOSE in electron/main.cjs — keep in sync.
|
||||
*/
|
||||
import type { ChatGPTControlBridge } from "@/src/core";
|
||||
import type { ChatGPTControlBridge, NativeSubscriptionControlBridge } from "@/src/core";
|
||||
const SIDEBAR_AUTOCLOSE = 928;
|
||||
|
||||
// Module scope, guarded for SSR (the server has no navigator).
|
||||
@@ -61,6 +61,7 @@ declare global {
|
||||
/** Read-only host OS; determines the native-control gutter. */
|
||||
platform?: string;
|
||||
chatgpt?: ChatGPTControlBridge;
|
||||
subscriptions?: NativeSubscriptionControlBridge;
|
||||
expandWindow: () => void;
|
||||
/** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */
|
||||
onZoom?: (cb: (factor: number) => void) => () => void;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import type { SessionPricing, TurnUsage } from "@/src/core";
|
||||
import { formatTokens, formatUsd, prettyModel } from "@/components/usage-format";
|
||||
import { subscriptionLabels, subscriptionLabel, type SessionPricing, type TurnUsage } from "@/src/core";
|
||||
import { formatTokens, formatUsd, planCostLabel, prettyModel } from "@/components/usage-format";
|
||||
import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker";
|
||||
|
||||
export function formatContextMeasurement(tokens: number | null, limit: number | null): string {
|
||||
@@ -89,7 +89,8 @@ export function StatsBar({
|
||||
// Honest cost: show dollars only when at least one turn priced;
|
||||
// otherwise "—" (never a fake $0.0000).
|
||||
const pricedCount = turns.length - unpriced - subscription;
|
||||
const costLabel = subscription ? (pricedCount > 0 ? `${formatUsd(cost)} API + ChatGPT plan` : "ChatGPT plan") : pricedCount > 0 ? formatUsd(cost) : "—";
|
||||
const providers = pricing?.subscriptionProviders ?? subscriptionLabels(turns.map((turn) => turn.model));
|
||||
const costLabel = planCostLabel(pricedCount > 0 ? cost : null, providers);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -147,7 +148,7 @@ export function StatsBar({
|
||||
<span>{subscription ? "Usage" : "Total cost"}</span>
|
||||
<span className="tabular-nums text-card-foreground">{costLabel}</span>
|
||||
</div>
|
||||
{subscription > 0 && <a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="underline underline-offset-2">Manage ChatGPT usage</a>}
|
||||
{providers.includes("ChatGPT") && <a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="underline underline-offset-2">Manage ChatGPT usage</a>}
|
||||
{unpriced > 0 && (
|
||||
<div className="flex justify-between gap-2">
|
||||
<span>Unpriced turns</span>
|
||||
@@ -170,7 +171,7 @@ export function StatsBar({
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2 tabular-nums text-card-foreground">
|
||||
<span className="text-muted-foreground">{turn.steps.length} steps</span>
|
||||
<span>{turn.model.startsWith("chatgpt:") ? "ChatGPT plan" : c === null ? "—" : formatUsd(c)}</span>
|
||||
<span>{subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c))}</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -8,9 +8,9 @@ export function formatUsd(c: number): string {
|
||||
return c >= 1 ? `$${c.toFixed(2)}` : `$${c.toFixed(4)}`;
|
||||
}
|
||||
|
||||
export function planCostLabel(dollars: number | null, subscription: boolean, format = formatUsd): string {
|
||||
if (subscription) return dollars === null ? "ChatGPT plan" : `${format(dollars)} API + ChatGPT plan`;
|
||||
return dollars === null ? "—" : format(dollars);
|
||||
export function planCostLabel(dollars: number | null, subscription: boolean | string | readonly string[] | undefined, format = formatUsd): string {
|
||||
const labels = subscription === true ? ["ChatGPT"] : typeof subscription === "string" ? [subscription] : Array.isArray(subscription) ? [...new Set(subscription)] : [];
|
||||
return [...(dollars === null ? [] : [format(dollars)]), ...labels].join(" + ") || "—";
|
||||
}
|
||||
|
||||
/** 38000 → "38k", 1234567 → "1.2M" — trimmed, never "38.0k". */
|
||||
|
||||
+10
-2
@@ -399,7 +399,7 @@ Practice turns the graph into problem sets the learner reasons through out loud.
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph DESKTOP["Electron app (production)"]
|
||||
MAIN["Electron main (electron/main.cjs)<br/>thin launcher + security guard<br/>1 IPC channel: lh:expand-window"] -->|"spawn: ELECTRON_RUN_AS_NODE,<br/>loopback port, LH_DATA_DIR=userData/data"| NEXT["standalone Next server (child)<br/>ALL app logic: turns · extraction · jobs · graph"]
|
||||
MAIN["Electron main (electron/main.cjs)<br/>launcher + credential/security owner<br/>origin-pinned controls · private child IPC"] -->|"spawn: ELECTRON_RUN_AS_NODE,<br/>loopback port, LH_DATA_DIR=userData/data"| NEXT["standalone Next server (child)<br/>ALL app logic: turns · extraction · jobs · graph"]
|
||||
MAIN -->|"loadURL, sandboxed renderer"| REND["Browser renderer (components/)<br/>fetch/WebSocket to own origin only"]
|
||||
end
|
||||
subgraph OUTSIDE["outside the process"]
|
||||
@@ -417,10 +417,18 @@ flowchart LR
|
||||
REND -->|"fetch /api/*"| NEXT
|
||||
```
|
||||
|
||||
The Electron main is a launcher + security guard, never a host for app logic: single-instance lock, deny-by-default permissions, origin-pinned navigation, sandboxed renderer. All Next.js code runs unchanged in a child process (`output: "standalone"`), relocated by the `LH_DATA_DIR` seam (also the test seam — `data-dir-seam.test.ts` covers it, including a fresh-child-process case). The build hard-fails if repo `data/` would leak into the bundle or if prompt assets fall out of the trace (`scripts/build-server.mjs`).
|
||||
The Electron main is a launcher + credential/security guard, never a host for teaching or inference logic: single-instance lock, deny-by-default permissions, origin-pinned navigation, sandboxed renderer. All Next.js code runs unchanged in a child process (`output: "standalone"`), relocated by the `LH_DATA_DIR` seam (also the test seam — `data-dir-seam.test.ts` covers it, including a fresh-child-process case). The build hard-fails if repo `data/` would leak into the bundle or if prompt assets fall out of the trace (`scripts/build-server.mjs`).
|
||||
|
||||
---
|
||||
|
||||
### 8.1 Subscription connection boundary
|
||||
|
||||
`electron/chatgpt.cjs` retains the existing verified-account ChatGPT authentication, storage and revocation behavior. `electron/subscriptions.cjs` delegates GitHub Copilot/Grok login and refresh to the installed Pi SDK through its public provider/Models interfaces. Their single saved connections live independently in owner-only, atomically written `github-copilot-credentials.json` / `xai-credentials.json` beside the existing app credential files. Electron main alone owns durable OAuth credentials and serialized refresh; no global Pi files or API-key credential precedence are involved.
|
||||
|
||||
The renderer gets projected status, public device user codes, prompts and controls over origin-pinned IPC. The privately launched server child receives credential-free catalogs and scoped request auth (including native headers/account endpoints), never refresh tokens. `src/agent/subscription-bridge.ts` rejects framework-only IPC and invalidates stale connection/catalog scopes. `subscription-stream.ts` dispatches directly through native providers, retaining native protocol identity on the wire and restoring logical app identity in assistant history. Persisted routes `chatgpt`, `github-copilot-subscription` and `xai-subscription` are distinct from ordinary API routes and never fall back to them.
|
||||
|
||||
`subscription-request-cycle.ts` is the shared admission/automatic-input recovery state machine; the old ChatGPT module is its compatibility export. Background consent is independent per provider, default ON, with foreground-preserving cancellation and queue ownership retained on no-dispatch. Native usage rejected before dispatch is transiently excluded from helper usage collectors, without adding persisted provenance fields. Catalogs and read-time pricing preserve the selected connection: subscription use displays compact provider labels, API dollars remain dollars, and mixed usage joins both without estimating subscription dollars or claiming detected tiers/allowances.
|
||||
|
||||
## 9. The foundational invariants — what ALWAYS holds
|
||||
|
||||
### Tier A — machine-enforced (`eslint.config.mjs`; lint fails the build)
|
||||
|
||||
+30
-1
@@ -27,6 +27,8 @@
|
||||
const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog } = require("electron");
|
||||
const { createChatGPT, fileStore } = require("./chatgpt.cjs");
|
||||
const { attachChatGPTChild, trustedSender, chatGPTControl } = require("./chatgpt-ipc.cjs");
|
||||
const { createSubscriptions, loadSubscriptionSDK } = require("./subscriptions.cjs");
|
||||
const { attachSubscriptionChild, subscriptionControl } = require("./subscription-ipc.cjs");
|
||||
const { desktopAPIHeaders } = require("./desktop-api.cjs");
|
||||
const { randomBytes } = require("node:crypto");
|
||||
const { spawn } = require("node:child_process");
|
||||
@@ -134,6 +136,8 @@ if (process.argv.includes("--browser-service")) {
|
||||
let serverProcess = null;
|
||||
let chatgpt = null;
|
||||
let chatgptChild = null;
|
||||
let subscriptions = null;
|
||||
let subscriptionChild = null;
|
||||
// Per-launch request capability, never OAuth tokens and never renderer state.
|
||||
let desktopAPISecret = null;
|
||||
const trustedAppWindows = new Set();
|
||||
@@ -222,6 +226,7 @@ function startServer(port) {
|
||||
);
|
||||
serverProcess = child;
|
||||
chatgptChild = attachChatGPTChild(child, chatgpt);
|
||||
subscriptionChild = attachSubscriptionChild(child, subscriptions);
|
||||
const fwd = (buf) => buf.toString().trimEnd().split("\n").forEach((l) => l && log(`server: ${l}`));
|
||||
child.stdout.on("data", fwd);
|
||||
child.stderr.on("data", fwd);
|
||||
@@ -230,7 +235,10 @@ function startServer(port) {
|
||||
serverProcess = null;
|
||||
chatgptChild?.dispose();
|
||||
chatgptChild = null;
|
||||
subscriptionChild?.dispose();
|
||||
subscriptionChild = null;
|
||||
chatgpt?.close();
|
||||
subscriptions?.close();
|
||||
});
|
||||
resolve(child);
|
||||
});
|
||||
@@ -255,6 +263,7 @@ function waitUntilUp(url, tries = 150) {
|
||||
|
||||
function killServer() {
|
||||
chatgpt?.close();
|
||||
subscriptions?.close();
|
||||
if (!serverProcess) return;
|
||||
const child = serverProcess;
|
||||
serverProcess = null;
|
||||
@@ -569,6 +578,7 @@ function installMenu() {
|
||||
async function main() {
|
||||
app.on("window-all-closed", () => {
|
||||
chatgpt?.pause();
|
||||
subscriptions?.pause();
|
||||
// macOS convention: the app stays resident, reactivated from the Dock.
|
||||
if (process.platform !== "darwin") app.quit();
|
||||
});
|
||||
@@ -600,6 +610,16 @@ async function main() {
|
||||
for (const win of BrowserWindow.getAllWindows()) win.webContents.send("lh:chatgpt:changed");
|
||||
},
|
||||
});
|
||||
subscriptions = createSubscriptions({
|
||||
directory: app.getPath("userData"),
|
||||
loadSDK: () => loadSubscriptionSDK(path.dirname(serverEntry())),
|
||||
openExternal: (target) => shell.openExternal(target),
|
||||
onChange: (snapshot) => {
|
||||
subscriptionChild?.send(snapshot);
|
||||
for (const win of BrowserWindow.getAllWindows()) win.webContents.send("lh:subscriptions:changed");
|
||||
},
|
||||
});
|
||||
await subscriptions.ready;
|
||||
log(`starting server: ${serverEntry()}`);
|
||||
log(`data dir: ${DATA_DIR()}${fs.existsSync(AUTH_FILE()) ? " · auth.json: present" : " · auth.json: ABSENT (no BYOK keys yet)"}`);
|
||||
await startServer(new URL(url).port);
|
||||
@@ -617,6 +637,15 @@ async function main() {
|
||||
return chatGPTControl(command, chatgpt);
|
||||
});
|
||||
|
||||
ipcMain.handle("lh:subscriptions:control", async (event, command) => {
|
||||
if (!trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) throw new Error("Untrusted desktop request.");
|
||||
if (!subscriptions || !serverProcess?.connected) {
|
||||
if (command?.op === "status") return [];
|
||||
throw new Error("Subscription sign-in requires the desktop app's private server connection.");
|
||||
}
|
||||
return subscriptionControl(command, subscriptions);
|
||||
});
|
||||
|
||||
installMenu();
|
||||
|
||||
// Auto-update (Wave C): wire the updater before the window so the
|
||||
@@ -642,7 +671,7 @@ async function main() {
|
||||
if (width < target) win.setBounds({ x, y, width: target, height });
|
||||
});
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) { chatgpt?.resume(); createWindow(url); }
|
||||
if (BrowserWindow.getAllWindows().length === 0) { chatgpt?.resume(); subscriptions?.resume(); createWindow(url); }
|
||||
});
|
||||
createWindow(url);
|
||||
log(`window open: ${url}`);
|
||||
|
||||
@@ -43,6 +43,21 @@ contextBridge.exposeInMainWorld("lh", {
|
||||
return () => ipcRenderer.removeListener("lh:chatgpt:changed", listener);
|
||||
},
|
||||
},
|
||||
subscriptions: {
|
||||
status: () => ipcRenderer.invoke("lh:subscriptions:control", { op: "status" }),
|
||||
login: (providerId, enabled) => ipcRenderer.invoke("lh:subscriptions:control", { op: "login", providerId, enabled }),
|
||||
cancel: (providerId) => ipcRenderer.invoke("lh:subscriptions:control", { op: "cancel", providerId }),
|
||||
respond: (providerId, attemptId, promptId, value) => ipcRenderer.invoke("lh:subscriptions:control", { op: "respond", providerId, attemptId, promptId, value }),
|
||||
open: (providerId, attemptId, actionId) => ipcRenderer.invoke("lh:subscriptions:control", { op: "open", providerId, attemptId, actionId }),
|
||||
retry: (providerId) => ipcRenderer.invoke("lh:subscriptions:control", { op: "retry", providerId }),
|
||||
disconnect: (providerId) => ipcRenderer.invoke("lh:subscriptions:control", { op: "disconnect", providerId }),
|
||||
consent: (providerId, enabled) => ipcRenderer.invoke("lh:subscriptions:control", { op: "consent", providerId, enabled }),
|
||||
onChange: (cb) => {
|
||||
const listener = () => cb();
|
||||
ipcRenderer.on("lh:subscriptions:changed", listener);
|
||||
return () => ipcRenderer.removeListener("lh:subscriptions:changed", listener);
|
||||
},
|
||||
},
|
||||
expandWindow: () => ipcRenderer.send("lh:expand-window"),
|
||||
onZoom: (cb) => {
|
||||
const listener = (_event, factor) => cb(factor);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use strict";
|
||||
const { PROVIDERS } = require("./subscription-store.cjs");
|
||||
const { publicError } = require("./subscriptions.cjs");
|
||||
function attachSubscriptionChild(child, controller) {
|
||||
let disposed = false;
|
||||
const send = (packet) => { if (!disposed && child.connected) { try { child.send(packet, () => {}); } catch { /* child exited */ } } };
|
||||
const listener = async (message) => {
|
||||
if (disposed || !child.connected || message?.channel !== "lh:subscriptions" || typeof message.id !== "string") return;
|
||||
const { id, op } = message;
|
||||
try {
|
||||
if (!controller || !["snapshot", "lease"].includes(op)) throw new Error();
|
||||
if (op === "lease" && (typeof message.background !== "boolean" || !validScope(message.scope))) throw new Error();
|
||||
const value = op === "snapshot" ? await controller.snapshot() : await controller.lease(message.scope, message.background);
|
||||
send({ channel: "lh:subscriptions", id, op, ok: true, value });
|
||||
} catch (error) { send({ channel: "lh:subscriptions", id, op, ok: false, error: publicError(error) }); }
|
||||
};
|
||||
child.on("message", listener);
|
||||
return {
|
||||
send(snapshot) { send({ channel: "lh:subscriptions", op: "changed", snapshot }); },
|
||||
dispose() { disposed = true; child.removeListener("message", listener); },
|
||||
};
|
||||
}
|
||||
function validScope(scope) {
|
||||
return scope && PROVIDERS.includes(scope.providerId) && typeof scope.connectionId === "string" && !!scope.connectionId && typeof scope.selectedModelId === "string" && !!scope.selectedModelId && [scope.epoch, scope.authorityRevision].every((n) => Number.isSafeInteger(n) && n >= 0);
|
||||
}
|
||||
/** Caller authenticates the exact trusted main-frame origin before dispatch. */
|
||||
async function subscriptionControl(command, controller) {
|
||||
try {
|
||||
if (!controller || !command || typeof command !== "object") throw new Error();
|
||||
if (command.op === "status") return await controller.status();
|
||||
if (!PROVIDERS.includes(command.providerId)) throw new Error();
|
||||
const id = command.providerId;
|
||||
switch (command.op) {
|
||||
case "login": return await controller.login(id, command.enabled);
|
||||
case "cancel": return controller.cancel(id);
|
||||
case "respond": return await controller.respond(id, command.attemptId, command.promptId, command.value);
|
||||
case "open": return await controller.open(id, command.attemptId, command.actionId);
|
||||
case "retry": return await controller.retry(id);
|
||||
case "disconnect": return await controller.disconnect(id);
|
||||
case "consent": return await controller.consent(id, command.enabled);
|
||||
default: throw new Error();
|
||||
}
|
||||
} catch (error) { throw new Error(publicError(error)); }
|
||||
}
|
||||
module.exports = { attachSubscriptionChild, subscriptionControl, validScope };
|
||||
@@ -0,0 +1,64 @@
|
||||
"use strict";
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
|
||||
const PROVIDERS = ["github-copilot", "xai"];
|
||||
const failure = (code) => Object.assign(new Error(code), { code });
|
||||
const nonempty = (value) => typeof value === "string" && value.length > 0 && !/[\x00-\x20\x7f]/.test(value);
|
||||
function validateCredential(providerId, credential) {
|
||||
if (!credential || credential.type !== "oauth" || !nonempty(credential.access) || !nonempty(credential.refresh) || !Number.isFinite(credential.expires) || credential.expires < 0) throw failure("storage");
|
||||
if (providerId === "github-copilot") {
|
||||
if (credential.enterpriseUrl !== undefined && !safeDomain(credential.enterpriseUrl)) throw failure("storage");
|
||||
if (credential.availableModelIds !== undefined && (!Array.isArray(credential.availableModelIds) || !credential.availableModelIds.every(nonempty))) throw failure("storage");
|
||||
}
|
||||
return credential;
|
||||
}
|
||||
function safeDomain(value) {
|
||||
if (typeof value !== "string" || !value || /[\s/:@?#\\]/.test(value)) return false;
|
||||
try { return new URL(`https://${value}`).hostname === value; } catch { return false; }
|
||||
}
|
||||
function safeHttps(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "https:" && !url.username && !url.password;
|
||||
} catch { return false; }
|
||||
}
|
||||
function validateState(providerId, state) {
|
||||
if (!PROVIDERS.includes(providerId) || !state || state.version !== 1 || typeof state.allowBackgroundUse !== "boolean") throw failure("storage");
|
||||
const hasId = Object.hasOwn(state, "connectionId");
|
||||
if (hasId !== Object.hasOwn(state, "credential") || (hasId && !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(state.connectionId))) throw failure("storage");
|
||||
if (hasId) validateCredential(providerId, state.credential);
|
||||
return state;
|
||||
}
|
||||
/** Main-process-only, one independent owner-only file per provider. No recovery writes on corruption. */
|
||||
function fileStore(file, providerId, io = fs) {
|
||||
return {
|
||||
async read() {
|
||||
try {
|
||||
const stat = await io.lstat(file);
|
||||
if (!stat.isFile() || (process.platform !== "win32" && ((stat.mode & 0o077) !== 0 || (typeof process.getuid === "function" && stat.uid !== process.getuid())))) throw failure("storage");
|
||||
return validateState(providerId, JSON.parse(await io.readFile(file, "utf8")));
|
||||
}
|
||||
catch (error) { if (error.code === "ENOENT") return { version: 1, allowBackgroundUse: true }; throw failure("storage"); }
|
||||
},
|
||||
async write(state) {
|
||||
validateState(providerId, state);
|
||||
const temporary = `${file}.${randomUUID()}.tmp`;
|
||||
let handle;
|
||||
try {
|
||||
await io.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||
handle = await io.open(temporary, "wx", 0o600);
|
||||
await handle.writeFile(`${JSON.stringify(state)}\n`);
|
||||
await handle.sync(); await handle.close(); handle = null;
|
||||
await io.rename(temporary, file);
|
||||
if (process.platform !== "win32") {
|
||||
const directory = await io.open(path.dirname(file), "r");
|
||||
try { await directory.sync(); } finally { await directory.close(); }
|
||||
}
|
||||
} catch { throw failure("storage"); }
|
||||
finally { await handle?.close().catch(() => {}); await io.unlink(temporary).catch(() => {}); }
|
||||
},
|
||||
};
|
||||
}
|
||||
module.exports = { PROVIDERS, failure, validateCredential, validateState, safeDomain, safeHttps, fileStore };
|
||||
@@ -0,0 +1,251 @@
|
||||
"use strict";
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const path = require("node:path");
|
||||
const { createRequire, registerHooks } = require("node:module");
|
||||
const { pathToFileURL } = require("node:url");
|
||||
const { PROVIDERS, fileStore, validateCredential, safeHttps, failure } = require("./subscription-store.cjs");
|
||||
|
||||
const LABELS = { "github-copilot": "GitHub Copilot", xai: "Grok" };
|
||||
const APIS = new Set(["openai-responses", "openai-completions", "anthropic-messages"]);
|
||||
const clone = (value) => value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
||||
const MESSAGES = {
|
||||
storage: "The saved connection could not be read or written safely. Reopen the app or reconnect after resolving the credential-file problem.",
|
||||
login: "Sign-in did not complete. Try connecting again.",
|
||||
refresh: "The connection could not be refreshed. Retry or reconnect in Providers.",
|
||||
cancel: "Sign-in cancelled.",
|
||||
unavailable: "This subscription connection is unavailable. Reconnect or explicitly choose another connection.",
|
||||
background: "Background use is off for this connection. Automatic follow-up remains deferred in its queue.",
|
||||
stale: "The connection or model catalog changed. Select an available model and retry.",
|
||||
interaction: "This sign-in action is no longer available.",
|
||||
};
|
||||
function publicError(error) { return MESSAGES[error?.code] ?? MESSAGES.unavailable; }
|
||||
/** Resolve the SAME SDK installation as the standalone server, through public exports. */
|
||||
async function loadSubscriptionSDK(baseDirectory) {
|
||||
const resolve = createRequire(path.join(baseDirectory, "package.json"));
|
||||
const load = (id) => {
|
||||
// Pi exports are import-only. Resolve the public export with ESM
|
||||
// conditions, then remove this synchronous, exact-specifier hook
|
||||
// BEFORE evaluation; no private package path or global hook remains.
|
||||
const hook = registerHooks({ resolve(specifier, context, next) {
|
||||
return next(specifier, specifier === id ? { ...context, conditions: [...context.conditions, "import"] } : context);
|
||||
} });
|
||||
let file;
|
||||
try { file = resolve.resolve(id); } finally { hook.deregister(); }
|
||||
return import(pathToFileURL(file).href);
|
||||
};
|
||||
const [sdk, copilot, xai] = await Promise.all([load("@earendil-works/pi-ai"), load("@earendil-works/pi-ai/providers/github-copilot"), load("@earendil-works/pi-ai/providers/xai")]);
|
||||
return { ...sdk, ...copilot, ...xai };
|
||||
}
|
||||
function validEndpoint(providerId, auth, credential) {
|
||||
if (!auth || (!(typeof auth.apiKey === "string" && auth.apiKey.length > 0) && !Object.entries(auth.headers ?? {}).some(([key, value]) => ["authorization", "x-api-key", "api-key"].includes(key.toLowerCase()) && typeof value === "string" && value.trim().length > 0))) return false;
|
||||
const value = auth.baseUrl ?? (providerId === "xai" ? "https://api.x.ai/v1" : "https://api.individual.githubcopilot.com");
|
||||
if (!safeHttps(value)) return false;
|
||||
const url = new URL(value);
|
||||
if (providerId === "xai") return url.origin === "https://api.x.ai" && url.pathname.replace(/\/$/, "") === "/v1" && !url.search && !url.hash;
|
||||
const domain = credential?.enterpriseUrl;
|
||||
return !url.search && !url.hash && url.pathname === "/" && (!url.port || url.port === "443") && (url.hostname.endsWith(".githubcopilot.com") || (domain && (url.hostname === domain || url.hostname.endsWith(`.${domain}`))));
|
||||
}
|
||||
/** Main owns durable credentials and refresh. Everything returned by status/snapshot is explicitly projected. */
|
||||
function createSubscriptions(options) {
|
||||
let sdk;
|
||||
let closed = false, paused = false;
|
||||
const entries = new Map(PROVIDERS.map((providerId) => [providerId, {
|
||||
providerId, state: { version: 1, allowBackgroundUse: true }, disk: options.stores?.[providerId] ?? fileStore(path.join(options.directory, `${providerId}-credentials.json`), providerId),
|
||||
epoch: 0, authorityRevision: 0, revision: 0, tail: Promise.resolve(), fatal: false, blocked: false, ready: false, error: undefined, authFailed: false, attempt: null, catalog: [], endpoint: null,
|
||||
}]));
|
||||
const entryFor = (id) => { const entry = entries.get(id); if (!entry) throw failure("unavailable"); return entry; };
|
||||
function serialized(entry, fn) { const result = entry.tail.then(fn); entry.tail = result.catch(() => {}); return result; }
|
||||
function statusOf(entry) {
|
||||
const available = entry.ready && !closed && !paused && !entry.fatal;
|
||||
const attempt = entry.attempt;
|
||||
return {
|
||||
providerId: entry.providerId, available, connected: !!(available && entry.state.credential && !entry.blocked && !entry.authFailed), pending: !!attempt, committing: attempt?.committing === true,
|
||||
allowBackgroundUse: entry.state.allowBackgroundUse, connectionId: entry.state.connectionId ?? null,
|
||||
epoch: entry.epoch, authorityRevision: entry.authorityRevision, revision: entry.revision,
|
||||
...(entry.error ? { error: entry.error } : {}),
|
||||
...(attempt ? { interaction: clone(attempt.public) } : {}),
|
||||
};
|
||||
}
|
||||
function snapshotNow() {
|
||||
return { statuses: [...entries.values()].map(statusOf), models: Object.fromEntries([...entries.values()].map((entry) => [entry.providerId, statusOf(entry).connected ? clone(entry.catalog) : []])) };
|
||||
}
|
||||
function changed(entry) { entry.revision++; try { options.onChange?.(snapshotNow()); } catch { /* observer cannot break credential writes */ } }
|
||||
function catalog(entry) {
|
||||
let models = entry.provider?.getModels().filter((model) => APIS.has(model.api)) ?? [];
|
||||
if (!entry.state.credential) models = [];
|
||||
else if (entry.providerId === "github-copilot" && !Array.isArray(entry.state.credential.availableModelIds)) models = [];
|
||||
else if (entry.provider?.filterModels) models = [...entry.provider.filterModels(models, entry.state.credential)];
|
||||
if (JSON.stringify(models) !== JSON.stringify(entry.catalog)) { entry.catalog = models; entry.authorityRevision++; }
|
||||
}
|
||||
async function updateEndpoint(entry) {
|
||||
let endpoint = null;
|
||||
if (entry.state.credential) {
|
||||
const auth = await entry.provider.auth.oauth.toAuth(entry.state.credential);
|
||||
if (!validEndpoint(entry.providerId, auth, entry.state.credential)) throw failure("storage");
|
||||
endpoint = auth.baseUrl ?? (entry.providerId === "xai" ? "https://api.x.ai/v1" : "https://api.individual.githubcopilot.com");
|
||||
}
|
||||
if (entry.endpoint !== endpoint) { entry.endpoint = endpoint; entry.authorityRevision++; }
|
||||
}
|
||||
function storageFailed(entry) { entry.fatal = true; entry.blocked = true; entry.epoch++; entry.error = MESSAGES.storage; changed(entry); }
|
||||
async function save(entry, state) {
|
||||
try { await entry.disk.write(state); } catch { storageFailed(entry); throw failure("storage"); }
|
||||
entry.state = clone(state);
|
||||
try { await updateEndpoint(entry); catalog(entry); } catch { storageFailed(entry); throw failure("storage"); }
|
||||
changed(entry);
|
||||
}
|
||||
const ready = (async () => {
|
||||
try { sdk = options.sdk ?? await options.loadSDK(); }
|
||||
catch { for (const entry of entries.values()) { entry.error = MESSAGES.unavailable; entry.fatal = true; changed(entry); } return; }
|
||||
await Promise.all([...entries.values()].map(async (entry) => {
|
||||
try {
|
||||
entry.state = clone(await entry.disk.read());
|
||||
const native = entry.providerId === "github-copilot" ? sdk.githubCopilotProvider() : sdk.xaiProvider();
|
||||
// No API-key auth method exists in this collection: ambient keys cannot become a fallback.
|
||||
entry.provider = { ...native, auth: { oauth: native.auth.oauth } };
|
||||
const store = {
|
||||
read: async (id) => id === entry.providerId ? clone(entry.state.credential) : undefined,
|
||||
list: async () => entry.state.credential ? [{ providerId: entry.providerId, type: "oauth" }] : [],
|
||||
modify: (id, fn, operation) => serialized(entry, async () => {
|
||||
if (id !== entry.providerId || entry.fatal || closed || paused || operation?.signal?.aborted) throw failure("unavailable");
|
||||
const next = await fn(clone(entry.state.credential));
|
||||
// A returned rotation must be saved even if the app paused while
|
||||
// refresh was in flight. Admission is rechecked after resolution;
|
||||
// queued logout deletes only after this serialized durable write.
|
||||
if (next !== undefined && JSON.stringify(next) !== JSON.stringify(entry.state.credential)) {
|
||||
try { validateCredential(id, next); } catch { storageFailed(entry); throw failure("storage"); }
|
||||
await save(entry, { ...entry.state, credential: next });
|
||||
}
|
||||
return clone(entry.state.credential);
|
||||
}),
|
||||
delete: (id) => serialized(entry, async () => {
|
||||
if (id !== entry.providerId || entry.fatal) throw failure("storage");
|
||||
await save(entry, { version: 1, allowBackgroundUse: entry.state.allowBackgroundUse });
|
||||
}),
|
||||
};
|
||||
entry.models = sdk.createModels({ credentials: store }); entry.models.setProvider(entry.provider);
|
||||
await updateEndpoint(entry); entry.ready = true; catalog(entry); changed(entry);
|
||||
} catch { entry.fatal = true; entry.error = MESSAGES.storage; changed(entry); }
|
||||
}));
|
||||
})();
|
||||
function current(entry, scope) {
|
||||
return statusOf(entry).connected && scope?.providerId === entry.providerId && scope.connectionId === entry.state.connectionId && scope.epoch === entry.epoch && scope.authorityRevision === entry.authorityRevision && entry.catalog.some((m) => m.id === scope.selectedModelId);
|
||||
}
|
||||
async function consent(providerId, enabled) {
|
||||
await ready; const entry = entryFor(providerId);
|
||||
if (typeof enabled !== "boolean" || entry.fatal || closed || paused) throw failure("unavailable");
|
||||
await serialized(entry, async () => { if (entry.state.allowBackgroundUse !== enabled) await save(entry, { ...entry.state, allowBackgroundUse: enabled }); });
|
||||
}
|
||||
async function login(providerId, enabled) {
|
||||
await ready; const entry = entryFor(providerId);
|
||||
if ((enabled !== undefined && typeof enabled !== "boolean") || entry.fatal || closed || paused || !entry.ready) throw failure("unavailable");
|
||||
if ([...entries.values()].some((e) => e.attempt)) throw failure("interaction");
|
||||
const abort = new AbortController();
|
||||
const attempt = { id: randomUUID(), abort, committing: false, actions: new Map(), prompt: null, public: {} };
|
||||
attempt.public = { attemptId: attempt.id }; entry.attempt = attempt; entry.error = undefined; changed(entry);
|
||||
const active = () => entry.attempt === attempt && !abort.signal.aborted && !closed && !paused;
|
||||
const addBrowser = (value) => {
|
||||
if (!safeHttps(value)) throw failure("interaction");
|
||||
const id = randomUUID(); attempt.actions.set(id, value);
|
||||
const url = new URL(value); attempt.public.browser = { id, url: `${url.origin}${url.pathname}` };
|
||||
};
|
||||
try {
|
||||
const staging = sdk.createModels(); staging.setProvider(entry.provider);
|
||||
const credential = await staging.login(providerId, "oauth", {
|
||||
signal: abort.signal,
|
||||
notify: (event) => {
|
||||
if (!active()) return;
|
||||
if (event.type === "auth_url") addBrowser(event.url);
|
||||
if (event.type === "device_code") { addBrowser(event.verificationUri); attempt.public.userCode = event.userCode; }
|
||||
// Native provider diagnostics are never forwarded. Public prompt wording comes from SDK-owned prompts.
|
||||
attempt.public.progress = event.type === "device_code" || event.type === "auth_url" ? "Complete sign-in in your browser." : "Waiting for the provider…";
|
||||
changed(entry);
|
||||
},
|
||||
prompt: (prompt) => new Promise((resolve, reject) => {
|
||||
if (!active() || prompt.signal?.aborted) { reject(failure("cancel")); return; }
|
||||
const id = randomUUID();
|
||||
const stop = () => { cleanup(); reject(failure("cancel")); };
|
||||
const cleanup = () => { abort.signal.removeEventListener("abort", stop); prompt.signal?.removeEventListener("abort", stop); if (attempt.prompt?.id === id) { attempt.prompt = null; delete attempt.public.prompt; } };
|
||||
attempt.prompt = { id, resolve: (value) => { cleanup(); resolve(value); }, reject: stop };
|
||||
attempt.public.prompt = { id, type: prompt.type, message: prompt.message, ...(prompt.placeholder ? { placeholder: prompt.placeholder } : {}), ...(prompt.type === "select" ? { options: clone(prompt.options) } : {}) };
|
||||
abort.signal.addEventListener("abort", stop, { once: true }); prompt.signal?.addEventListener("abort", stop, { once: true }); changed(entry);
|
||||
}),
|
||||
}, { agentName: "LearnHarness" });
|
||||
if (!active()) throw failure("cancel"); validateCredential(providerId, credential);
|
||||
const auth = await entry.provider.auth.oauth.toAuth(credential);
|
||||
if (!validEndpoint(providerId, auth, credential) || !active()) throw failure("login");
|
||||
await serialized(entry, async () => {
|
||||
if (!active() || entry.fatal) throw failure("cancel");
|
||||
attempt.committing = true; entry.blocked = true; entry.epoch++; changed(entry);
|
||||
await save(entry, { version: 1, allowBackgroundUse: enabled ?? entry.state.allowBackgroundUse, connectionId: randomUUID(), credential });
|
||||
entry.blocked = false; entry.authFailed = false; entry.error = undefined;
|
||||
});
|
||||
} catch (error) {
|
||||
if (active() && !entry.fatal) entry.error = error?.code === "cancel" ? undefined : MESSAGES.login;
|
||||
if (!abort.signal.aborted) throw failure(entry.fatal ? "storage" : "login");
|
||||
} finally { attempt.prompt?.reject(); if (entry.attempt === attempt) { entry.attempt = null; changed(entry); } }
|
||||
}
|
||||
function cancel(providerId) { const entry = entryFor(providerId); if (entry.attempt && !entry.attempt.committing) { entry.attempt.abort.abort(); entry.attempt = null; if (entry.authFailed && !entry.error) entry.error = MESSAGES.refresh; changed(entry); } }
|
||||
async function resolveAuth(entry, modelId) {
|
||||
if (entry.fatal || closed || paused || !entry.state.credential) throw failure("unavailable");
|
||||
const connectionId = entry.state.connectionId, epoch = entry.epoch;
|
||||
const unchanged = () => connectionId === entry.state.connectionId && epoch === entry.epoch && !entry.blocked && !closed && !paused;
|
||||
try {
|
||||
const model = entry.provider.getModels().find((m) => m.id === modelId);
|
||||
if (!model) throw failure("stale");
|
||||
const resolved = await entry.models.getAuth(model);
|
||||
if (!unchanged()) throw failure("stale");
|
||||
if (!validEndpoint(entry.providerId, resolved?.auth, entry.state.credential)) throw failure("unavailable");
|
||||
return {
|
||||
...(typeof resolved.auth.apiKey === "string" ? { apiKey: resolved.auth.apiKey } : {}),
|
||||
...(resolved.auth.headers ? { headers: Object.fromEntries(Object.entries(resolved.auth.headers).filter(([, value]) => value === null || typeof value === "string")) } : {}),
|
||||
...(typeof resolved.auth.baseUrl === "string" ? { baseUrl: resolved.auth.baseUrl } : {}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (!entry.fatal && unchanged()) { entry.authFailed = true; entry.error = MESSAGES.refresh; entry.epoch++; changed(entry); }
|
||||
throw failure(entry.fatal ? "storage" : error?.code === "stale" ? "stale" : "refresh");
|
||||
}
|
||||
}
|
||||
return {
|
||||
ready,
|
||||
async snapshot() { await ready; return snapshotNow(); },
|
||||
async status() { await ready; return snapshotNow().statuses; },
|
||||
login, consent, cancel,
|
||||
async respond(providerId, attemptId, promptId, value) {
|
||||
const entry = entryFor(providerId), attempt = entry.attempt;
|
||||
if (!attempt || attempt.id !== attemptId || attempt.prompt?.id !== promptId || typeof value !== "string" || value.length > 4096) throw failure("interaction");
|
||||
const prompt = attempt.public.prompt;
|
||||
if (prompt.type === "select" && !prompt.options.some((option) => option.id === value)) throw failure("interaction");
|
||||
attempt.prompt.resolve(value); changed(entry);
|
||||
},
|
||||
async open(providerId, attemptId, actionId) {
|
||||
const attempt = entryFor(providerId).attempt;
|
||||
const target = attempt?.id === attemptId ? attempt.actions.get(actionId) : undefined;
|
||||
if (!target || !safeHttps(target)) throw failure("interaction");
|
||||
try { await options.openExternal(target); } catch { throw failure("interaction"); }
|
||||
},
|
||||
async lease(scope, background) {
|
||||
await ready; const entry = entryFor(scope?.providerId);
|
||||
if (!current(entry, scope)) throw failure("stale");
|
||||
if (background && !entry.state.allowBackgroundUse) throw failure("background");
|
||||
const auth = await resolveAuth(entry, scope.selectedModelId);
|
||||
if (!current(entry, scope)) throw failure("stale");
|
||||
if (background && !entry.state.allowBackgroundUse) throw failure("background");
|
||||
return { scope: clone(scope), auth: clone(auth) };
|
||||
},
|
||||
async retry(providerId) {
|
||||
await ready; const entry = entryFor(providerId);
|
||||
const model = entry.catalog[0]; if (!model || entry.fatal || entry.blocked) throw failure("unavailable");
|
||||
await resolveAuth(entry, model.id); entry.authFailed = false; entry.error = undefined; changed(entry);
|
||||
},
|
||||
async disconnect(providerId) {
|
||||
await ready; const entry = entryFor(providerId); cancel(providerId);
|
||||
if (entry.fatal) throw failure("storage");
|
||||
entry.blocked = true; entry.epoch++; changed(entry);
|
||||
await entry.models.logout(providerId); entry.blocked = false; entry.authFailed = false; entry.error = undefined; changed(entry);
|
||||
},
|
||||
pause() { paused = true; for (const entry of entries.values()) { cancel(entry.providerId); entry.epoch++; changed(entry); } },
|
||||
resume() { if (closed) return; paused = false; for (const entry of entries.values()) changed(entry); },
|
||||
close() { closed = true; for (const entry of entries.values()) { cancel(entry.providerId); entry.epoch++; changed(entry); } },
|
||||
};
|
||||
}
|
||||
module.exports = { createSubscriptions, loadSubscriptionSDK, publicError, validEndpoint, LABELS };
|
||||
@@ -74,6 +74,11 @@ const nextConfig: NextConfig = {
|
||||
// per-platform-binary story as the canvas above (one per build).
|
||||
"node_modules/@resvg/resvg-js-*/**/*",
|
||||
"node_modules/@earendil-works/pi-ai/dist/providers/data/**/*",
|
||||
// Public native OAuth factories load these flows through variable imports.
|
||||
// Electron main uses the same standalone SDK; the offline runtime gate
|
||||
// evaluates their real toAuth paths without login or provider requests.
|
||||
"node_modules/@earendil-works/pi-ai/dist/auth/oauth/{load,github-copilot,xai,device-code}.js",
|
||||
"node_modules/@earendil-works/pi-ai/dist/utils/sleep.js",
|
||||
"node_modules/@earendil-works/pi-agent-core/dist/**/*.json",
|
||||
// Next 16.3.2 native Turbopack applies unanchored excludes while
|
||||
// traversing modules; **/data/**/* still prunes gcp-metadata and
|
||||
|
||||
@@ -103,6 +103,29 @@ async function probe(standalone) {
|
||||
noNetwork();
|
||||
console.log(`[smoke-provider-runtime] ${name}: native lazy Google adapter reached onPayload; generation stopped`);
|
||||
}
|
||||
// Exercise the public lazy OAuth surface offline. toAuth evaluates the
|
||||
// real flow module and its imports but neither logs in nor refreshes.
|
||||
for (const [name, factory] of [["github-copilot", "githubCopilotProvider"], ["xai", "xaiProvider"]]) {
|
||||
const provider = (await import(new URL(`providers/${name}.js`, entry).href))[factory]();
|
||||
const credential = { type: "oauth", access: name === "github-copilot" ? "tid=offline;proxy-ep=proxy.individual.githubcopilot.com;" : "offline-xai-oauth-not-a-token", refresh: "offline-refresh-not-a-token", expires: Date.now() + 3600000, availableModelIds: provider.getModels().map((model) => model.id) };
|
||||
const auth = await provider.auth.oauth.toAuth(credential);
|
||||
assert.equal(auth.apiKey, credential.access);
|
||||
if (name === "github-copilot") assert.equal(auth.baseUrl, "https://api.individual.githubcopilot.com");
|
||||
const seenApis = new Set();
|
||||
for (const model of provider.getModels()) {
|
||||
if (seenApis.has(model.api)) continue;
|
||||
seenApis.add(model.api);
|
||||
const sentinel = `LH_OFFLINE_SUBSCRIPTION_${randomUUID()}`;
|
||||
let reached = 0;
|
||||
const result = await provider.streamSimple({ ...model, ...(auth.baseUrl ? { baseUrl: auth.baseUrl } : {}) }, { messages: [{ role: "user", content: "Offline initialization only", timestamp: 0 }] }, {
|
||||
...auth, maxRetries: 0, onPayload() { reached++; throw new Error(sentinel); },
|
||||
}).result();
|
||||
assert.equal(reached, 1, `${name}/${model.api}: ${result.errorMessage}`);
|
||||
assert.equal(result.errorMessage, sentinel);
|
||||
}
|
||||
noNetwork();
|
||||
console.log(`[smoke-provider-runtime] ${name}: public native OAuth and ${seenApis.size} transport APIs initialized offline`);
|
||||
}
|
||||
// Resolution/evaluation, not folder-exists checks. This also covers the
|
||||
// previously missing transitive edge and permits legitimate nested deps.
|
||||
for (const name of ["@google/genai", "google-auth-library", "gcp-metadata", "json-bigint", "bignumber.js"]) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -82,9 +82,9 @@ test("Providers has exactly four ordered sections; unknown variables stay masked
|
||||
assert.equal(byTest("unknown-FUTURE_VAR").textContent, "FUTURE_VAR…abcd"); assert(!byTest("unknown-FUTURE_VAR").querySelector("input, button"));
|
||||
});
|
||||
|
||||
test("disconnected row is only secondary Continue; initial default is ON; cancel discards draft without OAuth/global mutation", async () => {
|
||||
test("disconnected row uses shared Connect action; initial default is ON; cancel discards draft without OAuth/global mutation", async () => {
|
||||
const f = fixture(); await mount(<ChatGPTConnection setup />);
|
||||
assert.equal(byTest("chatgpt-connection").textContent, "Continue with ChatGPT");
|
||||
assert.equal(byTest("chatgpt-connection").textContent, "ChatGPTConnect");
|
||||
assert(!document.body.textContent?.includes(CHATGPT_NOTICE)); const trigger = byTest("chatgpt-login");
|
||||
assert.match(trigger.className, /cursor-pointer/); assert.match(trigger.className, /hover:bg-accent/); assert.match(trigger.className, /focus-visible:ring/);
|
||||
await click(trigger); assert.equal(f.calls.login.length, 0); assert(document.body.textContent?.includes(CHATGPT_NOTICE));
|
||||
@@ -261,7 +261,7 @@ test("confirmed disconnect preserves identity/pending content until outcome, pre
|
||||
});
|
||||
|
||||
test("signed-out legacy accounts remain manageable/forgettable without pretending selection is a usable connection", async () => {
|
||||
const f = fixture(); await mount(); assert.equal(byTest("chatgpt-connection").textContent, "Continue with ChatGPT");
|
||||
const f = fixture(); await mount(); assert.equal(byTest("chatgpt-connection").textContent, "ChatGPTConnect");
|
||||
await click(byTest("chatgpt-login")); await selectAccount("Saved ChatGPT account", "signed-out-record"); await click(button("Manage…"));
|
||||
assert(byTest("chatgpt-managed-identity").textContent?.includes("signed-out@example.invalid"));
|
||||
assert.deepEqual(f.calls.select, []); assert.equal(f.calls.login.length, 0); await confirmDisconnect();
|
||||
|
||||
@@ -57,7 +57,7 @@ test("plan plus unknown graph rows keep the existing unknown suffix without fabr
|
||||
await act(async () => { document.querySelector("button")!.click(); });
|
||||
const recent = [...document.querySelectorAll(".border-t .shrink-0.tabular-nums")];
|
||||
assert.equal(recent.length, 2);
|
||||
for (const row of recent) assert.equal(row.textContent, "ChatGPT plan +1 unpriced", "both recent two-stage runs disclose their unknown stage");
|
||||
for (const row of recent) assert.equal(row.textContent, "ChatGPT +1 unpriced", "both recent two-stage runs disclose their unknown stage");
|
||||
assert.equal((document.body.textContent?.match(/\+1 unpriced/g) ?? []).length, 4, "both mixed stage rows also retain their unknown suffix");
|
||||
assert(!document.body.textContent?.includes("$0.00"));
|
||||
});
|
||||
|
||||
@@ -13,12 +13,12 @@ test("subscription usage is neither API dollars nor an unpriced-model mistake, i
|
||||
assert.equal(callCost(step, plan.model), null);
|
||||
assert.equal(turnCost(turn(plan.model, [])), null);
|
||||
assert.equal(jobCost({ usage: { model: plan.model, ...step, steps: 1 } }), null);
|
||||
assert.deepEqual(sessionCost({ turns: [plan, turn(plan.model, [])] }), { cost: 0, unpriced: 0, subscription: 2, turnCosts: { "scratch-turn": null } });
|
||||
assert.deepEqual(sessionCost({ turns: [plan, turn(plan.model, [])] }), { cost: 0, unpriced: 0, subscription: 2, subscriptionProviders: ["ChatGPT"], turnCosts: { "scratch-turn": null } });
|
||||
const api = turn("openai:gpt-5");
|
||||
const mixed = sessionCost({ turns: [plan, api, turn("unknown:missing")] });
|
||||
assert.equal(mixed.cost, turnCost(api)); assert.equal(mixed.unpriced, 1); assert.equal(mixed.subscription, 1);
|
||||
assert.equal(planCostLabel(null, true), "ChatGPT plan");
|
||||
assert.match(planCostLabel(mixed.cost, true), /API \+ ChatGPT plan$/);
|
||||
assert.equal(planCostLabel(null, true), "ChatGPT");
|
||||
assert.match(planCostLabel(mixed.cost, true), /\$[\d.]+ \+ ChatGPT$/);
|
||||
});
|
||||
|
||||
test("graph extraction and audit preserve subscription identity separately from priced and unknown stages", () => {
|
||||
|
||||
@@ -78,8 +78,9 @@ describe("desktop window chrome", () => {
|
||||
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), context);
|
||||
assert.equal(bridge.platform, "win32");
|
||||
assert.deepEqual(Object.keys(bridge).sort(), [
|
||||
"chatgpt", "expandWindow", "getZoom", "installUpdate", "onUpdateStatus", "onZoom", "platform",
|
||||
"chatgpt", "expandWindow", "getZoom", "installUpdate", "onUpdateStatus", "onZoom", "platform", "subscriptions",
|
||||
]);
|
||||
assert.deepEqual(Object.keys(bridge.subscriptions as object).sort(), ["cancel", "consent", "disconnect", "login", "onChange", "open", "respond", "retry", "status"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -34,8 +34,8 @@ test("known zero, mixed priced/unknown, and subscription headline semantics stay
|
||||
assert.ok(mixed.totalDollars > 0);
|
||||
assert.match(headline([priced, historical]), new RegExp(`>\\$${mixed.totalDollars.toFixed(2).replace(".", "\\.")}<\\/button>`));
|
||||
const plan = run([usage()], "chatgpt:fixture");
|
||||
assert.match(headline([plan]), />ChatGPT plan<\/button>/);
|
||||
assert.match(headline([plan, zero]), />\$0\.00 API \+ ChatGPT plan<\/button>/);
|
||||
assert.match(headline([plan]), />ChatGPT<\/button>/);
|
||||
assert.match(headline([plan, zero]), />\$0\.00 \+ ChatGPT<\/button>/);
|
||||
});
|
||||
|
||||
test("graph stage and recent-run aggregates preserve known one-hour cache writes", () => {
|
||||
|
||||
@@ -47,6 +47,8 @@ describe("standalone JavaScript tracing exclusions and native workaround policy"
|
||||
"node_modules/@napi-rs/canvas-*/**/*",
|
||||
"node_modules/@resvg/resvg-js-*/**/*",
|
||||
"node_modules/@earendil-works/pi-ai/dist/providers/data/**/*",
|
||||
"node_modules/@earendil-works/pi-ai/dist/auth/oauth/{load,github-copilot,xai,device-code}.js",
|
||||
"node_modules/@earendil-works/pi-ai/dist/utils/sleep.js",
|
||||
"node_modules/@earendil-works/pi-agent-core/dist/**/*.json",
|
||||
"node_modules/gcp-metadata/**/*",
|
||||
"node_modules/json-bigint/**/*",
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { createModels, type OAuthCredential, type ProviderAuthInteraction } from "@earendil-works/pi-ai";
|
||||
import { githubCopilotProvider } from "@earendil-works/pi-ai/providers/github-copilot";
|
||||
import { xaiProvider } from "@earendil-works/pi-ai/providers/xai";
|
||||
import type { NativeSubscriptionScope } from "../src/core/contracts/subscriptions.ts";
|
||||
const require = createRequire(import.meta.url);
|
||||
const { createSubscriptions, loadSubscriptionSDK, validEndpoint, publicError } = require("../electron/subscriptions.cjs");
|
||||
const { fileStore, validateState } = require("../electron/subscription-store.cjs");
|
||||
const { subscriptionControl } = require("../electron/subscription-ipc.cjs");
|
||||
const ids = ["github-copilot", "xai"] as const;
|
||||
const credential = (id: string, expires = Date.now() + 3600000): OAuthCredential => ({ type: "oauth", access: id === "xai" ? "fixture-xai-access" : "tid=fixture;proxy-ep=proxy.individual.githubcopilot.com;", refresh: "fixture-refresh", expires, ...(id === "github-copilot" ? { availableModelIds: [githubCopilotProvider().getModels()[0]!.id] } : {}) });
|
||||
function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { resolve, promise }; }
|
||||
async function fixture(options: { login?: (id: string, interaction: ProviderAuthInteraction) => Promise<OAuthCredential>; refresh?: (id: string, value: OAuthCredential, signal: AbortSignal) => Promise<OAuthCredential>; stores?: Record<string, unknown> } = {}) {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "lh-subscription-auth-"));
|
||||
let refreshes = 0;
|
||||
const factory = (id: string) => {
|
||||
const native = id === "xai" ? xaiProvider() : githubCopilotProvider();
|
||||
return { ...native, auth: { ...native.auth, oauth: { ...native.auth.oauth!, login: (interaction: ProviderAuthInteraction) => options.login?.(id, interaction) ?? Promise.resolve(credential(id)), refresh: async (value: OAuthCredential, signal: AbortSignal) => { refreshes++; return options.refresh?.(id, value, signal) ?? { ...credential(id), refresh: "fixture-rotated" }; } } } };
|
||||
};
|
||||
const sdk = { createModels, githubCopilotProvider: () => factory("github-copilot"), xaiProvider: () => factory("xai") };
|
||||
const updates: any[] = [], opened: string[] = [];
|
||||
const controller = createSubscriptions({ directory, sdk, stores: options.stores, openExternal: async (url: string) => { opened.push(url); }, onChange: (snapshot: unknown) => updates.push(snapshot) });
|
||||
await controller.ready;
|
||||
const scope = async (id: string): Promise<NativeSubscriptionScope> => {
|
||||
const snapshot = await controller.snapshot(), status = snapshot.statuses.find((s: any) => s.providerId === id), model = snapshot.models[id][0];
|
||||
return { providerId: id as NativeSubscriptionScope["providerId"], connectionId: status.connectionId, epoch: status.epoch, authorityRevision: status.authorityRevision, selectedModelId: model.id };
|
||||
};
|
||||
return { controller, directory, sdk, opened, updates, scope, get refreshes() { return refreshes; }, async cleanup() { controller.close(); await fs.rm(directory, { recursive: true, force: true }); } };
|
||||
}
|
||||
test("main loader resolves Pi's import-only PUBLIC package exports and native OAuth without a CLI or network", async () => {
|
||||
const sdk = await loadSubscriptionSDK(path.resolve(import.meta.dirname, ".."));
|
||||
assert.equal(typeof sdk.createModels, "function");
|
||||
for (const [id, factory] of [["github-copilot", sdk.githubCopilotProvider], ["xai", sdk.xaiProvider]] as const) {
|
||||
const provider = factory(), store = new sdk.InMemoryCredentialStore(); await store.modify(id, () => credential(id));
|
||||
const models = sdk.createModels({ credentials: store }); models.setProvider({ ...provider, auth: { oauth: provider.auth.oauth } });
|
||||
const resolved = await models.getAuth(provider.getModels()[0]); assert.equal(resolved.auth.apiKey, credential(id).access);
|
||||
}
|
||||
});
|
||||
for (const id of ids) test(`${id}: independent default-ON file, restart, explicit OAuth lease and local disconnect`, async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
assert((await f.controller.status()).every((s: any) => s.available && !s.connected && s.allowBackgroundUse));
|
||||
await f.controller.login(id, false);
|
||||
const saved = JSON.parse(await fs.readFile(path.join(f.directory, `${id}-credentials.json`), "utf8"));
|
||||
assert.equal(saved.version, 1); assert.equal(saved.credential.type, "oauth"); assert.equal(saved.allowBackgroundUse, false);
|
||||
assert.equal((await fs.stat(path.join(f.directory, `${id}-credentials.json`))).mode & 0o777, 0o600);
|
||||
const publicState = JSON.stringify(await f.controller.snapshot());
|
||||
assert(!publicState.includes(saved.credential.access)); assert(!publicState.includes(saved.credential.refresh));
|
||||
const scope = await f.scope(id), result = await f.controller.lease(scope, false);
|
||||
assert.equal(result.auth.apiKey, saved.credential.access);
|
||||
if (id === "github-copilot") assert.equal(result.auth.baseUrl, "https://api.individual.githubcopilot.com");
|
||||
await assert.rejects(f.controller.lease(scope, true), (error: any) => error.code === "background");
|
||||
const reloaded = createSubscriptions({ directory: f.directory, sdk: f.sdk, openExternal: async () => {} }); await reloaded.ready;
|
||||
assert.equal((await reloaded.status()).find((s: any) => s.providerId === id).connectionId, saved.connectionId); reloaded.close();
|
||||
await f.controller.disconnect(id); await assert.rejects(f.controller.lease(scope, false));
|
||||
const removed = JSON.parse(await fs.readFile(path.join(f.directory, `${id}-credentials.json`), "utf8"));
|
||||
assert.deepEqual(removed, { version: 1, allowBackgroundUse: false });
|
||||
assert.equal((await f.controller.status()).find((s: any) => s.providerId !== id).connected, false);
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
test("parallel expired-token requests refresh once, preserve native extension metadata and durable rotation", async () => {
|
||||
const release = deferred<OAuthCredential>();
|
||||
const f = await fixture({ login: async (id) => credential(id, 0), refresh: () => release.promise });
|
||||
try {
|
||||
await f.controller.login("github-copilot", true); const scope = await f.scope("github-copilot");
|
||||
const first = f.controller.lease(scope, false), second = f.controller.lease(scope, false);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const next = { ...credential("github-copilot"), refresh: "rotated-single-writer", extraNativeField: "retained" }; release.resolve(next);
|
||||
const results = await Promise.all([first, second]); assert.equal(f.refreshes, 1); assert.equal(results[0].auth.apiKey, next.access);
|
||||
const saved = JSON.parse(await fs.readFile(path.join(f.directory, "github-copilot-credentials.json"), "utf8"));
|
||||
assert.equal(saved.credential.refresh, "rotated-single-writer"); assert.equal(saved.credential.extraNativeField, "retained");
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
test("background changes retain foreground scope; replacement and disconnect invalidate old leases", async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.controller.login("xai", true); const scope = await f.scope("xai");
|
||||
await f.controller.consent("xai", false); await f.controller.lease(scope, false); await assert.rejects(f.controller.lease(scope, true));
|
||||
await f.controller.login("xai", false); assert.notEqual((await f.scope("xai")).connectionId, scope.connectionId); await assert.rejects(f.controller.lease(scope, false));
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
test("device interaction exposes only public code and opaque browser action; cancellation cannot commit late success", async () => {
|
||||
const done = deferred<OAuthCredential>();
|
||||
const f = await fixture({ login: async (_id, interaction) => { interaction.notify({ type: "device_code", userCode: "PUBLIC-CODE", verificationUri: "https://auth.x.ai/device?user_code=PUBLIC-CODE" }); return done.promise; } });
|
||||
try {
|
||||
const pending = f.controller.login("xai", true); await new Promise((resolve) => setImmediate(resolve));
|
||||
const status = (await f.controller.status()).find((s: any) => s.providerId === "xai"), interaction = status.interaction;
|
||||
assert.equal(interaction.userCode, "PUBLIC-CODE"); assert(!JSON.stringify(status).includes("device_code"));
|
||||
await f.controller.open("xai", interaction.attemptId, interaction.browser.id); assert.equal(f.opened.length, 1);
|
||||
await assert.rejects(f.controller.open("xai", "old", interaction.browser.id));
|
||||
f.controller.cancel("xai"); done.resolve(credential("xai")); await pending;
|
||||
assert.equal((await f.controller.status()).find((s: any) => s.providerId === "xai").connected, false);
|
||||
await assert.rejects(fs.readFile(path.join(f.directory, "xai-credentials.json")), (error: any) => error.code === "ENOENT");
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
test("cancelled replacement retains old credentials; late old completion cannot overwrite a newer login", async () => {
|
||||
let count = 0; const old = deferred<OAuthCredential>();
|
||||
const f = await fixture({ login: async (id) => ++count === 2 ? old.promise : credential(id) });
|
||||
try {
|
||||
await f.controller.login("xai", true); const first = await f.scope("xai");
|
||||
const pending = f.controller.login("xai", true); await new Promise((resolve) => setImmediate(resolve)); f.controller.cancel("xai");
|
||||
assert.equal((await f.scope("xai")).connectionId, first.connectionId);
|
||||
await f.controller.login("xai", true); const replacement = await f.scope("xai");
|
||||
old.resolve(credential("xai")); await pending;
|
||||
assert.equal((await f.scope("xai")).connectionId, replacement.connectionId);
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
for (const id of ids) test(`${id}: cancelled sign-in discards draft consent, and failed replacement retains the working connection`, async () => {
|
||||
let count = 0; const late = deferred<OAuthCredential>();
|
||||
const f = await fixture({ login: async (provider) => { count++; if (count === 1) return late.promise; if (count === 3) throw new Error("private-provider-login-error"); return credential(provider); } });
|
||||
try {
|
||||
const pending = f.controller.login(id, false); await new Promise((resolve) => setImmediate(resolve)); f.controller.cancel(id); late.resolve(credential(id)); await pending;
|
||||
assert.equal((await f.controller.status()).find((s: any) => s.providerId === id).allowBackgroundUse, true);
|
||||
await assert.rejects(fs.readFile(path.join(f.directory, `${id}-credentials.json`)), (error: any) => error.code === "ENOENT");
|
||||
await f.controller.login(id); const scope = await f.scope(id); const original = await fs.readFile(path.join(f.directory, `${id}-credentials.json`), "utf8");
|
||||
await assert.rejects(f.controller.login(id, false));
|
||||
assert.equal(await fs.readFile(path.join(f.directory, `${id}-credentials.json`), "utf8"), original);
|
||||
const status = (await f.controller.status()).find((s: any) => s.providerId === id); assert(status.connected); assert(status.allowBackgroundUse); assert(!JSON.stringify(status).includes("private-provider-login-error"));
|
||||
await f.controller.lease(scope, false);
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
test("Copilot native enterprise prompt is answered only through the current opaque prompt action", async () => {
|
||||
const f = await fixture({ login: async (id, interaction) => { const domain = await interaction.prompt({ type: "text", message: "GitHub domain", placeholder: "github.com" }); return { ...credential(id), enterpriseUrl: domain }; } });
|
||||
try {
|
||||
const pending = f.controller.login("github-copilot", false); await new Promise((resolve) => setImmediate(resolve));
|
||||
const status = (await f.controller.status()).find((s: any) => s.providerId === "github-copilot"), { attemptId, prompt } = status.interaction;
|
||||
assert.equal(prompt.type, "text"); assert.equal(prompt.message, "GitHub domain");
|
||||
await assert.rejects(f.controller.respond("github-copilot", "stale", prompt.id, "company.ghe.com"));
|
||||
await f.controller.respond("github-copilot", attemptId, prompt.id, "company.ghe.com"); await pending;
|
||||
const saved = JSON.parse(await fs.readFile(path.join(f.directory, "github-copilot-credentials.json"), "utf8")); assert.equal(saved.credential.enterpriseUrl, "company.ghe.com"); assert.equal(saved.allowBackgroundUse, false);
|
||||
assert(!(await f.controller.status()).find((s: any) => s.providerId === "github-copilot").interaction);
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
test("account endpoint rotation invalidates older scopes without losing the durable refreshed credential", async () => {
|
||||
const next = { ...credential("github-copilot"), access: "tid=fixture;proxy-ep=proxy.business.githubcopilot.com;" };
|
||||
const f = await fixture({ login: async (id) => credential(id, 0), refresh: async () => next });
|
||||
try {
|
||||
await f.controller.login("github-copilot"); const old = await f.scope("github-copilot"); await assert.rejects(f.controller.lease(old, false));
|
||||
const scope = await f.scope("github-copilot"); assert(scope.authorityRevision > old.authorityRevision); assert.equal(scope.connectionId, old.connectionId);
|
||||
assert.equal((await f.controller.lease(scope, false)).auth.baseUrl, "https://api.business.githubcopilot.com"); assert.equal(f.refreshes, 1);
|
||||
assert.equal(JSON.parse(await fs.readFile(path.join(f.directory, "github-copilot-credentials.json"), "utf8")).credential.access, next.access);
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
test("a malformed returned rotation fails closed and cannot retry with the pre-rotation credential", async () => {
|
||||
const f = await fixture({ login: async (id) => credential(id, 0), refresh: async (id) => ({ ...credential(id), refresh: "rotated-but-malformed", expires: NaN }) });
|
||||
try {
|
||||
await f.controller.login("xai"); const scope = await f.scope("xai"); await assert.rejects(f.controller.lease(scope, false));
|
||||
await assert.rejects(f.controller.lease(scope, false)); await assert.rejects(f.controller.retry("xai")); assert.equal(f.refreshes, 1);
|
||||
assert(!(await f.controller.status()).find((s: any) => s.providerId === "xai").available);
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
test("renderer controls cannot obtain a request lease or internal snapshot", async () => {
|
||||
const f = await fixture();
|
||||
try { for (const op of ["lease", "snapshot", "read", "getAuth"]) await assert.rejects(subscriptionControl({ op }, f.controller)); }
|
||||
finally { await f.cleanup(); }
|
||||
});
|
||||
test("corrupt files isolate providers and are preserved; malformed Copilot model metadata never grants all models", async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const file = path.join(f.directory, "github-copilot-credentials.json"); await fs.writeFile(file, "{broken", { mode: 0o600 });
|
||||
const reloaded = createSubscriptions({ directory: f.directory, sdk: f.sdk, openExternal: async () => {} }); await reloaded.ready;
|
||||
const statuses = await reloaded.status(); assert(!statuses.find((s: any) => s.providerId === "github-copilot").available); assert(statuses.find((s: any) => s.providerId === "xai").available);
|
||||
await assert.rejects(reloaded.login("github-copilot", true)); assert.equal(await fs.readFile(file, "utf8"), "{broken"); reloaded.close();
|
||||
assert.throws(() => validateState("github-copilot", { version: 1, allowBackgroundUse: true, connectionId: "b5ac22b7-dfe3-4e52-ae0a-6da2990d4aef", credential: { ...credential("github-copilot"), availableModelIds: [123] } }));
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
test("failed rotation persistence blocks subsequent requests instead of using old tokens", async () => {
|
||||
let state: any = { version: 1, allowBackgroundUse: true }, fail = false;
|
||||
const stores = { xai: { read: async () => state, write: async (next: any) => { if (fail) throw new Error("private-disk-detail"); state = structuredClone(next); } } };
|
||||
const f = await fixture({ stores, login: async (id) => credential(id, 0) });
|
||||
try {
|
||||
await f.controller.login("xai", true); const scope = await f.scope("xai"); fail = true;
|
||||
await assert.rejects(f.controller.lease(scope, false)); assert.equal(f.refreshes, 1);
|
||||
await assert.rejects(f.controller.lease(scope, false)); assert.equal(f.refreshes, 1);
|
||||
const status = (await f.controller.status()).find((s: any) => s.providerId === "xai"); assert(!status.available); assert(!JSON.stringify(status).includes("private-disk-detail"));
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
test("disconnect during refresh cannot resurrect credentials or replace disconnect status with a stale error", async () => {
|
||||
const rotation = deferred<OAuthCredential>(); const f = await fixture({ login: async (id) => credential(id, 0), refresh: () => rotation.promise });
|
||||
try {
|
||||
await f.controller.login("xai", true); const scope = await f.scope("xai");
|
||||
const lease = f.controller.lease(scope, false).then(() => false, () => true);
|
||||
await new Promise((resolve) => setImmediate(resolve)); const disconnect = f.controller.disconnect("xai"); rotation.resolve({ ...credential("xai"), refresh: "do-not-resurrect" });
|
||||
await disconnect; assert(await lease);
|
||||
const status = (await f.controller.status()).find((s: any) => s.providerId === "xai"); assert.equal(status.connectionId, null); assert.equal(status.error, undefined);
|
||||
assert.deepEqual(JSON.parse(await fs.readFile(path.join(f.directory, "xai-credentials.json"), "utf8")), { version: 1, allowBackgroundUse: true });
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
test("pause during refresh saves returned rotation but refuses the request; resume uses durable refreshed state", async () => {
|
||||
const rotation = deferred<OAuthCredential>(); const f = await fixture({ login: async (id) => credential(id, 0), refresh: () => rotation.promise });
|
||||
try {
|
||||
await f.controller.login("xai", true); const scope = await f.scope("xai"); const lease = f.controller.lease(scope, false).then(() => false, () => true);
|
||||
await new Promise((resolve) => setImmediate(resolve)); f.controller.pause(); rotation.resolve({ ...credential("xai"), refresh: "rotation-survives-pause" }); assert(await lease);
|
||||
const saved = JSON.parse(await fs.readFile(path.join(f.directory, "xai-credentials.json"), "utf8")); assert.equal(saved.credential.refresh, "rotation-survives-pause");
|
||||
f.controller.resume(); await f.controller.lease(await f.scope("xai"), false); assert.equal(f.refreshes, 1);
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
test("enterprise domains are preserved; derived endpoints and control errors are guarded", async () => {
|
||||
const value = { ...credential("github-copilot"), enterpriseUrl: "company.ghe.com" };
|
||||
validateState("github-copilot", { version: 1, allowBackgroundUse: true, connectionId: "b5ac22b7-dfe3-4e52-ae0a-6da2990d4aef", credential: value });
|
||||
assert(validEndpoint("github-copilot", { apiKey: "fixture", baseUrl: "https://copilot-api.company.ghe.com" }, value));
|
||||
for (const baseUrl of ["http://api.individual.githubcopilot.com", "https://attacker.example/", "https://user:pass@api.individual.githubcopilot.com", "https://api.individual.githubcopilot.com/path"]) assert(!validEndpoint("github-copilot", { apiKey: "fixture", baseUrl }, value));
|
||||
await assert.rejects(subscriptionControl({ op: "status" }, { status: async () => { throw new Error("private-token-response"); } }), (error: Error) => !error.message.includes("private-token-response"));
|
||||
assert(!publicError(new Error("private-token-response")).includes("private-token-response"));
|
||||
});
|
||||
test("atomic store failed rename preserves original and leaves no temporary files", async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const file = path.join(f.directory, "scratch.json"), state = { version: 1, allowBackgroundUse: false };
|
||||
await fileStore(file, "xai").write(state);
|
||||
const io = { ...fs, rename: async () => { throw new Error("fixture-rename-failure"); } };
|
||||
await assert.rejects(fileStore(file, "xai", io).write({ version: 1, allowBackgroundUse: true }));
|
||||
assert.deepEqual(JSON.parse(await fs.readFile(file, "utf8")), state);
|
||||
assert.deepEqual((await fs.readdir(f.directory)).filter((name) => name.endsWith(".tmp")), []);
|
||||
} finally { await f.cleanup(); }
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Agent, type AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import { AssistantMessageEventStream } from "@earendil-works/pi-ai/utils/event-stream";
|
||||
import { piModels } from "../src/agent/models.ts";
|
||||
import { toLlmMessages } from "../src/core/native-messages.ts";
|
||||
import { buildSubagentSteer } from "../src/core/contracts.ts";
|
||||
import { NATIVE_SUBSCRIPTIONS } from "../src/core/contracts/subscriptions.ts";
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "lh-subscription-background-"));
|
||||
process.env.LH_DATA_DIR = path.join(directory, "data"); process.env.LH_AUTH_FILE = path.join(directory, "auth.json"); fs.writeFileSync(process.env.LH_AUTH_FILE, "{}");
|
||||
const { stores } = await import("../src/server/stores.ts");
|
||||
const { beginTurn, endTurn } = await import("../src/server/turn-liveness.ts");
|
||||
const { deliverJobResult, registerLiveAgent, unregisterLiveAgent, setTurnRunner, settleSteers, steerLiveChat, automaticFollowUpAllowed } = await import("../src/server/jobs.ts");
|
||||
const { persistTurn } = await import("../src/server/turn-service.ts");
|
||||
const originalSend = Object.getOwnPropertyDescriptor(process, "send"), originalConnected = Object.getOwnPropertyDescriptor(process, "connected");
|
||||
const marker = process.env.LH_DESKTOP_SERVER, secret = process.env.LH_DESKTOP_API_SECRET;
|
||||
let revision = 0;
|
||||
function connection(providerId: string, background: boolean) {
|
||||
revision++;
|
||||
(process.emit as (event: string, value: unknown) => boolean)("message", { channel: "lh:subscriptions", op: "changed", snapshot: {
|
||||
statuses: NATIVE_SUBSCRIPTIONS.map((p) => ({ providerId: p.providerId, available: true, connected: true, pending: false, allowBackgroundUse: p.providerId === providerId ? background : true, connectionId: `fixture-${p.providerId}`, epoch: 1, authorityRevision: 1, revision })),
|
||||
models: Object.fromEntries(NATIVE_SUBSCRIPTIONS.map((p) => [p.providerId, piModels().getModels(p.providerId)])),
|
||||
} });
|
||||
}
|
||||
before(() => {
|
||||
process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64);
|
||||
Object.defineProperty(process, "connected", { configurable: true, value: true });
|
||||
Object.defineProperty(process, "send", { configurable: true, value: (_message: unknown, callback?: (error: Error) => void) => { callback?.(new Error("unexpected fixture request")); return false; } });
|
||||
});
|
||||
after(() => {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
if (originalSend) Object.defineProperty(process, "send", originalSend); else delete process.send;
|
||||
if (originalConnected) Object.defineProperty(process, "connected", originalConnected); else delete (process as { connected?: boolean }).connected;
|
||||
if (marker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = marker;
|
||||
if (secret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = secret;
|
||||
});
|
||||
for (const p of NATIVE_SUBSCRIPTIONS) for (const revokeAfterQueue of [false, true]) test(`${p.providerId}: foreground survives and automatic queue retains ownership with background ${revokeAfterQueue ? "revoked" : "off"}`, { timeout: 5000 }, async () => {
|
||||
connection(p.providerId, revokeAfterQueue);
|
||||
const chat = stores.chatStore.createChat("Fixture mixed queue"); const model = { ...piModels().getModels(p.providerId)[0]!, provider: p.logicalId };
|
||||
stores.chatStore.saveSettings(chat.id, { model: { provider: p.logicalId, id: model.id } });
|
||||
const job = stores.jobStore.createJob({ kind: "research", chatId: chat.id, toolCallId: "fixture-job-call" }); const result = { brief: "Fixture helper result", sources: [] };
|
||||
stores.jobStore.updateJob(job.id, { status: "running", result }); const steer = buildSubagentSteer(job, result); const contexts: string[] = [];
|
||||
const agent = new Agent({ initialState: { model, messages: [{ id: "fixture-initial", role: "user", content: "Initial learner input", timestamp: 1 } as AgentMessage] }, convertToLlm: toLlmMessages, streamFn: () => {
|
||||
contexts.push(JSON.stringify(agent.state.messages)); const stream = new AssistantMessageEventStream();
|
||||
const output = { role: "assistant" as const, provider: p.logicalId, api: model.api, model: model.id, content: [{ type: "text" as const, text: "Foreground response" }], stopReason: "stop" as const, timestamp: 2, usage: { input: 2, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 3, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
|
||||
stream.push({ type: "start", partial: output }); stream.push({ type: "done", reason: "stop", message: output }); stream.end(); return stream;
|
||||
} });
|
||||
let wakes = 0;
|
||||
setTurnRunner(async (chatId, input) => { wakes++; if (input.type === "steers") stores.chatStore.saveMessages(chatId, [...stores.chatStore.getChat(chatId)!.messages, ...input.steers]); return 200; });
|
||||
assert(beginTurn(chat.id)); registerLiveAgent(chat.id, agent); const delivered = deliverJobResult(steer);
|
||||
assert(steerLiveChat(chat.id, { id: "fixture-live-learner", role: "user", content: "Continue my learning", timestamp: 3 } as never));
|
||||
if (revokeAfterQueue) connection(p.providerId, false);
|
||||
assert(!automaticFollowUpAllowed(chat.id, agent)); await agent.continue();
|
||||
assert.equal(contexts.length, 1); assert(contexts[0]!.includes("Continue my learning")); assert(!contexts[0]!.includes(steer.id));
|
||||
const persisted = persistTurn(stores.chatStore, chat.id, 0, agent.state.messages, false);
|
||||
unregisterLiveAgent(chat.id); endTurn(chat.id); settleSteers(chat.id, persisted); await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(wakes, 0); assert.equal(stores.jobStore.getJob(job.id)!.status, "running");
|
||||
connection(p.providerId, true); await delivered; assert.equal(wakes, 1); assert.equal(stores.jobStore.getJob(job.id)!.status, "done");
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { act } from "react";
|
||||
(globalThis as Record<string, unknown>).React = React;
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, test } from "node:test";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { NativeSubscriptionConnection, NativeSubscriptionUIProvider, SubscriptionConnections, COPILOT_POLICY_NOTICE, emptySubscriptionStatus } from "../components/settings/subscription-connections.tsx";
|
||||
import { ChatGPTUIProvider } from "../components/settings/chatgpt-connection.tsx";
|
||||
import type { NativeSubscriptionControlBridge, NativeSubscriptionProviderId, NativeSubscriptionStatus } from "../src/core/contracts/subscriptions.ts";
|
||||
const dom = parseHTML("<html><body></body></html>");
|
||||
Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true });
|
||||
let root: Root | undefined, host: HTMLDivElement | undefined;
|
||||
function deferred() { let resolve!: () => void; const promise = new Promise<void>((done) => { resolve = done; }); return { promise, resolve }; }
|
||||
function fixture(connected = false) {
|
||||
let statuses: NativeSubscriptionStatus[] = ["github-copilot", "xai"].map((id) => ({ ...emptySubscriptionStatus(id as NativeSubscriptionProviderId), available: true, connected, connectionId: connected ? `local-${id}` : null }));
|
||||
const listeners = new Set<() => void>(), login = deferred();
|
||||
const calls = { login: [] as unknown[][], cancel: 0, open: [] as unknown[][], consent: [] as unknown[][], disconnect: [] as string[] };
|
||||
function push(id: NativeSubscriptionProviderId, next: Partial<NativeSubscriptionStatus>) {
|
||||
statuses = statuses.map((s) => s.providerId === id ? { ...s, revision: s.revision + 1, ...next } : s); for (const listener of listeners) listener();
|
||||
}
|
||||
const bridge: NativeSubscriptionControlBridge = {
|
||||
status: async () => structuredClone(statuses),
|
||||
login: async (id, enabled) => { calls.login.push([id, enabled]); push(id, { pending: true, interaction: { attemptId: "attempt", userCode: "PUBLIC-CODE", browser: { id: "opaque-action", url: "https://auth.example/device" }, progress: "Complete sign-in in your browser." } }); await login.promise; },
|
||||
cancel: async (id) => { calls.cancel++; push(id, { pending: false, interaction: undefined }); },
|
||||
respond: async () => {}, open: async (...args) => { calls.open.push(args); }, retry: async () => {},
|
||||
disconnect: async (id) => { calls.disconnect.push(id); push(id, { connected: false, connectionId: null }); },
|
||||
consent: async (id, enabled) => { calls.consent.push([id, enabled]); push(id, { allowBackgroundUse: enabled }); },
|
||||
onChange: (listener) => { listeners.add(listener); return () => { listeners.delete(listener); }; },
|
||||
};
|
||||
window.lh = { expandWindow() {}, subscriptions: bridge };
|
||||
return { bridge, calls, login, push, get statuses() { return statuses; } };
|
||||
}
|
||||
async function mount() {
|
||||
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
|
||||
await act(async () => root!.render(<NativeSubscriptionUIProvider><NativeSubscriptionConnection providerId="github-copilot" /><NativeSubscriptionConnection providerId="xai" /></NativeSubscriptionUIProvider>));
|
||||
}
|
||||
function button(text: string, scope: ParentNode = document) { const found = [...scope.querySelectorAll<HTMLButtonElement>("button")].find((element) => element.textContent === text); assert(found, `missing button ${text}`); return found; }
|
||||
const row = (id: string) => document.querySelector<HTMLElement>(`[data-testid="${id}-connection"]`)!;
|
||||
const dialog = () => document.querySelector<HTMLElement>('[data-testid="subscription-connection-dialog"]')!;
|
||||
async function click(element: HTMLElement) { assert(element); await act(async () => element.click()); }
|
||||
afterEach(async () => { await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined; delete window.lh; });
|
||||
|
||||
test("three compact rows render in approved order with no browser-only sign-in or fabricated identities", () => {
|
||||
const html = renderToStaticMarkup(<ChatGPTUIProvider><NativeSubscriptionUIProvider><SubscriptionConnections /></NativeSubscriptionUIProvider></ChatGPTUIProvider>);
|
||||
assert(html.indexOf('data-testid="chatgpt-connection"') < html.indexOf('data-testid="github-copilot-connection"'));
|
||||
assert(html.indexOf('data-testid="github-copilot-connection"') < html.indexOf('data-testid="xai-connection"'));
|
||||
assert.equal((html.match(/>Connect<\/button>/g) ?? []).length, 3);
|
||||
assert(!html.includes("@")); assert(!html.includes("Add accounts"));
|
||||
});
|
||||
test("Copilot disclosure and default-ON draft appear before login; browser action passes only opaque IDs", async () => {
|
||||
const f = fixture(); await mount(); await click(button("Connect", row("github-copilot")));
|
||||
assert(dialog().textContent?.includes(COPILOT_POLICY_NOTICE)); assert.equal(f.calls.login.length, 0);
|
||||
const toggle = dialog().querySelector<HTMLElement>('[role="switch"]')!; assert.equal(toggle.getAttribute("aria-checked"), "true");
|
||||
await click(toggle); await click(button("Connect", dialog())); assert.deepEqual(f.calls.login, [["github-copilot", false]]);
|
||||
assert(dialog().textContent?.includes("PUBLIC-CODE")); await click(button("Open browser", dialog()));
|
||||
assert.deepEqual(f.calls.open, [["github-copilot", "attempt", "opaque-action"]]);
|
||||
await click(button("Cancel", dialog())); assert.equal(f.calls.cancel, 1); assert(!dialog());
|
||||
await click(button("Connect", row("xai"))); const newer = dialog(); await act(async () => f.login.resolve());
|
||||
assert(dialog() === newer, "old completion cannot close another provider dialog");
|
||||
});
|
||||
test("single native connection management has no account controls; background changes are independent and disconnect is confirmed", async () => {
|
||||
const f = fixture(true); await mount(); await click(button("Manage…", row("xai")));
|
||||
assert(!dialog().querySelector("select")); assert(!dialog().textContent?.includes("Add accounts")); assert(button("Reconnect", dialog()));
|
||||
await click(dialog().querySelector<HTMLElement>('[role="switch"]')!); assert.deepEqual(f.calls.consent, [["xai", false]]);
|
||||
assert.equal(f.statuses.find((s) => s.providerId === "github-copilot")!.allowBackgroundUse, true);
|
||||
await click(button("Disconnect", dialog())); assert.equal(f.calls.disconnect.length, 0);
|
||||
assert(dialog().textContent?.includes("does not cancel your subscription or revoke access"));
|
||||
await click(button("Cancel", dialog())); assert(button("Reconnect", dialog()));
|
||||
await click(button("Disconnect", dialog())); await click(button("Disconnect", dialog())); assert.deepEqual(f.calls.disconnect, ["xai"]); assert(!dialog());
|
||||
});
|
||||
test("required native prompts render and commit cannot be cancelled through button, Escape or backdrop", async () => {
|
||||
const f = fixture(); await mount(); await click(button("Connect", row("github-copilot"))); await click(button("Connect", dialog()));
|
||||
await act(async () => f.push("github-copilot", { interaction: { attemptId: "attempt", prompt: { id: "enterprise", type: "text", message: "Enterprise domain (leave blank for github.com)", placeholder: "company.ghe.com" } } }));
|
||||
assert.equal(dialog().querySelector("input")?.getAttribute("placeholder"), "company.ghe.com"); assert(button("Continue", dialog()));
|
||||
await act(async () => f.push("github-copilot", { committing: true, interaction: undefined }));
|
||||
assert(button("Cancel", dialog()).disabled); await click(button("Cancel", dialog())); assert.equal(f.calls.cancel, 0); assert(dialog());
|
||||
await act(async () => { const event = new window.Event("keydown", { bubbles: true }); Object.defineProperty(event, "key", { value: "Escape" }); document.dispatchEvent(event); });
|
||||
assert(dialog()); assert.equal(f.calls.cancel, 0);
|
||||
await act(async () => f.login.resolve());
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
import { normalizeContext, type Api, type AssistantMessage, type Model } from "@earendil-works/pi-ai";
|
||||
import { piModels, piEngineForSpec } from "../src/agent/models.ts";
|
||||
import { NATIVE_SUBSCRIPTIONS, type NativeSubscriptionSnapshot } from "../src/core/contracts/subscriptions.ts";
|
||||
import { refreshSubscriptionModels, nativeSubscriptionModels, subscriptionAbortController, subscriptionRequest } from "../src/agent/subscription-bridge.ts";
|
||||
import { subscriptionHistory } from "../src/agent/subscription-stream.ts";
|
||||
import { runSubagent } from "../src/agent/subagents/pi-run.ts";
|
||||
|
||||
const originalSend = Object.getOwnPropertyDescriptor(process, "send"), originalConnected = Object.getOwnPropertyDescriptor(process, "connected");
|
||||
const marker = process.env.LH_DESKTOP_SERVER, secret = process.env.LH_DESKTOP_API_SECRET;
|
||||
const xaiKey = process.env.XAI_API_KEY, copilotKey = process.env.COPILOT_GITHUB_TOKEN;
|
||||
let revision = 1, denied = false, sends = 0;
|
||||
const context = normalizeContext({ messages: [{ role: "user", content: "Offline fixture", timestamp: 0 }] });
|
||||
function snapshot(background = true): NativeSubscriptionSnapshot {
|
||||
return {
|
||||
statuses: NATIVE_SUBSCRIPTIONS.map((p) => ({ providerId: p.providerId, available: true, connected: true, pending: false, allowBackgroundUse: background, connectionId: `fixture-${p.providerId}`, epoch: 1, authorityRevision: 1, revision })),
|
||||
models: Object.fromEntries(NATIVE_SUBSCRIPTIONS.map((p) => [p.providerId, piModels().getModels(p.providerId)])),
|
||||
};
|
||||
}
|
||||
const emit = (value: unknown) => (process.emit as (event: string, value: unknown) => boolean)("message", value);
|
||||
before(async () => {
|
||||
process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64);
|
||||
process.env.XAI_API_KEY = "fixture-must-never-be-used"; process.env.COPILOT_GITHUB_TOKEN = "fixture-api-route-only";
|
||||
Object.defineProperty(process, "connected", { configurable: true, value: true });
|
||||
Object.defineProperty(process, "send", { configurable: true, value: (message: any, callback?: () => void) => {
|
||||
sends++; queueMicrotask(() => emit({ channel: "lh:subscriptions", id: message.id, op: message.op, ...(denied && message.op === "lease" ? { ok: false, error: "fixture expired refresh" } : { ok: true, value: message.op === "snapshot" ? snapshot() : { scope: message.scope, auth: { apiKey: "fixture-oauth-only", ...(message.scope.providerId === "github-copilot" ? { baseUrl: "https://api.business.githubcopilot.com", headers: { "x-fixture-auth": "native-retained" } } : {}) } } }) }));
|
||||
callback?.(); return true;
|
||||
} });
|
||||
await refreshSubscriptionModels();
|
||||
});
|
||||
after(() => {
|
||||
if (originalSend) Object.defineProperty(process, "send", originalSend); else delete process.send;
|
||||
if (originalConnected) Object.defineProperty(process, "connected", originalConnected); else delete (process as { connected?: boolean }).connected;
|
||||
for (const [key, value] of [["LH_DESKTOP_SERVER", marker], ["LH_DESKTOP_API_SECRET", secret], ["XAI_API_KEY", xaiKey], ["COPILOT_GITHUB_TOKEN", copilotKey]]) { if (value === undefined) delete process.env[key!]; else process.env[key!] = value; }
|
||||
});
|
||||
test("logical subscription routes are separate catalog entries without rewriting native API models", () => {
|
||||
for (const p of NATIVE_SUBSCRIPTIONS) {
|
||||
assert(nativeSubscriptionModels().some((m) => m.provider === p.logicalId));
|
||||
assert(piModels().getModels(p.providerId).every((m) => m.provider === p.providerId));
|
||||
}
|
||||
});
|
||||
for (const p of NATIVE_SUBSCRIPTIONS) test(`${p.providerId}: every SDK transport keeps native identity, account auth and selected model before dispatch`, async () => {
|
||||
const apis = new Set<string>();
|
||||
for (const native of piModels().getModels(p.providerId)) {
|
||||
if (apis.has(native.api)) continue; apis.add(native.api);
|
||||
const engine = piEngineForSpec({ provider: p.logicalId, id: native.id }, false);
|
||||
let requests = 0, payloadObserved = false;
|
||||
const result = await engine.streamFn(engine.model, context, {
|
||||
onPayload: (raw, selected) => { payloadObserved = true; assert.equal(selected.provider, p.providerId); return { ...raw as object, model: "caller-cannot-replace-selected" }; },
|
||||
fetch: async (input, init) => {
|
||||
requests++; const request = new Request(input, init), body = await request.json();
|
||||
assert.equal(request.headers.get("authorization"), "Bearer fixture-oauth-only");
|
||||
assert.equal(body.model, native.id);
|
||||
if (p.providerId === "github-copilot") { assert.equal(new URL(request.url).origin, "https://api.business.githubcopilot.com"); assert.equal(request.headers.get("x-fixture-auth"), "native-retained"); }
|
||||
else assert.equal(new URL(request.url).origin, "https://api.x.ai");
|
||||
return new Response('{"error":{"message":"private-fixture-provider-detail"}}', { status: 401, headers: { "content-type": "application/json" } });
|
||||
},
|
||||
}).result();
|
||||
assert(payloadObserved); assert.equal(requests, 1); assert.equal(result.provider, p.logicalId); assert.equal(result.stopReason, "error");
|
||||
assert(!result.errorMessage?.includes("private-fixture-provider-detail")); assert(result.errorMessage?.includes("No API-key fallback"));
|
||||
}
|
||||
});
|
||||
test("failed OAuth lease never dispatches despite valid-looking ambient keys; forbidden auth overrides do not dispatch", async () => {
|
||||
let requests = 0; denied = true;
|
||||
try {
|
||||
for (const p of NATIVE_SUBSCRIPTIONS) {
|
||||
const model = piModels().getModels(p.providerId)[0]!, engine = piEngineForSpec({ provider: p.logicalId, id: model.id }, false);
|
||||
const fetch = async () => { requests++; throw new Error("must not dispatch"); };
|
||||
assert.equal((await engine.streamFn(engine.model, context, { fetch }).result()).stopReason, "error");
|
||||
assert.equal((await engine.streamFn(engine.model, context, { fetch, headers: { AUTHORIZATION: null } }).result()).stopReason, "error");
|
||||
}
|
||||
assert.equal(requests, 0);
|
||||
} finally { denied = false; }
|
||||
});
|
||||
test("a helper refused before dispatch reports no phantom usage or extraction step", async () => {
|
||||
denied = true; let usage = 0, steps = 0;
|
||||
try {
|
||||
const model = piModels().getModels("xai")[0]!, engine = piEngineForSpec({ provider: "xai-subscription", id: model.id });
|
||||
await assert.rejects(runSubagent([], { role: "researcher", engine, systemPrompt: "Offline fixture", prompt: "Do not dispatch", maxTurns: 1, onUsage: () => { usage++; }, onAssistantMessage: () => { steps++; } }));
|
||||
assert.equal(usage, 0); assert.equal(steps, 0);
|
||||
} finally { denied = false; }
|
||||
});
|
||||
test("history treatment distinguishes the same model through API and subscription, retaining only own reasoning signatures", () => {
|
||||
const native = piModels().getModels("xai")[0]!;
|
||||
const usage = { input: 1, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 1, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } };
|
||||
const message: AssistantMessage = { role: "assistant", provider: "xai", api: native.api, model: native.id, timestamp: 0, stopReason: "stop", usage, content: [{ type: "thinking", thinking: "", thinkingSignature: '{"encrypted_content":"private-api-signature"}', redacted: true }] };
|
||||
const own: AssistantMessage = { ...message, provider: "xai-subscription", content: [{ type: "thinking", thinking: "", thinkingSignature: '{"encrypted_content":"own-signature"}', redacted: true }] };
|
||||
const logical: Model<Api> = { ...native, provider: "xai-subscription" };
|
||||
const transformed = subscriptionHistory(normalizeContext({ messages: [message, own] }), logical, "xai");
|
||||
assert.deepEqual((transformed.messages[0] as AssistantMessage).content, []);
|
||||
assert.equal((transformed.messages[1] as AssistantMessage).content[0]!.type, "thinking");
|
||||
assert.equal(message.content.length, 1);
|
||||
});
|
||||
test("background invalidation is provider-specific and preserves foreground controllers; stale updates cannot reenable it", () => {
|
||||
const foreground = subscriptionAbortController("xai", false), background = subscriptionAbortController("xai", true), other = subscriptionAbortController("github-copilot", true);
|
||||
revision++; const next = snapshot(); next.statuses.find((s) => s.providerId === "xai")!.allowBackgroundUse = false;
|
||||
emit({ channel: "lh:subscriptions", op: "changed", snapshot: next });
|
||||
assert(!foreground.controller.signal.aborted); assert(background.controller.signal.aborted); assert(!other.controller.signal.aborted);
|
||||
const stale = snapshot(); stale.statuses.forEach((s) => { s.revision = 0; }); emit({ channel: "lh:subscriptions", op: "changed", snapshot: stale });
|
||||
foreground.release(); background.release(); other.release();
|
||||
});
|
||||
test("framework IPC alone cannot request subscription credentials or retain a usable catalog", async () => {
|
||||
const previous = sends; delete process.env.LH_DESKTOP_SERVER;
|
||||
await assert.rejects(subscriptionRequest("snapshot"), /desktop app/); assert.equal(sends, previous); assert.deepEqual(nativeSubscriptionModels(), []);
|
||||
process.env.LH_DESKTOP_SERVER = "1";
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
(globalThis as Record<string, unknown>).React = React;
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { subscriptionLabel, subscriptionLabels, isSubscriptionModel, type TurnUsage } from "../src/core/index.ts";
|
||||
import { sessionCost, callCost } from "../src/server/pricing.ts";
|
||||
import { graphCostSummary } from "../src/core/cost-summary.ts";
|
||||
import { planCostLabel } from "../components/usage-format.ts";
|
||||
import { StatsBar } from "../components/stats-bar.tsx";
|
||||
import { ModelMenuList } from "../components/settings/model-menu.tsx";
|
||||
import { toModelDto } from "../src/server/model-catalog.ts";
|
||||
import { piModels } from "../src/agent/models.ts";
|
||||
const step = { inputTokens: 1000, outputTokens: 100, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined };
|
||||
const turn = (model: string, id: string): TurnUsage => ({ id, messageId: id, at: "2026-10-24T00:00:00Z", model, steps: [step] });
|
||||
|
||||
test("only explicit subscription route IDs count as subscription use; names stay compact and unique", () => {
|
||||
assert.equal(subscriptionLabel("chatgpt:gpt-5"), "ChatGPT");
|
||||
assert.equal(subscriptionLabel("github-copilot-subscription:fixture"), "GitHub Copilot");
|
||||
assert.equal(subscriptionLabel("xai-subscription:grok"), "Grok");
|
||||
for (const model of ["openai:gpt-5", "xai:grok", "github-copilot:fixture", "meta:muse", "radius:any", "kimi-coding:any"]) assert(!isSubscriptionModel(model));
|
||||
assert.deepEqual(subscriptionLabels(["chatgpt:a", "xai-subscription:b", "chatgpt:c", "github-copilot-subscription:d"]), ["ChatGPT", "Grok", "GitHub Copilot"]);
|
||||
assert.equal(planCostLabel(0.12, ["Grok"], (n) => `$${n.toFixed(2)}`), "$0.12 + Grok");
|
||||
assert.equal(planCostLabel(0.67, ["ChatGPT"], (n) => `$${n.toFixed(2)}`), "$0.67 + ChatGPT");
|
||||
assert.equal(planCostLabel(null, ["ChatGPT", "GitHub Copilot", "Grok"]), "ChatGPT + GitHub Copilot + Grok");
|
||||
});
|
||||
test("mixed session pricing sums only API dollars and retains all subscription providers, not unpriced errors", () => {
|
||||
const turns = [turn("github-copilot-subscription:gpt-5", "copilot"), turn("xai-subscription:grok", "grok"), turn("chatgpt:gpt-5", "chatgpt"), turn("openai:gpt-5", "api")];
|
||||
const pricing = sessionCost({ turns }); assert.equal(pricing.subscription, 3); assert.equal(pricing.unpriced, 0); assert.equal(pricing.cost, callCost(step, "openai:gpt-5"));
|
||||
assert.deepEqual(pricing.subscriptionProviders, ["GitHub Copilot", "Grok", "ChatGPT"]);
|
||||
const html = renderToStaticMarkup(<StatsBar turns={turns} pricing={pricing} />);
|
||||
assert.match(html, /\$[\d.]+ \+ GitHub Copilot \+ Grok \+ ChatGPT/); assert(!html.includes(" API +")); assert(!html.includes("ChatGPT plan"));
|
||||
});
|
||||
test("graph stage/recent aggregates retain distinct subscription providers without inventing dollars", () => {
|
||||
const summary = graphCostSummary({ chats: [{ title: "Fixture", extractions: [{ startedAt: 1, at: "2026-10-24T00:00:00Z", durationMs: 1, stages: { extractor: { model: "github-copilot-subscription:gpt-5", steps: [step] }, auditor: { model: "xai-subscription:grok", steps: [step] } } }] }], sources: [] }, callCost);
|
||||
assert.equal(summary.totalDollars, 0); assert.equal(summary.unpricedRuns, 0); assert.equal(summary.runs[0]!.dollars, null);
|
||||
assert.deepEqual(summary.subscriptionProviders, ["GitHub Copilot", "Grok"]); assert.deepEqual(summary.runs[0]!.subscriptionProviders, ["GitHub Copilot", "Grok"]);
|
||||
});
|
||||
test("model menu distinguishes subscription connection and shows provider label rather than API prices", () => {
|
||||
const native = piModels().getModels("xai")[0]!;
|
||||
const html = renderToStaticMarkup(<ModelMenuList models={[toModelDto({ ...native, provider: "xai-subscription" })]} effectiveId={undefined} onPick={() => {}} />);
|
||||
assert(html.includes("Grok (subscription)")); assert(html.includes(">Grok</span>")); assert(!html.includes("USD per 1M")); assert(!html.includes("$0"));
|
||||
});
|
||||
Reference in New Issue
Block a user