Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b277ee3e0f | ||
|
|
9171a4f49c | ||
|
|
7fb9a1505c | ||
|
|
3cc598ded5 | ||
|
|
55a6501a09 | ||
|
|
d6e317c6fe | ||
|
|
0a9dabfdc2 | ||
|
|
5cf4e589be | ||
|
|
671d2c8bb7 | ||
|
|
e8c81faad9 | ||
|
|
480a29aaea | ||
|
|
c549494488 | ||
|
|
74b0c65843 | ||
|
|
04037e98d6 | ||
|
|
36338b3b55 | ||
|
|
8946209f98 | ||
|
|
624c91a437 | ||
|
|
24d1dda07d |
@@ -0,0 +1,49 @@
|
|||||||
|
name: Notify website of public desktop release
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
# Use only the separately scoped website token; no app token or checkout is needed.
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
notify-website:
|
||||||
|
if: >-
|
||||||
|
github.repository == 'amosblomqvist/learnharness' &&
|
||||||
|
!github.event.release.draft && !github.event.release.prerelease
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- name: Queue the website update for the published tag
|
||||||
|
env:
|
||||||
|
WEBSITE_DISPATCH_TOKEN: ${{ secrets.WEBSITE_DISPATCH_TOKEN }}
|
||||||
|
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ -z "$WEBSITE_DISPATCH_TOKEN" ]]; then
|
||||||
|
echo '::error::Missing WEBSITE_DISPATCH_TOKEN.'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -z "$RELEASE_TAG" ]]; then
|
||||||
|
echo '::error::The published release has no tag.'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
payload=$(jq -cn --arg tag "$RELEASE_TAG" \
|
||||||
|
'{event_type:"desktop-release-published",client_payload:{tag:$tag}}')
|
||||||
|
status=$(curl --fail-with-body --silent --show-error \
|
||||||
|
--connect-timeout 15 --max-time 60 \
|
||||||
|
--request POST \
|
||||||
|
--header 'Accept: application/vnd.github+json' \
|
||||||
|
--header "Authorization: Bearer $WEBSITE_DISPATCH_TOKEN" \
|
||||||
|
--header 'X-GitHub-Api-Version: 2022-11-28' \
|
||||||
|
--header 'Content-Type: application/json' \
|
||||||
|
--data "$payload" \
|
||||||
|
--output /dev/null --write-out '%{http_code}' \
|
||||||
|
https://api.github.com/repos/amosblomqvist/learnharness-web/dispatches)
|
||||||
|
if [[ "$status" != '204' ]]; then
|
||||||
|
printf '::error::Expected dispatch HTTP 204, received %s.\n' "$status"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo 'Website update queued. This does not confirm website deployment.'
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* GET /api/activity — the dock's one poll: every running background
|
* GET /api/activity — the dock's one poll: every running background
|
||||||
* process, every view (chat, home, everywhere). Composition only — the
|
* process, every view (chat, home, everywhere). Composition only — the
|
||||||
* feed lives in server/observability.ts; cost folds client-side via
|
* feed lives in server/observability.ts; costs are projected server-side
|
||||||
* core/prices.ts. See that module's header for why /api/jobs stays
|
* through Pi's catalog and public calculator. See that module's header for why /api/jobs stays
|
||||||
* chat-scoped and untouched.
|
* chat-scoped and untouched.
|
||||||
*/
|
*/
|
||||||
import { activityFeed } from "@/src/server";
|
import { activityFeed } from "@/src/server";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, stores, type ChatDeleteMode } from "@/src/server";
|
import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, sessionCost, stores, type ChatDeleteMode } from "@/src/server";
|
||||||
|
|
||||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -17,6 +17,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
|
|||||||
return Response.json({
|
return Response.json({
|
||||||
...chat,
|
...chat,
|
||||||
messages: chat.messages,
|
messages: chat.messages,
|
||||||
|
pricing: sessionCost(chat),
|
||||||
turnInFlight: isTurnRunning(id),
|
turnInFlight: isTurnRunning(id),
|
||||||
compacting: compactionInFlight(id),
|
compacting: compactionInFlight(id),
|
||||||
coverage: sessionCoverage(id),
|
coverage: sessionCoverage(id),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* three usage channels (chat extraction runs, notes-source runs,
|
* three usage channels (chat extraction runs, notes-source runs,
|
||||||
* cluster-naming runs). Thin transport over src/server's graphCosts();
|
* cluster-naming runs). Thin transport over src/server's graphCosts();
|
||||||
* the fold itself is pure core (core/cost-summary.ts). Dollars are
|
* the fold itself is pure core (core/cost-summary.ts). Dollars are
|
||||||
* inferred from PRICES at read time — never stored, so a price sync
|
* inferred from Pi's catalog/calculator at read time — never stored, so a catalog update
|
||||||
* re-prices the whole history for free.
|
* re-prices the whole history for free.
|
||||||
*
|
*
|
||||||
* Scope (user-decided): the graph pipeline ONLY — teaching turns and
|
* Scope (user-decided): the graph pipeline ONLY — teaching turns and
|
||||||
|
|||||||
@@ -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 { ActivityIcon, BotIcon, FileTextIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CallUsage, JobKind, JobRecord } from "@/src/core";
|
import type { CallUsage, JobKind, JobRecord } from "@/src/core";
|
||||||
import { isModelsRefusal, isProviderKeyRefusal, jobCost } from "@/src/core";
|
import { isModelsRefusal, isProviderKeyRefusal, subscriptionLabel, subscriptionLabels } from "@/src/core";
|
||||||
import { formatTokens, planCostLabel } from "@/components/usage-format";
|
import { formatTokens, planCostLabel } from "@/components/usage-format";
|
||||||
import { useServerEvent, useServerResync } from "@/components/server-events";
|
import { useServerEvent, useServerResync } from "@/components/server-events";
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ import { useServerEvent, useServerResync } from "@/components/server-events";
|
|||||||
* Data: ONE feed of GET /api/activity (jobs across ALL chats, title-joined,
|
* Data: ONE feed of GET /api/activity (jobs across ALL chats, title-joined,
|
||||||
* oldest-first + extraction runs), fetched when truth moves (events
|
* oldest-first + extraction runs), fetched when truth moves (events
|
||||||
* channel, S5) and folded client-side with core/prices.ts.
|
* channel, S5) and folded client-side with core/prices.ts.
|
||||||
* Cost is INFERRED (jobCost); the context line is the LAST model call's
|
* Cost is INFERRED server-side through Pi; the context line is the LAST model call's
|
||||||
* input+output against the model's limit (JobUsage.lastStep — pending
|
* input+output against the model's limit (JobUsage.lastStep — pending
|
||||||
* contract) — never a cumulative sum, which would be the wrong number.
|
* contract) — never a cumulative sum, which would be the wrong number.
|
||||||
* Absent data renders "—", never zero (the doctrine).
|
* Absent data renders "—", never zero (the doctrine).
|
||||||
@@ -48,6 +48,8 @@ interface FeedUsage extends NonNullable<JobRecord["usage"]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FeedJob extends JobRecord {
|
export interface FeedJob extends JobRecord {
|
||||||
|
/** Read-time server estimate; absent on older transport payloads. */
|
||||||
|
costUsd?: number | null;
|
||||||
title: string;
|
title: string;
|
||||||
usage?: FeedUsage;
|
usage?: FeedUsage;
|
||||||
/** Server-joined live catalog limit (owner amendment 2026-09-23) —
|
/** Server-joined live catalog limit (owner amendment 2026-09-23) —
|
||||||
@@ -200,7 +202,7 @@ export function toBlobs(feed: ActivityFeed): ActivityBlob[] {
|
|||||||
// blob shape's call — here the line simply hides).
|
// blob shape's call — here the line simply hides).
|
||||||
const limit = job.contextLimit;
|
const limit = job.contextLimit;
|
||||||
if (usage && lastStep && limit !== null && lastStep.inputTokens !== undefined) {
|
if (usage && lastStep && limit !== null && lastStep.inputTokens !== undefined) {
|
||||||
// Same metric as the session meter (prices.ts stepFootprint — the
|
// Same metric as the session meter (four normalized buckets — the
|
||||||
// context-meter audit, owner 2026-09-23): all four directions.
|
// context-meter audit, owner 2026-09-23): all four directions.
|
||||||
// Provider "input" EXCLUDES cacheRead/cacheWrite, so input+output
|
// Provider "input" EXCLUDES cacheRead/cacheWrite, so input+output
|
||||||
// alone reads wildly low on cache-heavy jobs.
|
// alone reads wildly low on cache-heavy jobs.
|
||||||
@@ -220,7 +222,7 @@ export function toBlobs(feed: ActivityFeed): ActivityBlob[] {
|
|||||||
title: job.title,
|
title: job.title,
|
||||||
chatId: job.chatId,
|
chatId: job.chatId,
|
||||||
startedAt: Date.parse(job.createdAt) || 0,
|
startedAt: Date.parse(job.createdAt) || 0,
|
||||||
cost: jobCost(job),
|
cost: job.costUsd ?? null,
|
||||||
context,
|
context,
|
||||||
// BYOK (owner, 2026-09-22): a refusal is info, not failure —
|
// BYOK (owner, 2026-09-22): a refusal is info, not failure —
|
||||||
// the honest line the runner left ("failed") reads as setup
|
// the honest line the runner left ("failed") reads as setup
|
||||||
@@ -348,7 +350,7 @@ export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) {
|
|||||||
Background agents
|
Background agents
|
||||||
{blobs.length > 0 && (
|
{blobs.length > 0 && (
|
||||||
<span className="ml-auto tabular-nums" data-testid="activity-total-cost" title="Total running cost (inferred)">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -423,7 +425,7 @@ function ActivityBlobRow({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex shrink-0 items-center gap-2 tabular-nums text-muted-foreground">
|
<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 data-testid="activity-blob-elapsed">{formatElapsed(now - blob.startedAt)}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -462,7 +464,7 @@ function ActivityBlobRow({
|
|||||||
<div className="flex justify-between gap-2">
|
<div className="flex justify-between gap-2">
|
||||||
<span>Cost</span>
|
<span>Cost</span>
|
||||||
<span className="tabular-nums text-card-foreground">
|
<span className="tabular-nums text-card-foreground">
|
||||||
{planCostLabel(blob.cost, blob.model?.startsWith("chatgpt:") === true)}
|
{planCostLabel(blob.cost, subscriptionLabel(blob.model))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between gap-2">
|
<div className="flex justify-between gap-2">
|
||||||
|
|||||||
+17
-6
@@ -57,7 +57,7 @@ import { PracticeDock } from "@/components/practice-dock";
|
|||||||
import { ThinkingCard, type PracticeThinkingData } from "@/components/practice-cards";
|
import { ThinkingCard, type PracticeThinkingData } from "@/components/practice-cards";
|
||||||
import { GraphContextCard, type GraphContextData } from "@/components/graph-context-card";
|
import { GraphContextCard, type GraphContextData } from "@/components/graph-context-card";
|
||||||
import { TopBar } from "@/components/top-bar";
|
import { TopBar } from "@/components/top-bar";
|
||||||
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage } from "@/src/core";
|
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage, SessionPricing } from "@/src/core";
|
||||||
import type { LhViewMessage } from "@/src/core";
|
import type { LhViewMessage } from "@/src/core";
|
||||||
import { PhaseChip, PhaseDivider } from "@/components/phase-divider";
|
import { PhaseChip, PhaseDivider } from "@/components/phase-divider";
|
||||||
import {
|
import {
|
||||||
@@ -236,7 +236,7 @@ export function Chat({
|
|||||||
// (core/view.ts projectView); sends are ONE explicit input event each
|
// (core/view.ts projectView); sends are ONE explicit input event each
|
||||||
// ({chatId, input} — the stash indirection died with the SDK), and the
|
// ({chatId, input} — the stash indirection died with the SDK), and the
|
||||||
// turn stream is the VERBATIM AgentEvent passthrough the server emits.
|
// turn stream is the VERBATIM AgentEvent passthrough the server emits.
|
||||||
const { messages, nativeMessages, send, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
|
const { messages, nativeMessages, contextTokens, send, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
|
||||||
useChatSession({ chatId, initialMessages, initiallyInFlight });
|
useChatSession({ chatId, initialMessages, initiallyInFlight });
|
||||||
|
|
||||||
const sessionFindScopeRef = useRef<HTMLDivElement | null>(null);
|
const sessionFindScopeRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -717,8 +717,9 @@ export function Chat({
|
|||||||
const syncFromServer = useCallback(async () => {
|
const syncFromServer = useCallback(async () => {
|
||||||
const res = await fetch(`/api/chats/${chatId}`);
|
const res = await fetch(`/api/chats/${chatId}`);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const record = (await res.json()) as { messages?: LhMessage[]; turns?: TurnUsage[] };
|
const record = (await res.json()) as { messages?: LhMessage[]; turns?: TurnUsage[]; pricing?: SessionPricing };
|
||||||
if (Array.isArray(record.turns)) setTurns(record.turns);
|
if (Array.isArray(record.turns)) setTurns(record.turns);
|
||||||
|
setPricing(record.pricing);
|
||||||
if (!Array.isArray(record.messages)) return;
|
if (!Array.isArray(record.messages)) return;
|
||||||
setMessages(record.messages);
|
setMessages(record.messages);
|
||||||
// The continuation seam (contracts.ts protocol §4): a job result
|
// The continuation seam (contracts.ts protocol §4): a job result
|
||||||
@@ -845,12 +846,13 @@ export function Chat({
|
|||||||
const planTree = useMemo(() => latestTree(messages), [messages]);
|
const planTree = useMemo(() => latestTree(messages), [messages]);
|
||||||
|
|
||||||
// ── Session usage (wave-4 stats bar) ──
|
// ── Session usage (wave-4 stats bar) ──
|
||||||
// The money channel: the record's turns log, folded client-side by
|
// The money channel: the record's turns log and read-time server Pi
|
||||||
// core/prices.ts. Seeded once on mount (the page doesn't carry turns),
|
// pricing projection. Seeded once on mount (the page doesn't carry turns),
|
||||||
// refreshed when the local stream settles (each turn appends to the log
|
// refreshed when the local stream settles (each turn appends to the log
|
||||||
// server-side), and kept fresh by syncFromServer whenever it already
|
// server-side), and kept fresh by syncFromServer whenever it already
|
||||||
// fetches the record. No turns yet → the bar renders "—" everywhere.
|
// fetches the record. No turns yet → the bar renders "—" everywhere.
|
||||||
const [turns, setTurns] = useState<TurnUsage[]>([]);
|
const [turns, setTurns] = useState<TurnUsage[]>([]);
|
||||||
|
const [pricing, setPricing] = useState<SessionPricing | undefined>();
|
||||||
// The per-chat model control state (model-picker writes via PUT; the
|
// The per-chat model control state (model-picker writes via PUT; the
|
||||||
// record is the source of truth, lifted here so the stats bar re-renders).
|
// record is the source of truth, lifted here so the stats bar re-renders).
|
||||||
// The dock's reserved tail height (DockOverlay reports it; the spacer at
|
// The dock's reserved tail height (DockOverlay reports it; the spacer at
|
||||||
@@ -864,8 +866,9 @@ export function Chat({
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/chats/${chatId}`);
|
const res = await fetch(`/api/chats/${chatId}`);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const record = (await res.json()) as { turns?: TurnUsage[] };
|
const record = (await res.json()) as { turns?: TurnUsage[]; pricing?: SessionPricing };
|
||||||
if (Array.isArray(record.turns)) setTurns(record.turns);
|
if (Array.isArray(record.turns)) setTurns(record.turns);
|
||||||
|
setPricing(record.pricing);
|
||||||
} catch {
|
} catch {
|
||||||
// transient blip: stats stay on the last known figures
|
// transient blip: stats stay on the last known figures
|
||||||
}
|
}
|
||||||
@@ -1604,6 +1607,8 @@ export function Chat({
|
|||||||
onReadingThinking={applyReadingThinking}
|
onReadingThinking={applyReadingThinking}
|
||||||
phase={phases.at(-1) ?? "probe"}
|
phase={phases.at(-1) ?? "probe"}
|
||||||
turns={turns}
|
turns={turns}
|
||||||
|
contextTokens={contextTokens}
|
||||||
|
pricing={pricing}
|
||||||
onReserve={setDockReserve}
|
onReserve={setDockReserve}
|
||||||
dictationSlot={<DictationBar dictation={dictation} />}
|
dictationSlot={<DictationBar dictation={dictation} />}
|
||||||
dictationCancel={dictation.cancel}
|
dictationCancel={dictation.cancel}
|
||||||
@@ -1824,6 +1829,8 @@ function DockOverlay({
|
|||||||
onReadingThinking,
|
onReadingThinking,
|
||||||
phase,
|
phase,
|
||||||
turns,
|
turns,
|
||||||
|
contextTokens,
|
||||||
|
pricing,
|
||||||
composer,
|
composer,
|
||||||
onSubmitAnswer,
|
onSubmitAnswer,
|
||||||
chatId,
|
chatId,
|
||||||
@@ -1855,6 +1862,8 @@ function DockOverlay({
|
|||||||
phase: Phase3;
|
phase: Phase3;
|
||||||
/** The session's usage log — the stats bar beneath the composer folds it. */
|
/** The session's usage log — the stats bar beneath the composer folds it. */
|
||||||
turns: TurnUsage[];
|
turns: TurnUsage[];
|
||||||
|
contextTokens: number | null;
|
||||||
|
pricing?: SessionPricing;
|
||||||
composer: React.ReactNode;
|
composer: React.ReactNode;
|
||||||
onSubmitAnswer: (output: QuestionAnswerData) => void;
|
onSubmitAnswer: (output: QuestionAnswerData) => void;
|
||||||
/** The model control's pass-through (the stats bar hosts it). */
|
/** The model control's pass-through (the stats bar hosts it). */
|
||||||
@@ -2327,6 +2336,8 @@ function DockOverlay({
|
|||||||
morphing shell so it never joins a height animation. */}
|
morphing shell so it never joins a height animation. */}
|
||||||
<StatsBar
|
<StatsBar
|
||||||
turns={turns}
|
turns={turns}
|
||||||
|
contextTokens={contextTokens}
|
||||||
|
pricing={pricing}
|
||||||
dictationSlot={dictationSlot}
|
dictationSlot={dictationSlot}
|
||||||
chatId={chatId}
|
chatId={chatId}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import { NotesImportErrors, useNotesImport } from "@/components/notes-import.tsx
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { FileTextIcon, ListTreeIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react";
|
import { FileTextIcon, ListTreeIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react";
|
||||||
import { incrementalLayout, propositionSessionHref, type GraphCostSummary, type GraphNode, type GraphSnapshot } from "@/src/core";
|
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 { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts";
|
||||||
import { usePopoverDismiss } from "@/components/popover-dismiss";
|
import { usePopoverDismiss } from "@/components/popover-dismiss";
|
||||||
import { useActivity } from "@/components/activity-dock";
|
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
|
/** Run/stage cost at 3 decimals (pipeline pennies are real money at this
|
||||||
* scale); headline totals use 2. Null (unpriced model) renders "—". */
|
* scale); headline totals use 2. Null (unpriced model) renders "—". */
|
||||||
function fmtRunCost(dollars: number | null, subscription = false): string {
|
function fmtRunCost(dollars: number | null, providers: readonly string[] = []): string {
|
||||||
if (subscription) return dollars === null ? "ChatGPT plan" : `$${dollars.toFixed(3)} API + ChatGPT plan`;
|
return planCostLabel(dollars, providers, (value) => `$${value.toFixed(3)}`);
|
||||||
return dollars === null ? "—" : `$${dollars.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 {
|
function graphUsageLabel(costs: Pick<GraphCostSummary, "stages">): string {
|
||||||
const stages = [costs.stages.extraction, costs.stages.audit];
|
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 priced = stages.some((stage) => stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) > 0);
|
||||||
const dollars = displayCostTotals(costs).totalDollars;
|
const dollars = displayCostTotals(costs).totalDollars;
|
||||||
return plan ? (priced ? `$${dollars.toFixed(2)} API + ChatGPT plan` : "ChatGPT plan") : `$${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]">
|
<div key={label} className="flex items-baseline justify-between gap-2 text-[11px]">
|
||||||
<span className="shrink-0 text-muted-foreground">{label}</span>
|
<span className="shrink-0 text-muted-foreground">{label}</span>
|
||||||
<span className="min-w-0 text-right tabular-nums">
|
<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) && (
|
{stage.unpricedRuns > 0 && (stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) > 0 || (stage.subscriptionRuns ?? 0) > 0) && (
|
||||||
<span className="text-muted-foreground"> +{stage.unpricedRuns} unpriced</span>
|
<span className="text-muted-foreground"> +{stage.unpricedRuns} unpriced</span>
|
||||||
)}
|
)}
|
||||||
@@ -430,7 +433,7 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export function catalogLimits(catalog: Pick<ModelCatalogResponse, "models">): Ma
|
|||||||
* changing the new-session default. Unpicked reads "—" (the
|
* changing the new-session default. Unpicked reads "—" (the
|
||||||
* no-factory-fallback language: the UI never presents a default model).
|
* no-factory-fallback language: the UI never presents a default model).
|
||||||
*/
|
*/
|
||||||
export function ModelControl({ chatId }: { chatId?: string }) {
|
export function ModelControl({ chatId, onModelChange }: { chatId?: string; onModelChange?: (model: string | undefined) => void }) {
|
||||||
const { catalog, refresh } = useModelCatalog();
|
const { catalog, refresh } = useModelCatalog();
|
||||||
const [models, setModels] = useState<ModelsSettings | undefined>();
|
const [models, setModels] = useState<ModelsSettings | undefined>();
|
||||||
const [session, setSession] = useState<ChatSettings | undefined>();
|
const [session, setSession] = useState<ChatSettings | undefined>();
|
||||||
@@ -136,6 +136,9 @@ export function ModelControl({ chatId }: { chatId?: string }) {
|
|||||||
: strong;
|
: strong;
|
||||||
const effectiveId = override?.model?.id;
|
const effectiveId = override?.model?.id;
|
||||||
const effectiveProvider = override?.model?.provider;
|
const effectiveProvider = override?.model?.provider;
|
||||||
|
useEffect(() => {
|
||||||
|
onModelChange?.(effectiveProvider && effectiveId ? `${effectiveProvider}:${effectiveId}` : undefined);
|
||||||
|
}, [effectiveProvider, effectiveId, onModelChange]);
|
||||||
const storedLevel = override?.thinkingLevel;
|
const storedLevel = override?.thinkingLevel;
|
||||||
const entry = catalog?.models.find(
|
const entry = catalog?.models.find(
|
||||||
(m) => m.id === effectiveId && m.provider === effectiveProvider,
|
(m) => m.id === effectiveId && m.provider === effectiveProvider,
|
||||||
|
|||||||
+50
-19
@@ -17,6 +17,7 @@ import type { AgentEvent } from "@earendil-works/pi-agent-core";
|
|||||||
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
||||||
import {
|
import {
|
||||||
needsContinuation,
|
needsContinuation,
|
||||||
|
measuredContextTokens,
|
||||||
newAnswerResult,
|
newAnswerResult,
|
||||||
newNextMessage,
|
newNextMessage,
|
||||||
newProblemDismissedMarker,
|
newProblemDismissedMarker,
|
||||||
@@ -163,6 +164,8 @@ function sameDetails(a: unknown, b: unknown): boolean {
|
|||||||
export interface SessionState {
|
export interface SessionState {
|
||||||
messages: LhViewMessage[];
|
messages: LhViewMessage[];
|
||||||
nativeMessages: LhMessage[];
|
nativeMessages: LhMessage[];
|
||||||
|
/** Completed conversation usage; null after compaction or before first usage. */
|
||||||
|
contextTokens: number | null;
|
||||||
status: SessionStatus;
|
status: SessionStatus;
|
||||||
error: { message: string } | null;
|
error: { message: string } | null;
|
||||||
/** Steers sent but not yet drained (composer steering, 2026-10-03):
|
/** Steers sent but not yet drained (composer steering, 2026-10-03):
|
||||||
@@ -220,10 +223,14 @@ export class SessionModel {
|
|||||||
// The live turn's execution layer (view model mid-turn states):
|
// The live turn's execution layer (view model mid-turn states):
|
||||||
private executions = new Map<string, LiveExecution>();
|
private executions = new Map<string, LiveExecution>();
|
||||||
private streamingIds = new Set<string>();
|
private streamingIds = new Set<string>();
|
||||||
|
private incompleteUsageIds = new Set<string>();
|
||||||
/** The in-flight assistant message's client id (see applyWireEvent). */
|
/** The in-flight assistant message's client id (see applyWireEvent). */
|
||||||
private streamingMessageId: string | null = null;
|
private streamingMessageId: string | null = null;
|
||||||
private statusValue: SessionStatus = "ready";
|
private statusValue: SessionStatus = "ready";
|
||||||
|
/** Request/loop failures are transient; provider failures derive from messages. */
|
||||||
private errorValue: { message: string } | null = null;
|
private errorValue: { message: string } | null = null;
|
||||||
|
/** A completed failure from THIS consume, not the previous attempt's record. */
|
||||||
|
private liveFailureId: string | null = null;
|
||||||
// The single live consume (POST response or reattach stream): a newer
|
// The single live consume (POST response or reattach stream): a newer
|
||||||
// consume cancels the older — one reader at a time.
|
// consume cancels the older — one reader at a time.
|
||||||
private consumeAbort: AbortController | null = null;
|
private consumeAbort: AbortController | null = null;
|
||||||
@@ -254,11 +261,22 @@ export class SessionModel {
|
|||||||
this.statusValue === "submitted" || this.statusValue === "streaming"
|
this.statusValue === "submitted" || this.statusValue === "streaming"
|
||||||
? { executions: this.executions, inFlight: true, streamingIds: this.streamingIds }
|
? { executions: this.executions, inFlight: true, streamingIds: this.streamingIds }
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const messages = [...this.base, ...this.tail];
|
||||||
|
const latest = messages.at(-1);
|
||||||
|
// One rule for live and restored failures: only the final completed
|
||||||
|
// assistant can own the banner. While trying again, hide the old
|
||||||
|
// failure from the first Thinking frame, but show a new failure at end.
|
||||||
|
const providerError =
|
||||||
|
latest?.role === "assistant" && latest.stopReason === "error" &&
|
||||||
|
!this.incompleteUsageIds.has(latest.id) && (!live || latest.id === this.liveFailureId)
|
||||||
|
? { message: latest.errorMessage ?? "An error occurred." }
|
||||||
|
: null;
|
||||||
return {
|
return {
|
||||||
messages: projectView([...this.base, ...this.tail], live),
|
messages: projectView(messages, live),
|
||||||
nativeMessages: [...this.base, ...this.tail],
|
nativeMessages: messages,
|
||||||
|
contextTokens: measuredContextTokens(messages, this.incompleteUsageIds),
|
||||||
status: this.statusValue,
|
status: this.statusValue,
|
||||||
error: this.errorValue,
|
error: this.errorValue ?? providerError,
|
||||||
steerQueue: this.steerQueueValue,
|
steerQueue: this.steerQueueValue,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -274,6 +292,7 @@ export class SessionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setStatus(status: SessionStatus): void {
|
private setStatus(status: SessionStatus): void {
|
||||||
|
if (status === "submitted") this.liveFailureId = null;
|
||||||
this.statusValue = status;
|
this.statusValue = status;
|
||||||
this.readyRef = status === "ready";
|
this.readyRef = status === "ready";
|
||||||
this.onChange?.();
|
this.onChange?.();
|
||||||
@@ -364,6 +383,15 @@ export class SessionModel {
|
|||||||
// the wire) still wins.
|
// the wire) still wins.
|
||||||
const id = message.id ?? this.streamingMessageId ?? localId();
|
const id = message.id ?? this.streamingMessageId ?? localId();
|
||||||
const withId = { ...message, id } as LhMessage;
|
const withId = { ...message, id } as LhMessage;
|
||||||
|
if (agent.type === "message_end") {
|
||||||
|
this.incompleteUsageIds.delete(id);
|
||||||
|
if (message.role === "assistant" && message.stopReason === "error") {
|
||||||
|
this.liveFailureId = id;
|
||||||
|
// A newer provider failure replaces an earlier connection/loop
|
||||||
|
// error, just as the live message_end did before restoration.
|
||||||
|
this.errorValue = null;
|
||||||
|
}
|
||||||
|
} else this.incompleteUsageIds.add(id);
|
||||||
if (agent.type === "message_start") {
|
if (agent.type === "message_start") {
|
||||||
this.streamingMessageId = id;
|
this.streamingMessageId = id;
|
||||||
this.streamingIds.add(id);
|
this.streamingIds.add(id);
|
||||||
@@ -378,15 +406,6 @@ export class SessionModel {
|
|||||||
}
|
}
|
||||||
if (agent.type === "message_end") {
|
if (agent.type === "message_end") {
|
||||||
this.streamingMessageId = null; // slot done — next start mints fresh
|
this.streamingMessageId = null; // slot done — next start mints fresh
|
||||||
// (fallthrough: returns true below — the message landed)
|
|
||||||
const stop = (message as { stopReason?: string }).stopReason;
|
|
||||||
if (stop === "error") {
|
|
||||||
// Provider error, verbatim (owner ruling) — the record
|
|
||||||
// persists the same message; no masking anywhere.
|
|
||||||
this.setError(
|
|
||||||
String((message as { errorMessage?: string }).errorMessage ?? "An error occurred."),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return true; // a message event is view work, even empty content
|
return true; // a message event is view work, even empty content
|
||||||
}
|
}
|
||||||
@@ -426,17 +445,18 @@ export class SessionModel {
|
|||||||
|
|
||||||
/** Consume one SSE body to its [DONE] terminal. The turn-settled
|
/** Consume one SSE body to its [DONE] terminal. The turn-settled
|
||||||
* terminal (stream-route only) fires the settle hook and ends. */
|
* terminal (stream-route only) fires the settle hook and ends. */
|
||||||
async consumeStream(res: Response): Promise<void> {
|
async consumeStream(res: Response, controller = this.consumeAbort): Promise<void> {
|
||||||
if (!res.body) throw new Error("The turn stream body is empty.");
|
if (!res.body) throw new Error("The turn stream body is empty.");
|
||||||
const reader = res.body.getReader();
|
const reader = res.body.getReader();
|
||||||
let sawEvent = false;
|
let sawEvent = false;
|
||||||
try {
|
try {
|
||||||
for await (const event of sseEvents(reader)) {
|
for await (const event of sseEvents(reader)) {
|
||||||
|
if (this.consumeAbort !== controller) return;
|
||||||
if (event.type === "turn-settled") {
|
if (event.type === "turn-settled") {
|
||||||
// The raced-settle terminal: no turn to attach to — the
|
// The raced-settle terminal: no turn to attach to — the
|
||||||
// record is current; the settle hook refetches.
|
// record is current; the settle hook refetches.
|
||||||
await reader.cancel().catch(() => {});
|
await reader.cancel().catch(() => {});
|
||||||
this.onReattachSettled?.();
|
if (this.consumeAbort === controller) this.onReattachSettled?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const acted = this.applyWireEvent(event);
|
const acted = this.applyWireEvent(event);
|
||||||
@@ -446,9 +466,13 @@ export class SessionModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.streamingIds = new Set();
|
// A cancelled consume may finish after its replacement has entered
|
||||||
this.executions = new Map();
|
// Thinking. Only the current owner can settle shared state.
|
||||||
if (this.statusValue !== "error") this.setStatus("ready");
|
if (this.consumeAbort === controller) {
|
||||||
|
this.streamingIds = new Set();
|
||||||
|
this.executions = new Map();
|
||||||
|
if (this.statusValue !== "error") this.setStatus("ready");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,6 +511,7 @@ export class SessionModel {
|
|||||||
// every path that actually replays onto a stale tail).
|
// every path that actually replays onto a stale tail).
|
||||||
if (!refetchRecordFirst) {
|
if (!refetchRecordFirst) {
|
||||||
this.mutate(() => {
|
this.mutate(() => {
|
||||||
|
this.incompleteUsageIds.clear();
|
||||||
this.tail = [];
|
this.tail = [];
|
||||||
this.streamingMessageId = null;
|
this.streamingMessageId = null;
|
||||||
});
|
});
|
||||||
@@ -497,11 +522,14 @@ export class SessionModel {
|
|||||||
const recordRes = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}`, {
|
const recordRes = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}`, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
if (this.consumeAbort !== controller) return;
|
||||||
if (recordRes.ok) {
|
if (recordRes.ok) {
|
||||||
const record = (await recordRes.json()) as { messages?: LhMessage[] };
|
const record = (await recordRes.json()) as { messages?: LhMessage[] };
|
||||||
|
if (this.consumeAbort !== controller) return;
|
||||||
if (Array.isArray(record.messages)) {
|
if (Array.isArray(record.messages)) {
|
||||||
this.mutate(() => {
|
this.mutate(() => {
|
||||||
this.base = record.messages!;
|
this.base = record.messages!;
|
||||||
|
this.incompleteUsageIds.clear();
|
||||||
this.tail = [];
|
this.tail = [];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -509,6 +537,7 @@ export class SessionModel {
|
|||||||
// The record did not land — restore the clean-slate guard
|
// The record did not land — restore the clean-slate guard
|
||||||
// before the replay (no atomic swap is coming).
|
// before the replay (no atomic swap is coming).
|
||||||
this.mutate(() => {
|
this.mutate(() => {
|
||||||
|
this.incompleteUsageIds.clear();
|
||||||
this.tail = [];
|
this.tail = [];
|
||||||
this.streamingMessageId = null;
|
this.streamingMessageId = null;
|
||||||
});
|
});
|
||||||
@@ -517,10 +546,11 @@ export class SessionModel {
|
|||||||
const res = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}/stream`, {
|
const res = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}/stream`, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
if (this.consumeAbort !== controller) return;
|
||||||
if (!res.ok) throw new Error((await res.text()) || "Failed to attach to the running turn.");
|
if (!res.ok) throw new Error((await res.text()) || "Failed to attach to the running turn.");
|
||||||
await this.consumeStream(res);
|
await this.consumeStream(res, controller);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as Error).name === "AbortError") return; // a newer consume won
|
if (this.consumeAbort !== controller || (err as Error).name === "AbortError") return; // a newer consume won
|
||||||
this.setError((err as Error).message);
|
this.setError((err as Error).message);
|
||||||
this.setStatus("error");
|
this.setStatus("error");
|
||||||
}
|
}
|
||||||
@@ -714,6 +744,7 @@ export class SessionModel {
|
|||||||
setMessages(record: LhMessage[]): void {
|
setMessages(record: LhMessage[]): void {
|
||||||
this.mutate(() => {
|
this.mutate(() => {
|
||||||
this.base = record;
|
this.base = record;
|
||||||
|
this.incompleteUsageIds.clear();
|
||||||
this.tail = [];
|
this.tail = [];
|
||||||
this.streamingMessageId = null; // the tail (and its slot) is gone
|
this.streamingMessageId = null; // the tail (and its slot) is gone
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
|
|||||||
return {
|
return {
|
||||||
messages: state.messages,
|
messages: state.messages,
|
||||||
nativeMessages: state.nativeMessages,
|
nativeMessages: state.nativeMessages,
|
||||||
|
contextTokens: state.contextTokens,
|
||||||
status: state.status,
|
status: state.status,
|
||||||
error: state.error,
|
error: state.error,
|
||||||
steerQueue: state.steerQueue,
|
steerQueue: state.steerQueue,
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import { SettingsGroup, SettingsRow, Toggle, CommitInput } from "./controls.tsx";
|
import { SettingsGroup, SettingsRow, Toggle, CommitInput } from "./controls.tsx";
|
||||||
import { ModelMenuList, ThinkingMenuList } from "./model-menu";
|
import { ModelMenuList, ThinkingMenuList } from "./model-menu";
|
||||||
import { ChatGPTBackgroundAccess } from "./chatgpt-connection";
|
import { ChatGPTBackgroundAccess } from "./chatgpt-connection";
|
||||||
|
import { NativeSubscriptionBackgroundAccess } from "./subscription-connections";
|
||||||
|
|
||||||
/** One slot's display identity — the tier's roster as the row hint rides
|
/** 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). */
|
* with the resolved default (owner: the hint ALWAYS names the default). */
|
||||||
@@ -648,7 +649,7 @@ export function AgentSection({
|
|||||||
ref={thisMenuOpen ? menuAnchorRef : undefined}
|
ref={thisMenuOpen ? menuAnchorRef : undefined}
|
||||||
className="relative flex items-center gap-2"
|
className="relative flex items-center gap-2"
|
||||||
>
|
>
|
||||||
{slot !== "strong" && <ChatGPTBackgroundAccess provider={coord?.provider} />}
|
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={coord?.provider} /><NativeSubscriptionBackgroundAccess provider={coord?.provider} /></>}
|
||||||
<MenuTrigger
|
<MenuTrigger
|
||||||
value={modelId ? prettyModel(modelId) : ""}
|
value={modelId ? prettyModel(modelId) : ""}
|
||||||
emptyLabel="—"
|
emptyLabel="—"
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import type { ChatGPTStatus } from "@/src/core";
|
import type { ChatGPTStatus } from "@/src/core";
|
||||||
import { useBackdropDismiss } from "@/components/backdrop-dismiss";
|
import { ConnectionDialog, ConnectionFooter, ConnectionRow } from "./connection-controls";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Toggle } from "./controls";
|
import { Toggle } from "./controls";
|
||||||
|
|
||||||
export const CHATGPT_NOTICE = "LearnHarness stores your ChatGPT account details and encrypted credentials locally on this device. When this connection is used, relevant conversation context, learning material, and tool results are sent directly to OpenAI. ChatGPT plan limits apply; any additional credits are authorized and managed in ChatGPT, not LearnHarness.";
|
export const CHATGPT_NOTICE = "LearnHarness stores your ChatGPT account details and credentials in an unencrypted local file on this device. When this connection is used, relevant conversation context, learning material, and tool results are sent directly to OpenAI. ChatGPT plan limits apply; any additional credits are authorized and managed in ChatGPT, not LearnHarness.";
|
||||||
export const CHATGPT_BACKGROUND_HINT = "Background work counts toward your ChatGPT plan usage.";
|
export const CHATGPT_BACKGROUND_HINT = "Background work counts toward your ChatGPT plan usage.";
|
||||||
const BACKGROUND_LABEL = "Allow background use for all ChatGPT accounts";
|
const BACKGROUND_LABEL = "Allow background use for all ChatGPT accounts";
|
||||||
const REVOCATION_WARNING = "Account disconnected locally. Remote revocation could not be confirmed.";
|
const REVOCATION_WARNING = "Account disconnected locally. Remote revocation could not be confirmed.";
|
||||||
@@ -22,7 +21,7 @@ function isStatus(value: unknown): value is ChatGPTStatus {
|
|||||||
const s = value as ChatGPTStatus;
|
const s = value as ChatGPTStatus;
|
||||||
return typeof s.available === "boolean" && typeof s.pending === "boolean" && typeof s.allowBackgroundUse === "boolean" && (s.activeId === null || typeof s.activeId === "string") &&
|
return typeof s.available === "boolean" && typeof s.pending === "boolean" && typeof s.allowBackgroundUse === "boolean" && (s.activeId === null || typeof s.activeId === "string") &&
|
||||||
(s.error === null || typeof s.error === "string") && (s.catalogError == null || typeof s.catalogError === "string") && Array.isArray(s.accounts) &&
|
(s.error === null || typeof s.error === "string") && (s.catalogError == null || typeof s.catalogError === "string") && Array.isArray(s.accounts) &&
|
||||||
s.accounts.every((a) => a && typeof a.id === "string" && typeof a.email === "string" && typeof a.registration === "string" && typeof a.connected === "boolean" && (a.planEnabled === undefined || typeof a.planEnabled === "boolean"));
|
s.accounts.every((a) => a && typeof a.id === "string" && typeof a.email === "string" && typeof a.registration === "string" && typeof a.connected === "boolean" && (a.planEnabled === undefined || typeof a.planEnabled === "boolean") && (a.canSignInAgain === undefined || typeof a.canSignInAgain === "boolean"));
|
||||||
}
|
}
|
||||||
function replyError(value: unknown): string {
|
function replyError(value: unknown): string {
|
||||||
return value && typeof value === "object" && "error" in value && typeof value.error === "string" ? value.error : CONTROL_ERROR;
|
return value && typeof value === "object" && "error" in value && typeof value.error === "string" ? value.error : CONTROL_ERROR;
|
||||||
@@ -138,11 +137,11 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const managedPresent = !!managed && status.accounts.some((a) => a.id === managed.id);
|
const managedPresent = !!managed && status.accounts.some((a) => a.id === managed.id);
|
||||||
const visibleError = error || (status.error !== dismissedError ? status.error : null) || status.catalogError || null;
|
const visibleError = error || (status.error !== dismissedError ? status.error : null) || status.catalogError || null;
|
||||||
const warning = visibleError === REVOCATION_WARNING ? visibleError : null;
|
const warning = visibleError === REVOCATION_WARNING ? visibleError : null;
|
||||||
function openConnect() {
|
function openConnect(accountId = "") {
|
||||||
if (operation.current) return;
|
if (operation.current) return;
|
||||||
setBackground(statusRef.current.allowBackgroundUse);
|
setBackground(statusRef.current.allowBackgroundUse);
|
||||||
setBackgroundDirty(false);
|
setBackgroundDirty(false);
|
||||||
setRegistration("");
|
setRegistration(accountId);
|
||||||
setError(null);
|
setError(null);
|
||||||
setDismissedError(status.error);
|
setDismissedError(status.error);
|
||||||
showDialog({ kind: "connect" });
|
showDialog({ kind: "connect" });
|
||||||
@@ -255,12 +254,13 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
|||||||
};
|
};
|
||||||
return <Context.Provider value={context}>
|
return <Context.Provider value={context}>
|
||||||
{children}
|
{children}
|
||||||
{dialog && <AccountDialog key={dialog.kind} onClose={close} busy={busy && operation.current !== "login"}>
|
{dialog && <AccountDialog key={`${dialog.kind}:${dialog.kind === "manage" && !!dialog.confirm}`} onClose={close} busy={busy && operation.current !== "login"}>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">ChatGPT</h2>
|
||||||
{dialog.kind === "warning" ? <>
|
{dialog.kind === "warning" ? <>
|
||||||
<p role="alert" className="rounded-sm border border-border px-3 py-2 text-xs text-foreground" data-testid="chatgpt-revocation-warning">{REVOCATION_WARNING}</p>
|
<p role="alert" className="rounded-sm border border-border px-3 py-2 text-xs text-foreground" data-testid="chatgpt-revocation-warning">{REVOCATION_WARNING}</p>
|
||||||
<div className="mt-4 flex justify-end"><Button variant="outline" size="sm" onClick={() => { context.dismissWarning(); showDialog(null); }}>Dismiss</Button></div>
|
<div className="mt-4 flex justify-end"><Button variant="outline" size="sm" onClick={() => { context.dismissWarning(); showDialog(null); }}>Dismiss</Button></div>
|
||||||
</> : dialog.kind === "manage" && dialog.confirm ? <div data-testid="chatgpt-disconnect-confirmation">
|
</> : dialog.kind === "manage" && dialog.confirm ? <div data-testid="chatgpt-disconnect-confirmation">
|
||||||
<h2 className="text-sm font-semibold text-red-400">{CHATGPT_DISCONNECT_COPY.title}</h2>
|
<p className="mt-3 text-sm font-semibold text-red-400">{CHATGPT_DISCONNECT_COPY.title}</p>
|
||||||
<p className="mt-3 text-xs text-foreground">{managed?.email || "ChatGPT account"}</p>
|
<p className="mt-3 text-xs text-foreground">{managed?.email || "ChatGPT account"}</p>
|
||||||
<p className="mt-3 text-xs text-muted-foreground">{CHATGPT_DISCONNECT_COPY.body}</p>
|
<p className="mt-3 text-xs text-muted-foreground">{CHATGPT_DISCONNECT_COPY.body}</p>
|
||||||
{context.error && <p role="alert" className="mt-3 text-xs text-red-400">{context.error}</p>}
|
{context.error && <p role="alert" className="mt-3 text-xs text-red-400">{context.error}</p>}
|
||||||
@@ -270,7 +270,6 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
</div> : <>
|
</div> : <>
|
||||||
{dialog.kind === "connect" ? <>
|
{dialog.kind === "connect" ? <>
|
||||||
<h2 className="text-sm font-semibold text-foreground">Continue with ChatGPT</h2>
|
|
||||||
{status.accounts.length > 0 && <div className="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
|
{status.accounts.length > 0 && <div className="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<select aria-label="Saved ChatGPT account" value={registration} onChange={(event) => setRegistration(event.target.value)} disabled={busy || status.pending} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1.5 text-foreground">
|
<select aria-label="Saved ChatGPT account" value={registration} onChange={(event) => setRegistration(event.target.value)} disabled={busy || status.pending} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1.5 text-foreground">
|
||||||
<option value="">Add another account or workspace</option>
|
<option value="">Add another account or workspace</option>
|
||||||
@@ -279,16 +278,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
|||||||
{registration && <Button variant="ghost" size="xs" disabled={busy} onClick={() => context.manage(registration)}>Manage…</Button>}
|
{registration && <Button variant="ghost" size="xs" disabled={busy} onClick={() => context.manage(registration)}>Manage…</Button>}
|
||||||
</div>}
|
</div>}
|
||||||
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="chatgpt-privacy-notice">{CHATGPT_NOTICE}</p>
|
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="chatgpt-privacy-notice">{CHATGPT_NOTICE}</p>
|
||||||
</> : managed && <>
|
</> : null}
|
||||||
<div data-testid="chatgpt-managed-identity" className="flex items-center gap-2 text-xs text-foreground">
|
|
||||||
{status.accounts.length > 1 && managedPresent && !busy ? <select aria-label="Manage ChatGPT account" value={managed.id} onChange={(event) => context.manage(event.target.value)} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1.5">
|
|
||||||
{status.accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
|
|
||||||
</select> : <span>{managed.email || "ChatGPT account"}</span>}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex items-center" data-testid="chatgpt-account-actions">
|
|
||||||
<Button variant="ghost" size="xs" className="cursor-pointer text-red-400 hover:bg-red-500/10 hover:text-red-300" data-testid="chatgpt-disconnect" disabled={busy || status.pending || !managedPresent} onClick={() => dialog.kind === "manage" && showDialog({ ...dialog, confirm: true })}>Disconnect account</Button>
|
|
||||||
</div>
|
|
||||||
</>}
|
|
||||||
<div className={`mt-4 flex items-center gap-3 text-xs text-foreground ${busy && operation.current === "change" && status.available && !status.pending ? "[&>button:disabled]:cursor-auto" : ""}`} data-testid="chatgpt-global-background">
|
<div className={`mt-4 flex items-center gap-3 text-xs text-foreground ${busy && operation.current === "change" && status.available && !status.pending ? "[&>button:disabled]:cursor-auto" : ""}`} data-testid="chatgpt-global-background">
|
||||||
<Toggle label={BACKGROUND_LABEL} checked={dialog.kind === "connect" ? background : status.allowBackgroundUse} disabled={busy || status.pending || !status.available} onChange={(enabled) => {
|
<Toggle label={BACKGROUND_LABEL} checked={dialog.kind === "connect" ? background : status.allowBackgroundUse} disabled={busy || status.pending || !status.available} onChange={(enabled) => {
|
||||||
if (dialog.kind === "connect") { setBackground(enabled); setBackgroundDirty(true); }
|
if (dialog.kind === "connect") { setBackground(enabled); setBackgroundDirty(true); }
|
||||||
@@ -296,19 +286,28 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}} />
|
}} />
|
||||||
<span>{BACKGROUND_LABEL}<span className="mt-1 block leading-relaxed text-muted-foreground">{CHATGPT_BACKGROUND_HINT}</span></span>
|
<span>{BACKGROUND_LABEL}<span className="mt-1 block leading-relaxed text-muted-foreground">{CHATGPT_BACKGROUND_HINT}</span></span>
|
||||||
</div>
|
</div>
|
||||||
{dialog.kind === "manage" && <p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="chatgpt-privacy-notice">{CHATGPT_NOTICE}</p>}
|
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
<div className="mt-3 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||||
<a href="https://openai.com/policies/privacy-policy/" target="_blank" rel="noreferrer" className="underline underline-offset-2">OpenAI privacy</a>
|
<a href="https://openai.com/policies/privacy-policy/" target="_blank" rel="noreferrer" className="underline underline-offset-2">OpenAI privacy</a>
|
||||||
<a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="underline underline-offset-2">Manage usage</a>
|
<a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="underline underline-offset-2">Manage usage</a>
|
||||||
</div>
|
</div>
|
||||||
{!status.available && <p className="mt-3 text-xs text-muted-foreground">ChatGPT sign-in is available only in the desktop app with secure OS credential storage. API-key connections remain available here.</p>}
|
{!status.available && <p className="mt-3 text-xs text-muted-foreground">ChatGPT sign-in is available only in the desktop app. API-key connections remain available here.</p>}
|
||||||
{status.pending && <p role="status" className="mt-3 text-xs text-muted-foreground">Waiting for browser sign-in…</p>}
|
{status.pending && <p role="status" className="mt-3 text-xs text-muted-foreground">Waiting for browser sign-in…</p>}
|
||||||
{context.error && <p role="alert" className="mt-3 text-xs text-red-400">{context.error}</p>}
|
{context.error && <p role="alert" className="mt-3 text-xs text-red-400">{context.error}</p>}
|
||||||
<div className="mt-4 flex items-center justify-end gap-2" data-testid="chatgpt-account-footer">
|
{dialog.kind === "manage" && managed && <div className="mt-4 flex flex-wrap items-center justify-between gap-3" data-testid="chatgpt-account-strip">
|
||||||
{dialog.kind === "manage" && <Button variant="outline" size="sm" data-testid="chatgpt-add-accounts" className="cursor-pointer disabled:cursor-not-allowed" onClick={openConnect} disabled={busy || status.pending || !status.available}>Add accounts</Button>}
|
<div data-testid="chatgpt-managed-identity" className="flex min-w-0 items-center gap-2 text-xs text-foreground">
|
||||||
<Button variant="ghost" size="sm" onClick={close} disabled={busy && operation.current !== "login"}>{dialog.kind === "connect" ? "Cancel" : "Close"}</Button>
|
{status.accounts.length > 1 && managedPresent && !busy ? <select aria-label="Manage ChatGPT account" value={managed.id} onChange={(event) => context.manage(event.target.value)} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1.5">
|
||||||
|
{status.accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
|
||||||
|
</select> : <span className="break-all">{managed.email || "ChatGPT account"}</span>}
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" data-testid="chatgpt-add-accounts" className="cursor-pointer text-muted-foreground disabled:cursor-not-allowed" onClick={() => openConnect()} disabled={busy || status.pending || !status.available}>Add accounts</Button>
|
||||||
|
</div>}
|
||||||
|
<ConnectionFooter testid="chatgpt-account-footer" leading={dialog.kind === "manage" && <div data-testid="chatgpt-account-actions">
|
||||||
|
<Button variant="ghost" size="sm" className="cursor-pointer text-red-400 hover:bg-red-500/10 hover:text-red-300" data-testid="chatgpt-disconnect" disabled={busy || status.pending || !managedPresent} onClick={() => showDialog({ ...dialog, confirm: true })}>Disconnect</Button>
|
||||||
|
</div>}>
|
||||||
|
{dialog.kind === "manage" && managedPresent && managed && !managed.connected && managed.canSignInAgain === true && status.available && <Button variant="outline" size="sm" disabled={busy || status.pending || !status.available} onClick={() => openConnect(managed.id)}>Sign in again</Button>}
|
||||||
|
<Button variant={dialog.kind === "manage" ? "outline" : "ghost"} size="sm" onClick={close} disabled={busy && operation.current !== "login"}>{dialog.kind === "connect" ? "Cancel" : "Close"}</Button>
|
||||||
{dialog.kind === "connect" && <ContinueButton onClick={() => void login()} disabled={!status.available || busy || status.pending} testid="chatgpt-proceed" />}
|
{dialog.kind === "connect" && <ContinueButton onClick={() => void login()} disabled={!status.available || busy || status.pending} testid="chatgpt-proceed" />}
|
||||||
</div>
|
</ConnectionFooter>
|
||||||
</>}
|
</>}
|
||||||
</AccountDialog>}
|
</AccountDialog>}
|
||||||
</Context.Provider>;
|
</Context.Provider>;
|
||||||
@@ -324,48 +323,20 @@ function ContinueButton({ onClick, disabled, testid = "chatgpt-login" }: { onCli
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AccountDialog({ children, onClose, busy }: { children: React.ReactNode; onClose: () => void; busy: boolean }) {
|
function AccountDialog({ children, onClose, busy }: { children: React.ReactNode; onClose: () => void; busy: boolean }) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
return <ConnectionDialog label="ChatGPT account" testid="chatgpt-account-dialog" backdropTestid="chatgpt-dialog-backdrop" onClose={onClose} busy={busy}>{children}</ConnectionDialog>;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compact settings row, or setup's secondary action only. */
|
/** Shared compact settings/setup row; account selection precedes the quiet action. */
|
||||||
export function ChatGPTConnection({ setup = false }: { setup?: boolean }) {
|
export function ChatGPTConnection(_props: { setup?: boolean } = {}) {
|
||||||
const ui = useContext(Context);
|
const ui = useContext(Context);
|
||||||
const active = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected);
|
const active = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected);
|
||||||
const accounts = ui.status.accounts.filter((a) => a.connected);
|
const accounts = ui.status.accounts.filter((a) => a.connected);
|
||||||
return <div className="flex flex-col gap-2" data-testid="chatgpt-connection">
|
return <div className="flex flex-col gap-2" data-testid="chatgpt-connection">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<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()}>
|
||||||
{!setup && active && <span className="font-ui text-xs text-foreground">ChatGPT</span>}
|
{active && <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">
|
||||||
{active && !setup ? <>
|
{accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
|
||||||
<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">
|
</select>}
|
||||||
{accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
|
</ConnectionRow>
|
||||||
</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>
|
|
||||||
{ui.inlineError && <p role="alert" className="text-xs text-red-400">{ui.inlineError}</p>}
|
{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>}
|
{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>;
|
</div>;
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"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 ${action === "Connect" ? "" : "text-muted-foreground hover:text-foreground"}`} data-testid={actionTestid} disabled={disabled} onClick={onAction}>{action}</Button>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConnectionFooter({ leading, children, testid }: { leading?: React.ReactNode; children: React.ReactNode; testid?: string }) {
|
||||||
|
return <div className="mt-4 flex flex-wrap items-center justify-between gap-3" data-testid={testid}>
|
||||||
|
{leading}
|
||||||
|
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">{children}</div>
|
||||||
|
</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 { useEffect, useRef, useState } from "react";
|
||||||
import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core";
|
import { AUTH_KEYS, type AuthKeyStatus } from "@/src/core";
|
||||||
import { SettingsGroup } from "./controls.tsx";
|
import { SettingsGroup } from "./controls.tsx";
|
||||||
import { ChatGPTConnection } from "./chatgpt-connection.tsx";
|
import { SubscriptionConnections } from "./subscription-connections.tsx";
|
||||||
import { ProviderMenuList } from "./model-menu.tsx";
|
import { ProviderMenuList } from "./model-menu.tsx";
|
||||||
import { MenuShell } from "@/components/menu-shell";
|
import { MenuShell } from "@/components/menu-shell";
|
||||||
import { notifySettingsSaved } from "@/components/settings-signal";
|
import { notifySettingsSaved } from "@/components/settings-signal";
|
||||||
@@ -456,7 +456,7 @@ export function KeysSection({
|
|||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
|
||||||
<SettingsGroup label="Subscriptions" testid="group-subscriptions">
|
<SettingsGroup label="Subscriptions" testid="group-subscriptions">
|
||||||
<div data-testid="group-chatgpt"><ChatGPTConnection /></div>
|
<SubscriptionConnections />
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
|
||||||
{(["Dictation", "Search"] as const).map((label) => <SettingsGroup key={label} label={label} testid={label === "Dictation" ? "group-dictation" : "group-search"}>
|
{(["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 { useEffect, useRef, useState } from "react";
|
||||||
import type { ModelCatalogEntry } from "@/src/core";
|
import type { ModelCatalogEntry } from "@/src/core";
|
||||||
import { fuzzyScore } from "@/src/core";
|
import { fuzzyScore, subscriptionLabel } from "@/src/core";
|
||||||
import { formatTokens } from "@/components/usage-format";
|
import { formatTokens } from "@/components/usage-format";
|
||||||
|
|
||||||
function priceLabel(cost: ModelCatalogEntry["cost"]): string {
|
function priceLabel(cost: ModelCatalogEntry["cost"]): string {
|
||||||
@@ -125,12 +125,12 @@ export function ModelMenuList({
|
|||||||
>
|
>
|
||||||
<span className="flex items-center justify-between gap-2">
|
<span className="flex items-center justify-between gap-2">
|
||||||
<span className="truncate text-card-foreground">{m.name}</span>
|
<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">
|
<span className="shrink-0 tabular-nums text-muted-foreground" title={subscriptionLabel(`${m.provider}:${m.id}`) ? "Subscription connection" : "USD per 1M tokens in/out"}>
|
||||||
{m.provider === "chatgpt" ? "ChatGPT plan" : priceLabel(m.cost)}
|
{subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center justify-between gap-2 text-muted-foreground">
|
<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 className="shrink-0 tabular-nums">{formatTokens(m.contextWindow)}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { Hint } from "@/components/ui/hint";
|
|||||||
import { AppearanceSection, GraphSection } from "./appearance-section.tsx";
|
import { AppearanceSection, GraphSection } from "./appearance-section.tsx";
|
||||||
import { KeyboardSection } from "./keyboard-section.tsx";
|
import { KeyboardSection } from "./keyboard-section.tsx";
|
||||||
import { ChatGPTUIProvider } from "./chatgpt-connection.tsx";
|
import { ChatGPTUIProvider } from "./chatgpt-connection.tsx";
|
||||||
|
import { NativeSubscriptionUIProvider } from "./subscription-connections.tsx";
|
||||||
|
|
||||||
/** Owner (2026-09-20): hints earn their place — mechanics-explaining
|
/** Owner (2026-09-20): hints earn their place — mechanics-explaining
|
||||||
* prose is out; only the keyboard note stays (storage location is a
|
* prose is out; only the keyboard note stays (storage location is a
|
||||||
@@ -92,12 +93,12 @@ export function SettingsDialogProvider({
|
|||||||
setShow(true);
|
setShow(true);
|
||||||
}, []);
|
}, []);
|
||||||
return (
|
return (
|
||||||
<ChatGPTUIProvider><DialogContext.Provider value={{ open }}>
|
<ChatGPTUIProvider><NativeSubscriptionUIProvider><DialogContext.Provider value={{ open }}>
|
||||||
{children}
|
{children}
|
||||||
{show && (
|
{show && (
|
||||||
<SettingsDialog onClose={() => setShow(false)} initialSection={landOn} initialRow={landOnRow} />
|
<SettingsDialog onClose={() => setShow(false)} initialSection={landOn} initialRow={landOnRow} />
|
||||||
)}
|
)}
|
||||||
</DialogContext.Provider></ChatGPTUIProvider>
|
</DialogContext.Provider></NativeSubscriptionUIProvider></ChatGPTUIProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"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, ConnectionFooter, 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 [view, setView] = useState<"notice" | "connection" | "manage">("notice");
|
||||||
|
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 management = view === "manage";
|
||||||
|
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);
|
||||||
|
const current = statusesRef.current.find((s) => s.providerId === providerId);
|
||||||
|
setBackground(current?.allowBackgroundUse ?? true); setView(current?.connectionId ? "manage" : "notice"); 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);
|
||||||
|
}
|
||||||
|
function beginSignIn() {
|
||||||
|
if (operation || busy || !status) return;
|
||||||
|
generation.current++; setError(null); setConfirm(false); setBackground(status.allowBackgroundUse); setBackgroundDirty(false); setView("notice");
|
||||||
|
}
|
||||||
|
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 key={`${dialog}:${view}:${confirm}`} label={`${descriptor.label} connection`} testid="subscription-connection-dialog" backdropTestid="subscription-dialog-backdrop" busy={busy} onClose={close}>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">{descriptor.label}</h2>
|
||||||
|
{confirm ? <>
|
||||||
|
<p className="mt-3 text-sm font-semibold text-red-400">Disconnect {descriptor.label}?</p>
|
||||||
|
<p className="mt-3 text-xs leading-relaxed text-muted-foreground">{subscriptionDisconnectCopy(descriptor.label)}</p>
|
||||||
|
</> : view === "notice" ? <>
|
||||||
|
<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={management ? status.allowBackgroundUse : background} disabled={busy || !!operation || status.pending || !status.available} onChange={(enabled) => { setBackground(enabled); if (management && 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>}
|
||||||
|
{view === "connection" && 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>}
|
||||||
|
<ConnectionFooter testid="subscription-connection-footer" leading={!confirm && management && status.connectionId && !status.pending && <Button variant="ghost" size="sm" className="cursor-pointer text-red-400 hover:bg-red-500/10 hover:text-red-300" disabled={busy || !!operation} onClick={() => setConfirm(true)}>Disconnect</Button>}>
|
||||||
|
{!confirm && management && status.connectionId && status.available && !status.connected && !!status.error && !status.pending && <>
|
||||||
|
{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={beginSignIn}>Sign in again</Button>
|
||||||
|
</>}
|
||||||
|
<Button variant={!confirm && management ? "outline" : "ghost"} size="sm" disabled={busy} onClick={close}>{confirm || !management ? "Cancel" : "Close"}</Button>
|
||||||
|
{confirm ? <Button variant="destructive" size="sm" disabled={busy} onClick={() => bridge && void control(() => bridge.disconnect(dialog), true)}>Disconnect</Button> : view === "notice" ? <Button variant="outline" size="sm" disabled={!status.available || !!operation} onClick={() => setView("connection")}>Continue</Button> : view === "connection" && <Button variant="outline" size="sm" disabled={!status.available || !!operation || status.pending} onClick={() => void login()}>Connect</Button>}
|
||||||
|
</ConnectionFooter>
|
||||||
|
</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.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 { Hint } from "@/components/ui/hint";
|
||||||
import { type ProviderKeyInfo } from "@/components/settings/keys-section";
|
import { type ProviderKeyInfo } from "@/components/settings/keys-section";
|
||||||
import { ProviderMenuList } from "@/components/settings/model-menu";
|
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 { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
|
||||||
import { ThinkingMenuList } from "@/components/settings/model-menu";
|
import { ThinkingMenuList } from "@/components/settings/model-menu";
|
||||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||||
@@ -211,7 +212,7 @@ export function SetupKeysBody({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -477,7 +478,7 @@ export function SetupDialog({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground/70">{SLOT_META[slot].roster}</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="flex items-center gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const STORAGE_KEY = "lh:sidebar-open";
|
|||||||
*
|
*
|
||||||
* Mirrors SIDEBAR_AUTOCLOSE in electron/main.cjs — keep in sync.
|
* 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;
|
const SIDEBAR_AUTOCLOSE = 928;
|
||||||
|
|
||||||
// Module scope, guarded for SSR (the server has no navigator).
|
// 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. */
|
/** Read-only host OS; determines the native-control gutter. */
|
||||||
platform?: string;
|
platform?: string;
|
||||||
chatgpt?: ChatGPTControlBridge;
|
chatgpt?: ChatGPTControlBridge;
|
||||||
|
subscriptions?: NativeSubscriptionControlBridge;
|
||||||
expandWindow: () => void;
|
expandWindow: () => void;
|
||||||
/** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */
|
/** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */
|
||||||
onZoom?: (cb: (factor: number) => void) => () => void;
|
onZoom?: (cb: (factor: number) => void) => () => void;
|
||||||
|
|||||||
+31
-29
@@ -1,10 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import { contextWindow, sessionCost, turnCost, type ChatSettings, type TurnUsage } from "@/src/core";
|
import { subscriptionLabels, subscriptionLabel, type SessionPricing, type TurnUsage } from "@/src/core";
|
||||||
import { formatTokens, formatUsd, prettyModel } from "@/components/usage-format";
|
import { formatTokens, formatUsd, planCostLabel, prettyModel } from "@/components/usage-format";
|
||||||
import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker";
|
import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker";
|
||||||
|
|
||||||
|
export function formatContextMeasurement(tokens: number | null, limit: number | null): string {
|
||||||
|
const numerator = tokens === null ? "?" : formatTokens(tokens);
|
||||||
|
return limit === null ? numerator : `${numerator} / ${formatTokens(limit)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The below-composer stats bar (wave 4 observability): the session's
|
* The below-composer stats bar (wave 4 observability): the session's
|
||||||
* money/context footprint as one quiet row beneath the composer —
|
* money/context footprint as one quiet row beneath the composer —
|
||||||
@@ -16,23 +21,23 @@ import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model
|
|||||||
* there died with the injection; a "— · —" summary over an empty
|
* there died with the injection; a "— · —" summary over an empty
|
||||||
* popover is pure noise) — the dictation strip's middle simply widens.
|
* popover is pure noise) — the dictation strip's middle simply widens.
|
||||||
*
|
*
|
||||||
* All folds come from core/prices.ts (sessionCost, contextWindow, turnCost)
|
* Spending arrives already priced by the server through Pi's catalog and
|
||||||
* — no cost math lives here. Absent data renders "—", never zero (the
|
* calculator. Missing cost stays "—", never fake zero. Context is a separate
|
||||||
* doctrine): no turns yet → every figure is "—"; turns the price table
|
* completed-conversation measurement, never a money-log/summarizer fold or
|
||||||
* can't price count as unpriced, shown in the popover, never folded into
|
* transcript estimate. Its catalog denominator follows the selected model;
|
||||||
* a fake $0. The RIGHT side is the model + thinking control (components/
|
* model changes preserve the numerator. Successful compaction clears it to ?.
|
||||||
* model-picker.tsx): one controller for the conversation agents, backed by
|
|
||||||
* pi-ai's catalog; the buttons show the EFFECTIVE model (the record's
|
|
||||||
* settings, else config's pin), while the left-side figures still fold the
|
|
||||||
* turn log's actual billed models.
|
|
||||||
*/
|
*/
|
||||||
export function StatsBar({
|
export function StatsBar({
|
||||||
turns,
|
turns,
|
||||||
|
contextTokens = null,
|
||||||
|
pricing,
|
||||||
dictationSlot,
|
dictationSlot,
|
||||||
chatId,
|
chatId,
|
||||||
leadingSlot,
|
leadingSlot,
|
||||||
}: {
|
}: {
|
||||||
turns: TurnUsage[];
|
turns: TurnUsage[];
|
||||||
|
contextTokens?: number | null;
|
||||||
|
pricing?: SessionPricing;
|
||||||
/** The bound session (per-session models, owner 2026-09-28): present
|
/** The bound session (per-session models, owner 2026-09-28): present
|
||||||
* on the in-session bar — the model control binds THE SESSION'S own
|
* on the in-session bar — the model control binds THE SESSION'S own
|
||||||
* record; absent on the draft surfaces, where it binds the strong
|
* record; absent on the draft surfaces, where it binds the strong
|
||||||
@@ -49,6 +54,7 @@ export function StatsBar({
|
|||||||
leadingSlot?: ReactNode;
|
leadingSlot?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [selectedModel, setSelectedModel] = useState<string | undefined>();
|
||||||
// Same dropdown discipline as the model control (owner, df4433f treatment,
|
// Same dropdown discipline as the model control (owner, df4433f treatment,
|
||||||
// stats round 1): close on any pointerdown outside the bar or on Escape.
|
// stats round 1): close on any pointerdown outside the bar or on Escape.
|
||||||
const barRef = useRef<HTMLDivElement>(null);
|
const barRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -73,22 +79,18 @@ export function StatsBar({
|
|||||||
document.removeEventListener("keydown", onKeyDown);
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
// The live-limits join (context-meter amendment, owner 2026-09-23):
|
// Catalog-only denominator; the picker reports its effective selection.
|
||||||
// contextWindow's denominator resolves ONLY from pi's live catalog —
|
// Missing/pending catalog means usage-only, never a hardcoded maximum.
|
||||||
// built client-side over the picker's cached /api/models fetch (the
|
|
||||||
// same fetch ModelControl below rides; module-level dedupe). Catalog
|
|
||||||
// pending or failed → undefined index → limit null → USAGE-ONLY (the
|
|
||||||
// honest-render precedent, never a fake window); the usage-only render
|
|
||||||
// IS the null path.
|
|
||||||
const { catalog } = useModelCatalog();
|
const { catalog } = useModelCatalog();
|
||||||
const limits = useMemo(() => (catalog ? catalogLimits(catalog) : undefined), [catalog]);
|
const limits = useMemo(() => (catalog ? catalogLimits(catalog) : undefined), [catalog]);
|
||||||
const ctx = contextWindow({ turns }, limits);
|
const limit = selectedModel ? limits?.get(selectedModel) ?? null : null;
|
||||||
const { cost, unpriced, subscription = 0 } = sessionCost({ turns });
|
const contextLabel = formatContextMeasurement(contextTokens, limit);
|
||||||
|
const { cost = 0, unpriced = turns.length, subscription = 0 } = pricing ?? {};
|
||||||
// Honest cost: show dollars only when at least one turn priced;
|
// Honest cost: show dollars only when at least one turn priced;
|
||||||
// otherwise "—" (never a fake $0.0000).
|
// otherwise "—" (never a fake $0.0000).
|
||||||
const pricedCount = turns.length - unpriced - subscription;
|
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 lastModel = turns.at(-1)?.model;
|
const costLabel = planCostLabel(pricedCount > 0 ? cost : null, providers);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -110,7 +112,7 @@ export function StatsBar({
|
|||||||
{leadingSlot}
|
{leadingSlot}
|
||||||
{/* The left side: the session-data summary — only once a session
|
{/* The left side: the session-data summary — only once a session
|
||||||
exists (turns). Pre-chat renders nothing (see the header). */}
|
exists (turns). Pre-chat renders nothing (see the header). */}
|
||||||
{turns.length > 0 && (
|
{(chatId || turns.length > 0) && (
|
||||||
<div ref={barRef} className="relative">
|
<div ref={barRef} className="relative">
|
||||||
{/* No tooltip — the numbers describe themselves and the popover
|
{/* No tooltip — the numbers describe themselves and the popover
|
||||||
speaks on click (owner, subtlety round 2026-09-25). */}
|
speaks on click (owner, subtlety round 2026-09-25). */}
|
||||||
@@ -122,7 +124,7 @@ export function StatsBar({
|
|||||||
className="flex items-center gap-1.5 tabular-nums transition-colors hover:text-foreground"
|
className="flex items-center gap-1.5 tabular-nums transition-colors hover:text-foreground"
|
||||||
>
|
>
|
||||||
<span data-testid="stats-context">
|
<span data-testid="stats-context">
|
||||||
{ctx ? (ctx.limit !== null ? `${formatTokens(ctx.usedTokens)} / ${formatTokens(ctx.limit)}` : formatTokens(ctx.usedTokens)) : "—"}
|
{contextLabel}
|
||||||
</span>
|
</span>
|
||||||
<span aria-hidden>·</span>
|
<span aria-hidden>·</span>
|
||||||
<span data-testid="stats-cost">{costLabel}</span>
|
<span data-testid="stats-cost">{costLabel}</span>
|
||||||
@@ -139,14 +141,14 @@ export function StatsBar({
|
|||||||
<div className="flex justify-between gap-2">
|
<div className="flex justify-between gap-2">
|
||||||
<span>Context (last call)</span>
|
<span>Context (last call)</span>
|
||||||
<span className="tabular-nums text-card-foreground">
|
<span className="tabular-nums text-card-foreground">
|
||||||
{ctx ? (ctx.limit !== null ? `${formatTokens(ctx.usedTokens)} / ${formatTokens(ctx.limit)}` : formatTokens(ctx.usedTokens)) : "—"}
|
{contextLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between gap-2">
|
<div className="flex justify-between gap-2">
|
||||||
<span>{subscription ? "Usage" : "Total cost"}</span>
|
<span>{subscription ? "Usage" : "Total cost"}</span>
|
||||||
<span className="tabular-nums text-card-foreground">{costLabel}</span>
|
<span className="tabular-nums text-card-foreground">{costLabel}</span>
|
||||||
</div>
|
</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 && (
|
{unpriced > 0 && (
|
||||||
<div className="flex justify-between gap-2">
|
<div className="flex justify-between gap-2">
|
||||||
<span>Unpriced turns</span>
|
<span>Unpriced turns</span>
|
||||||
@@ -161,7 +163,7 @@ export function StatsBar({
|
|||||||
</div>
|
</div>
|
||||||
<ul className="flex max-h-56 flex-col gap-1 overflow-y-auto">
|
<ul className="flex max-h-56 flex-col gap-1 overflow-y-auto">
|
||||||
{turns.map((turn, i) => {
|
{turns.map((turn, i) => {
|
||||||
const c = turnCost(turn);
|
const c = pricing?.turnCosts[turn.id] ?? null;
|
||||||
return (
|
return (
|
||||||
<li key={`${turn.messageId}-${i}`} className="flex items-center justify-between gap-2">
|
<li key={`${turn.messageId}-${i}`} className="flex items-center justify-between gap-2">
|
||||||
<span className="truncate text-muted-foreground">
|
<span className="truncate text-muted-foreground">
|
||||||
@@ -169,7 +171,7 @@ export function StatsBar({
|
|||||||
</span>
|
</span>
|
||||||
<span className="flex shrink-0 items-center gap-2 tabular-nums text-card-foreground">
|
<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 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>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -190,7 +192,7 @@ export function StatsBar({
|
|||||||
{/* The model + thinking control — one controller for the conversation
|
{/* The model + thinking control — one controller for the conversation
|
||||||
agents (prober/teacher); the record's `turns` above still folds
|
agents (prober/teacher); the record's `turns` above still folds
|
||||||
the ACTUAL models billed, regardless of what the picker says. */}
|
the ACTUAL models billed, regardless of what the picker says. */}
|
||||||
<ModelControl chatId={chatId} />
|
<ModelControl chatId={chatId} onModelChange={setSelectedModel} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ export function formatUsd(c: number): string {
|
|||||||
return c >= 1 ? `$${c.toFixed(2)}` : `$${c.toFixed(4)}`;
|
return c >= 1 ? `$${c.toFixed(2)}` : `$${c.toFixed(4)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function planCostLabel(dollars: number | null, subscription: boolean, format = formatUsd): string {
|
export function planCostLabel(dollars: number | null, subscription: boolean | string | readonly string[] | undefined, format = formatUsd): string {
|
||||||
if (subscription) return dollars === null ? "ChatGPT plan" : `${format(dollars)} API + ChatGPT plan`;
|
const labels = subscription === true ? ["ChatGPT"] : typeof subscription === "string" ? [subscription] : Array.isArray(subscription) ? [...new Set(subscription)] : [];
|
||||||
return dollars === null ? "—" : format(dollars);
|
return [...(dollars === null ? [] : [format(dollars)]), ...labels].join(" + ") || "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 38000 → "38k", 1234567 → "1.2M" — trimmed, never "38.0k". */
|
/** 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
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
subgraph DESKTOP["Electron app (production)"]
|
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"]
|
MAIN -->|"loadURL, sandboxed renderer"| REND["Browser renderer (components/)<br/>fetch/WebSocket to own origin only"]
|
||||||
end
|
end
|
||||||
subgraph OUTSIDE["outside the process"]
|
subgraph OUTSIDE["outside the process"]
|
||||||
@@ -417,10 +417,18 @@ flowchart LR
|
|||||||
REND -->|"fetch /api/*"| NEXT
|
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
|
## 9. The foundational invariants — what ALWAYS holds
|
||||||
|
|
||||||
### Tier A — machine-enforced (`eslint.config.mjs`; lint fails the build)
|
### Tier A — machine-enforced (`eslint.config.mjs`; lint fails the build)
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Compaction and usage accounting
|
||||||
|
|
||||||
|
## Retention
|
||||||
|
|
||||||
|
`src/core/compaction.ts` locally ports the cutoff-selection behavior and relevant
|
||||||
|
message-size heuristic from **@earendil-works/pi-coding-agent 1.4.0**. There is no
|
||||||
|
coding-agent dependency. Upgrade this port deliberately; the native golden
|
||||||
|
fixtures are in `tests/compaction-native-parity.test.ts`.
|
||||||
|
|
||||||
|
Walk backward to the approximately 20,000-token retained-tail budget, then select
|
||||||
|
the nearest valid user/assistant boundary at or after the crossing. Never start
|
||||||
|
at a tool result. If trailing results alone exceed the budget, keep the preceding
|
||||||
|
assistant call. Preserve adjacent context-invisible metadata. LearnHarness adapts
|
||||||
|
its visible custom markers to user-like entries, excludes UI-only markers, and
|
||||||
|
uses the active compaction's retained history rather than discarded messages.
|
||||||
|
|
||||||
|
Text, visible thinking, tool-call names/arguments use characters divided by four;
|
||||||
|
images receive the native fixed 1,200-token allowance. Encrypted signatures are
|
||||||
|
not measured or stripped. Whole-message boundaries can overshoot. This does
|
||||||
|
**not** guarantee a measured 20k tail or a fixed post-compaction request size.
|
||||||
|
|
||||||
|
Only cutoff selection is ported. LearnHarness retains its educational summary
|
||||||
|
prompt, plan continuity, persistence, threshold, scheduling, and authorization
|
||||||
|
lifecycle. It does not adopt Pi's coding summary or separate split-turn summary
|
||||||
|
calls. A cut that drops no active history does not launch a summarizer.
|
||||||
|
|
||||||
|
## Conversation meter
|
||||||
|
|
||||||
|
The numerator is the latest qualifying completed conversation response's native
|
||||||
|
Pi `input + output + cacheRead + cacheWrite`. Reasoning is already inside output;
|
||||||
|
`totalTokens` is not an override. Partial stream updates, errors, aborts, invalid
|
||||||
|
counts, and all-zero usage do not replace the last measurement. A successful
|
||||||
|
compaction marker invalidates earlier measurements: show `? / maximum` until a
|
||||||
|
new qualifying response completes. Summarizer usage never supplies this meter.
|
||||||
|
|
||||||
|
The denominator follows the selected model's catalog limit, independently of the
|
||||||
|
last numeric measurement. Changing models preserves that numerator. No catalog
|
||||||
|
limit means no invented denominator; no percentage display is added.
|
||||||
|
|
||||||
|
## Spending
|
||||||
|
|
||||||
|
`src/server/pricing.ts` uses Pi AI's full, key-independent catalog and public
|
||||||
|
`calculateCost` on a new usage object. The browser receives derived costs, not a
|
||||||
|
second calculator/rate table. Totals are estimates using the currently shipped
|
||||||
|
catalog, not provider invoices; catalog updates can reprice history.
|
||||||
|
|
||||||
|
Saved input is Pi-normalized **fresh input**, disjoint from cache buckets. Never
|
||||||
|
subtract cached reads from it again or migrate old logs as raw inclusive prompt
|
||||||
|
tokens. New captures preserve the one-hour subset of cache writes and individual
|
||||||
|
background-job calls. Older write-bearing records with a lost retention split,
|
||||||
|
and tier-priced multi-call jobs with only aggregate usage, remain unpriced when
|
||||||
|
the lost detail can change the answer. They are not reconstructed from totals.
|
||||||
|
Flat-rate historical jobs with sufficient counts remain priceable. Missing models
|
||||||
|
or required usage stay unpriced; ChatGPT subscriptions remain plan usage, not
|
||||||
|
API dollars. Summarizer costs continue to count toward session spending.
|
||||||
|
|
||||||
|
## Upstream attribution
|
||||||
|
|
||||||
|
The cutoff/estimator port adapts MIT-licensed code from
|
||||||
|
`@earendil-works/pi-coding-agent` 1.4.0, author Mario Zechner (published package
|
||||||
|
metadata). The inspected `dist/core/compaction/compaction.js` SHA-256 is
|
||||||
|
`f51e78ef1da66b5803fc91e185f07b968f8f989ab9326b7744958416aad3f6f6`.
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) Mario Zechner
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
+5
-2
@@ -30,7 +30,6 @@ npm run desktop:dist # local signed/notarized macOS arm64 build (LH_NOTARY key
|
|||||||
npx tsx --env-file=.env.local scripts/import-notes.ts <dir> # bulk-import notes → graph
|
npx tsx --env-file=.env.local scripts/import-notes.ts <dir> # bulk-import notes → graph
|
||||||
npx tsx --env-file=.env.local scripts/backfill-graph.ts # wipe + re-extract every chat
|
npx tsx --env-file=.env.local scripts/backfill-graph.ts # wipe + re-extract every chat
|
||||||
npx tsx --env-file=.env.local scripts/replay-layout.ts # re-derive layout from the ledgers
|
npx tsx --env-file=.env.local scripts/replay-layout.ts # re-derive layout from the ledgers
|
||||||
npx tsx scripts/sync-pricing.ts # regenerate core/prices.ts tables
|
|
||||||
```
|
```
|
||||||
|
|
||||||
For Windows/Linux packages and the three-OS CI/release flow, see [`desktop-release.md`](desktop-release.md). Native renderer binaries are traced per platform, so release builds run on the target OS, not as cross-builds from macOS.
|
For Windows/Linux packages and the three-OS CI/release flow, see [`desktop-release.md`](desktop-release.md). Native renderer binaries are traced per platform, so release builds run on the target OS, not as cross-builds from macOS.
|
||||||
@@ -144,7 +143,7 @@ src/
|
|||||||
electron/ desktop launcher + security guard (spawns the standalone
|
electron/ desktop launcher + security guard (spawns the standalone
|
||||||
server; 1 IPC channel; LH_DATA_DIR + BYOK env)
|
server; 1 IPC channel; LH_DATA_DIR + BYOK env)
|
||||||
scripts/ build-server.mjs (packaging guards), import-notes,
|
scripts/ build-server.mjs (packaging guards), import-notes,
|
||||||
backfill-graph, replay-layout, sync-pricing
|
backfill-graph, replay-layout
|
||||||
data/ chats/ · graph/ (nodes/chains/sessions/coverage
|
data/ chats/ · graph/ (nodes/chains/sessions/coverage
|
||||||
ledgers + positions/sources/render-settings maps) ·
|
ledgers + positions/sources/render-settings maps) ·
|
||||||
jobs/ · settings.json — runtime state, git-ignored
|
jobs/ · settings.json — runtime state, git-ignored
|
||||||
@@ -152,6 +151,10 @@ data/ chats/ · graph/ (nodes/chains/sessions/coverage
|
|||||||
|
|
||||||
## Key mechanisms
|
## Key mechanisms
|
||||||
|
|
||||||
|
See [compaction and accounting](compaction-accounting.md) for the local Pi cutoff port,
|
||||||
|
measurement-only context meter, and server-side current-catalog pricing. There is no
|
||||||
|
pricing-generation step: Pi AI's catalog and public calculator are the authority.
|
||||||
|
|
||||||
**Client-side question tools.** `quiz` and `ask_user_question` have no server `execute`: the tool call streams to the browser, the learner answers in the dock, the client grades via `question-core` and returns the verdict with `addToolOutput` — which fires the SDK continuation so the teacher resumes mid-turn. Options are shuffled deterministically (seeded by toolCallId) so server and client renders match. A pending quiz persisted in the chat record is exactly what makes **resume mid-question free**.
|
**Client-side question tools.** `quiz` and `ask_user_question` have no server `execute`: the tool call streams to the browser, the learner answers in the dock, the client grades via `question-core` and returns the verdict with `addToolOutput` — which fires the SDK continuation so the teacher resumes mid-turn. Options are shuffled deterministically (seeded by toolCallId) so server and client renders match. A pending quiz persisted in the chat record is exactly what makes **resume mid-question free**.
|
||||||
|
|
||||||
**The formal plan tree (author-once).** The teacher authors the session's dependency tree with one schema-validated tool — `plan_init`, whose mermaid-subset input is parsed deterministically (line-numbered errors) and validated as a DAG. The tree persists in the chat record; progress is never mutated — `current`/`visited`/upcoming are **derived** from tagged quiz calls in the transcript, and the panel renders that derivation.
|
**The formal plan tree (author-once).** The teacher authors the session's dependency tree with one schema-validated tool — `plan_init`, whose mermaid-subset input is parsed deterministically (line-numbered errors) and validated as a DAG. The tree persists in the chat record; progress is never mutated — `current`/`visited`/upcoming are **derived** from tagged quiz calls in the transcript, and the panel renders that derivation.
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ files:
|
|||||||
# Main-process ID-token signature verification (no bundled OAuth implementation).
|
# Main-process ID-token signature verification (no bundled OAuth implementation).
|
||||||
- node_modules/jose/**/*
|
- node_modules/jose/**/*
|
||||||
extraResources:
|
extraResources:
|
||||||
|
# Attribution/license for the bounded local Pi cutoff-selector port.
|
||||||
|
- from: docs/compaction-accounting.md
|
||||||
|
to: licenses/pi-compaction.md
|
||||||
# Copy from the PARENT dir (.next), not .next/standalone itself:
|
# Copy from the PARENT dir (.next), not .next/standalone itself:
|
||||||
# electron-builder hard-excludes a copy's root node_modules and the
|
# electron-builder hard-excludes a copy's root node_modules and the
|
||||||
# default no-filter pattern is a literal path match (empirically both
|
# default no-filter pattern is a literal path match (empirically both
|
||||||
@@ -97,6 +100,11 @@ extraResources:
|
|||||||
filter:
|
filter:
|
||||||
- "standalone/**/*"
|
- "standalone/**/*"
|
||||||
mac:
|
mac:
|
||||||
|
# The backend's --require runs before Next loads. Keep this resource
|
||||||
|
# outside app.asar so early Node-mode loading needs no ASAR support.
|
||||||
|
extraResources:
|
||||||
|
- from: electron/server-title-guard.cjs
|
||||||
|
to: server-title-guard.cjs
|
||||||
icon: build/icon.icon
|
icon: build/icon.icon
|
||||||
# zip rides next to dmg (flipped 2026-10-08): Squirrel.Mac — Electron's
|
# zip rides next to dmg (flipped 2026-10-08): Squirrel.Mac — Electron's
|
||||||
# macOS auto-updater — installs from ZIP and cannot read DMGs. DMG is
|
# macOS auto-updater — installs from ZIP and cannot read DMGs. DMG is
|
||||||
|
|||||||
+14
-28
@@ -19,8 +19,7 @@ const fail = (code) => Object.assign(new Error(code), { code });
|
|||||||
const publicError = (err) => ({
|
const publicError = (err) => ({
|
||||||
cancelled: "ChatGPT sign-in cancelled.",
|
cancelled: "ChatGPT sign-in cancelled.",
|
||||||
timeout: "ChatGPT sign-in timed out. Try again.",
|
timeout: "ChatGPT sign-in timed out. Try again.",
|
||||||
secure_storage: "Secure credential storage is unavailable. ChatGPT sign-in requires the desktop app and an OS secure-storage backend.",
|
storage: "ChatGPT credentials could not be read or saved. Check local file access, then reconnect before making requests.",
|
||||||
storage: "ChatGPT credentials could not be saved securely. Reconnect before making requests.",
|
|
||||||
identity: "ChatGPT sign-in could not be verified. Select the intended saved account and try again.",
|
identity: "ChatGPT sign-in could not be verified. Select the intended saved account and try again.",
|
||||||
scope: "ChatGPT plan permission is missing. Reconnect and authorize ChatGPT plan use.",
|
scope: "ChatGPT plan permission is missing. Reconnect and authorize ChatGPT plan use.",
|
||||||
disconnected: "This ChatGPT connection is disconnected. Reconnect in Providers.",
|
disconnected: "This ChatGPT connection is disconnected. Reconnect in Providers.",
|
||||||
@@ -34,7 +33,7 @@ const publicError = (err) => ({
|
|||||||
|
|
||||||
const nonempty = (v) => typeof v === "string" && v.length > 0 && !/[\x00-\x20\x7f]/.test(v);
|
const nonempty = (v) => typeof v === "string" && v.length > 0 && !/[\x00-\x20\x7f]/.test(v);
|
||||||
const planScopes = (tokens) => !!tokens?.accessToken && SCOPES.every((scope) => tokens.scopes.includes(scope));
|
const planScopes = (tokens) => !!tokens?.accessToken && SCOPES.every((scope) => tokens.scopes.includes(scope));
|
||||||
/** Reject the entire encrypted record. Encryption is not schema validation. */
|
/** Reject the entire credential record when its schema is invalid. */
|
||||||
function validateState(state) {
|
function validateState(state) {
|
||||||
if (!state || state.version !== 1 || !nonempty(state.hostId) || !Array.isArray(state.registrations) || !(state.activeId === null || nonempty(state.activeId))) throw fail("storage");
|
if (!state || state.version !== 1 || !nonempty(state.hostId) || !Array.isArray(state.registrations) || !(state.activeId === null || nonempty(state.activeId))) throw fail("storage");
|
||||||
if (Object.hasOwn(state, "allowBackgroundUse") && typeof state.allowBackgroundUse !== "boolean") throw fail("storage");
|
if (Object.hasOwn(state, "allowBackgroundUse") && typeof state.allowBackgroundUse !== "boolean") throw fail("storage");
|
||||||
@@ -51,35 +50,25 @@ function validateState(state) {
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Encrypted envelope only, outside learning data. No plaintext fallback. */
|
/** Private, unencrypted JSON outside learning data. No OS credential-store access. */
|
||||||
function encryptedStore(file, safeStorage) {
|
function fileStore(file) {
|
||||||
function secure() {
|
|
||||||
if (!safeStorage.isEncryptionAvailable() || safeStorage.getSelectedStorageBackend?.() === "basic_text") throw fail("secure_storage");
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
secure,
|
|
||||||
async read() {
|
async read() {
|
||||||
secure();
|
|
||||||
try {
|
try {
|
||||||
const envelope = JSON.parse(await fs.readFile(file, "utf8"));
|
return validateState(JSON.parse(await fs.readFile(file, "utf8")));
|
||||||
if (envelope.version !== 1 || typeof envelope.encrypted !== "string" || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(envelope.encrypted) || !envelope.encrypted) throw fail("storage");
|
|
||||||
const state = JSON.parse(safeStorage.decryptString(Buffer.from(envelope.encrypted, "base64")));
|
|
||||||
return validateState(state);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.code === "ENOENT") return null;
|
if (err.code === "ENOENT") return null;
|
||||||
throw fail("storage");
|
throw fail("storage");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async write(state) {
|
async write(state) {
|
||||||
secure();
|
|
||||||
validateState(state);
|
validateState(state);
|
||||||
const temp = `${file}.${crypto.randomUUID()}.tmp`;
|
const temp = `${file}.${crypto.randomUUID()}.tmp`;
|
||||||
let handle;
|
let handle;
|
||||||
try {
|
try {
|
||||||
const encrypted = safeStorage.encryptString(JSON.stringify(state)).toString("base64");
|
|
||||||
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||||
handle = await fs.open(temp, "wx", 0o600);
|
handle = await fs.open(temp, "wx", 0o600);
|
||||||
await handle.writeFile(JSON.stringify({ version: 1, encrypted }));
|
await handle.writeFile(`${JSON.stringify(state)}\n`);
|
||||||
await handle.sync();
|
await handle.sync();
|
||||||
await handle.close();
|
await handle.close();
|
||||||
handle = null;
|
handle = null;
|
||||||
@@ -115,7 +104,6 @@ function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch,
|
|||||||
let authorityRevision = 0;
|
let authorityRevision = 0;
|
||||||
let catalogSequence = 0;
|
let catalogSequence = 0;
|
||||||
let storageDenied = false;
|
let storageDenied = false;
|
||||||
let secureAvailable = true;
|
|
||||||
const stamp = () => ({ epoch, authorityRevision, revision });
|
const stamp = () => ({ epoch, authorityRevision, revision });
|
||||||
let backgroundIntent;
|
let backgroundIntent;
|
||||||
let pending;
|
let pending;
|
||||||
@@ -174,17 +162,13 @@ function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch,
|
|||||||
state = first;
|
state = first;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
try { await init; checkSecure(); } catch (err) { denyStorage(err); throw err; }
|
try { await init; } catch (err) { denyStorage(err); throw err; }
|
||||||
}
|
}
|
||||||
function denyStorage(err) {
|
function denyStorage(err) {
|
||||||
secureAvailable = false;
|
|
||||||
if (!storageDenied) { storageDenied = true; lastError = publicError(err); stop(); }
|
if (!storageDenied) { storageDenied = true; lastError = publicError(err); stop(); }
|
||||||
}
|
}
|
||||||
function checkSecure() {
|
|
||||||
try { store.secure(); secureAvailable = true; } catch (err) { denyStorage(err); throw err; }
|
|
||||||
}
|
|
||||||
async function save(next) {
|
async function save(next) {
|
||||||
try { checkSecure(); validateState(next); await store.write(next); checkSecure(); } catch (err) {
|
try { validateState(next); await store.write(next); } catch (err) {
|
||||||
// A rotated token may already have invalidated its predecessor. Fail closed.
|
// A rotated token may already have invalidated its predecessor. Fail closed.
|
||||||
denyStorage(err);
|
denyStorage(err);
|
||||||
lastError = publicError(err);
|
lastError = publicError(err);
|
||||||
@@ -195,7 +179,7 @@ function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch,
|
|||||||
}
|
}
|
||||||
function snapshot() {
|
function snapshot() {
|
||||||
return {
|
return {
|
||||||
available: !!state && secureAvailable && !closed && !paused,
|
available: !!state && !closed && !paused,
|
||||||
pending: !!pending,
|
pending: !!pending,
|
||||||
allowBackgroundUse: backgroundAllowed(),
|
allowBackgroundUse: backgroundAllowed(),
|
||||||
activeId: state?.activeId ?? null,
|
activeId: state?.activeId ?? null,
|
||||||
@@ -210,6 +194,9 @@ function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch,
|
|||||||
registration: r.clientId,
|
registration: r.clientId,
|
||||||
connected: !storageDenied && !closed && !paused && !suspended && !r.reconnectRequired && planScopes(r.tokens),
|
connected: !storageDenied && !closed && !paused && !suspended && !r.reconnectRequired && planScopes(r.tokens),
|
||||||
planEnabled: !storageDenied && !r.reconnectRequired && planScopes(r.tokens),
|
planEnabled: !storageDenied && !r.reconnectRequired && planScopes(r.tokens),
|
||||||
|
// Recovery is authorization-only, not a response to storage failure
|
||||||
|
// or temporary transport suspension. Never infer it from error copy.
|
||||||
|
canSignInAgain: !storageDenied && !closed && !paused && (r.reconnectRequired === true || !planScopes(r.tokens)),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
error: lastError,
|
error: lastError,
|
||||||
@@ -331,7 +318,7 @@ function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch,
|
|||||||
next.registrations = next.registrations.filter((r) => r.id !== record.id).concat(record);
|
next.registrations = next.registrations.filter((r) => r.id !== record.id).concat(record);
|
||||||
next.activeId = record.id;
|
next.activeId = record.id;
|
||||||
const previous = structuredClone(state);
|
const previous = structuredClone(state);
|
||||||
await save(next); // Active identity changes only AFTER validation and durable encrypted persistence.
|
await save(next); // Active identity changes only AFTER validation and durable persistence.
|
||||||
if (closed || abort.signal.aborted || epoch !== attemptEpoch) {
|
if (closed || abort.signal.aborted || epoch !== attemptEpoch) {
|
||||||
await save(previous);
|
await save(previous);
|
||||||
throw fail("cancelled");
|
throw fail("cancelled");
|
||||||
@@ -365,7 +352,6 @@ function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch,
|
|||||||
function cancelLogin() { pending?.cancel(); return snapshot(); }
|
function cancelLogin() { pending?.cancel(); return snapshot(); }
|
||||||
|
|
||||||
function authorized(background, expected, capturedEpoch) {
|
function authorized(background, expected, capturedEpoch) {
|
||||||
checkSecure();
|
|
||||||
const account = active();
|
const account = active();
|
||||||
if (!account || (capturedEpoch !== undefined && capturedEpoch !== epoch)) throw fail("disconnected");
|
if (!account || (capturedEpoch !== undefined && capturedEpoch !== epoch)) throw fail("disconnected");
|
||||||
if (!planScopes(account.tokens)) throw fail("scope");
|
if (!planScopes(account.tokens)) throw fail("scope");
|
||||||
@@ -553,4 +539,4 @@ function createChatGPT({ store, openExternal, fetch: request = globalThis.fetch,
|
|||||||
return { status, login, cancelLogin, lease, models, select, consent, disconnect, logout, close, pause, resume, stamp };
|
return { status, login, cancelLogin, lease, models, select, consent, disconnect, logout, close, pause, resume, stamp };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createChatGPT, encryptedStore, publicError, allowedAuthEndpoint, SCOPES, validateState };
|
module.exports = { createChatGPT, fileStore, publicError, allowedAuthEndpoint, SCOPES, validateState };
|
||||||
|
|||||||
+38
-5
@@ -24,9 +24,11 @@
|
|||||||
* would shadow every settings-page key change until restart.
|
* would shadow every settings-page key change until restart.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog, safeStorage } = require("electron");
|
const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog } = require("electron");
|
||||||
const { createChatGPT, encryptedStore } = require("./chatgpt.cjs");
|
const { createChatGPT, fileStore } = require("./chatgpt.cjs");
|
||||||
const { attachChatGPTChild, trustedSender, chatGPTControl } = require("./chatgpt-ipc.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 { desktopAPIHeaders } = require("./desktop-api.cjs");
|
||||||
const { randomBytes } = require("node:crypto");
|
const { randomBytes } = require("node:crypto");
|
||||||
const { spawn } = require("node:child_process");
|
const { spawn } = require("node:child_process");
|
||||||
@@ -134,6 +136,8 @@ if (process.argv.includes("--browser-service")) {
|
|||||||
let serverProcess = null;
|
let serverProcess = null;
|
||||||
let chatgpt = null;
|
let chatgpt = null;
|
||||||
let chatgptChild = null;
|
let chatgptChild = null;
|
||||||
|
let subscriptions = null;
|
||||||
|
let subscriptionChild = null;
|
||||||
// Per-launch request capability, never OAuth tokens and never renderer state.
|
// Per-launch request capability, never OAuth tokens and never renderer state.
|
||||||
let desktopAPISecret = null;
|
let desktopAPISecret = null;
|
||||||
const trustedAppWindows = new Set();
|
const trustedAppWindows = new Set();
|
||||||
@@ -196,7 +200,11 @@ function startServer(port) {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn(
|
const child = spawn(
|
||||||
process.execPath,
|
process.execPath,
|
||||||
[serverEntry()],
|
// Only the macOS backend needs this: Next's native title setter
|
||||||
|
// otherwise checks this Node-mode process in as a foreground app.
|
||||||
|
process.platform === "darwin"
|
||||||
|
? ["--require", path.join(process.resourcesPath, "server-title-guard.cjs"), serverEntry()]
|
||||||
|
: [serverEntry()],
|
||||||
{
|
{
|
||||||
cwd: path.dirname(serverEntry()),
|
cwd: path.dirname(serverEntry()),
|
||||||
env: {
|
env: {
|
||||||
@@ -218,6 +226,7 @@ function startServer(port) {
|
|||||||
);
|
);
|
||||||
serverProcess = child;
|
serverProcess = child;
|
||||||
chatgptChild = attachChatGPTChild(child, chatgpt);
|
chatgptChild = attachChatGPTChild(child, chatgpt);
|
||||||
|
subscriptionChild = attachSubscriptionChild(child, subscriptions);
|
||||||
const fwd = (buf) => buf.toString().trimEnd().split("\n").forEach((l) => l && log(`server: ${l}`));
|
const fwd = (buf) => buf.toString().trimEnd().split("\n").forEach((l) => l && log(`server: ${l}`));
|
||||||
child.stdout.on("data", fwd);
|
child.stdout.on("data", fwd);
|
||||||
child.stderr.on("data", fwd);
|
child.stderr.on("data", fwd);
|
||||||
@@ -226,7 +235,10 @@ function startServer(port) {
|
|||||||
serverProcess = null;
|
serverProcess = null;
|
||||||
chatgptChild?.dispose();
|
chatgptChild?.dispose();
|
||||||
chatgptChild = null;
|
chatgptChild = null;
|
||||||
|
subscriptionChild?.dispose();
|
||||||
|
subscriptionChild = null;
|
||||||
chatgpt?.close();
|
chatgpt?.close();
|
||||||
|
subscriptions?.close();
|
||||||
});
|
});
|
||||||
resolve(child);
|
resolve(child);
|
||||||
});
|
});
|
||||||
@@ -251,6 +263,7 @@ function waitUntilUp(url, tries = 150) {
|
|||||||
|
|
||||||
function killServer() {
|
function killServer() {
|
||||||
chatgpt?.close();
|
chatgpt?.close();
|
||||||
|
subscriptions?.close();
|
||||||
if (!serverProcess) return;
|
if (!serverProcess) return;
|
||||||
const child = serverProcess;
|
const child = serverProcess;
|
||||||
serverProcess = null;
|
serverProcess = null;
|
||||||
@@ -565,6 +578,7 @@ function installMenu() {
|
|||||||
async function main() {
|
async function main() {
|
||||||
app.on("window-all-closed", () => {
|
app.on("window-all-closed", () => {
|
||||||
chatgpt?.pause();
|
chatgpt?.pause();
|
||||||
|
subscriptions?.pause();
|
||||||
// macOS convention: the app stays resident, reactivated from the Dock.
|
// macOS convention: the app stays resident, reactivated from the Dock.
|
||||||
if (process.platform !== "darwin") app.quit();
|
if (process.platform !== "darwin") app.quit();
|
||||||
});
|
});
|
||||||
@@ -589,13 +603,23 @@ async function main() {
|
|||||||
callback({ requestHeaders: desktopAPIHeaders(details, ownedOrigin, desktopAPISecret, [...trustedAppWindows]) });
|
callback({ requestHeaders: desktopAPIHeaders(details, ownedOrigin, desktopAPISecret, [...trustedAppWindows]) });
|
||||||
});
|
});
|
||||||
chatgpt = createChatGPT({
|
chatgpt = createChatGPT({
|
||||||
store: encryptedStore(path.join(app.getPath("userData"), "chatgpt-credentials.enc.json"), safeStorage),
|
store: fileStore(path.join(app.getPath("userData"), "chatgpt-credentials.json")),
|
||||||
openExternal: (target) => shell.openExternal(target),
|
openExternal: (target) => shell.openExternal(target),
|
||||||
onChange: (value) => {
|
onChange: (value) => {
|
||||||
chatgptChild?.send(value);
|
chatgptChild?.send(value);
|
||||||
for (const win of BrowserWindow.getAllWindows()) win.webContents.send("lh:chatgpt:changed");
|
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(`starting server: ${serverEntry()}`);
|
||||||
log(`data dir: ${DATA_DIR()}${fs.existsSync(AUTH_FILE()) ? " · auth.json: present" : " · auth.json: ABSENT (no BYOK keys yet)"}`);
|
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);
|
await startServer(new URL(url).port);
|
||||||
@@ -613,6 +637,15 @@ async function main() {
|
|||||||
return chatGPTControl(command, chatgpt);
|
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();
|
installMenu();
|
||||||
|
|
||||||
// Auto-update (Wave C): wire the updater before the window so the
|
// Auto-update (Wave C): wire the updater before the window so the
|
||||||
@@ -638,7 +671,7 @@ async function main() {
|
|||||||
if (width < target) win.setBounds({ x, y, width: target, height });
|
if (width < target) win.setBounds({ x, y, width: target, height });
|
||||||
});
|
});
|
||||||
app.on("activate", () => {
|
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);
|
createWindow(url);
|
||||||
log(`window open: ${url}`);
|
log(`window open: ${url}`);
|
||||||
|
|||||||
@@ -43,6 +43,21 @@ contextBridge.exposeInMainWorld("lh", {
|
|||||||
return () => ipcRenderer.removeListener("lh:chatgpt:changed", listener);
|
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"),
|
expandWindow: () => ipcRenderer.send("lh:expand-window"),
|
||||||
onZoom: (cb) => {
|
onZoom: (cb) => {
|
||||||
const listener = (_event, factor) => cb(factor);
|
const listener = (_event, factor) => cb(factor);
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Preloaded only into the packaged macOS backend, before Next starts.
|
||||||
|
*
|
||||||
|
* Next assigns process.title. In Electron's Node mode on macOS, the native
|
||||||
|
* setter checks in with Launch Services using the main app bundle, creating
|
||||||
|
* an unwanted foreground application registration. Keep title writes in JS
|
||||||
|
* instead: the native process name stays unchanged while JS can read back
|
||||||
|
* its assigned title. This deliberately does not emulate native truncation
|
||||||
|
* or every coercion detail; assignments use String(value).
|
||||||
|
*
|
||||||
|
* Explicit launch arguments (not NODE_OPTIONS) keep the full-Electron browser
|
||||||
|
* helper untouched. Node-mode fork descendants can inherit this preload.
|
||||||
|
*/
|
||||||
|
if (process.platform === "darwin" && process.env.ELECTRON_RUN_AS_NODE === "1" && process.env.LH_DESKTOP_SERVER === "1") {
|
||||||
|
let title = process.title;
|
||||||
|
Object.defineProperty(process, "title", {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
get: () => title,
|
||||||
|
set: (value) => {
|
||||||
|
title = String(value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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).
|
// per-platform-binary story as the canvas above (one per build).
|
||||||
"node_modules/@resvg/resvg-js-*/**/*",
|
"node_modules/@resvg/resvg-js-*/**/*",
|
||||||
"node_modules/@earendil-works/pi-ai/dist/providers/data/**/*",
|
"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",
|
"node_modules/@earendil-works/pi-agent-core/dist/**/*.json",
|
||||||
// Next 16.3.2 native Turbopack applies unanchored excludes while
|
// Next 16.3.2 native Turbopack applies unanchored excludes while
|
||||||
// traversing modules; **/data/**/* still prunes gcp-metadata and
|
// traversing modules; **/data/**/* still prunes gcp-metadata and
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "learnharness",
|
"name": "learnharness",
|
||||||
"version": "0.3.5",
|
"version": "0.3.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "learnharness",
|
"name": "learnharness",
|
||||||
"version": "0.3.5",
|
"version": "0.3.7",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@earendil-works/pi-agent-core": "1.4.0",
|
"@earendil-works/pi-agent-core": "1.4.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "learnharness",
|
"name": "learnharness",
|
||||||
"version": "0.3.5",
|
"version": "0.3.7",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Amos Blomqvist",
|
"name": "Amos Blomqvist",
|
||||||
|
|||||||
@@ -103,6 +103,29 @@ async function probe(standalone) {
|
|||||||
noNetwork();
|
noNetwork();
|
||||||
console.log(`[smoke-provider-runtime] ${name}: native lazy Google adapter reached onPayload; generation stopped`);
|
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
|
// Resolution/evaluation, not folder-exists checks. This also covers the
|
||||||
// previously missing transitive edge and permits legitimate nested deps.
|
// previously missing transitive edge and permits legitimate nested deps.
|
||||||
for (const name of ["@google/genai", "google-auth-library", "gcp-metadata", "json-bigint", "bignumber.js"]) {
|
for (const name of ["@google/genai", "google-auth-library", "gcp-metadata", "json-bigint", "bignumber.js"]) {
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
/**
|
|
||||||
* Regenerate src/core/prices.ts tables from pi-ai's shipped per-provider
|
|
||||||
* catalogs (providers/data/*.json).
|
|
||||||
*
|
|
||||||
* Usage: npx tsx scripts/sync-pricing.ts
|
|
||||||
*
|
|
||||||
* The catalogs are pi-ai's curated, versioned snapshots — the SAME source
|
|
||||||
* piEngineFor resolves engines from and /api/models serves the picker
|
|
||||||
* from — so the money channel's lookup table can never disagree with the
|
|
||||||
* engine. Fully offline (the JSON ships in node_modules); refreshes happen
|
|
||||||
* by bumping @earendil-works/pi-ai and re-running this script. The tables
|
|
||||||
* are generated; hand edits are overwritten. A model missing from the
|
|
||||||
* catalogs gets no entry — cost renders as absent ("—"), never zero.
|
|
||||||
*
|
|
||||||
* Key format (model slots wave, 2026-09-20): uniformly provider-qualified
|
|
||||||
* ("provider:id", incl. "openrouter:<slug>"). Owner decision: no legacy
|
|
||||||
* compat — records stamped with pre-wave bare slugs simply render unpriced.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as fs from "node:fs";
|
|
||||||
import * as path from "node:path";
|
|
||||||
|
|
||||||
const DATA_DIR = path.join(
|
|
||||||
import.meta.dirname,
|
|
||||||
"..",
|
|
||||||
"node_modules",
|
|
||||||
"@earendil-works",
|
|
||||||
"pi-ai",
|
|
||||||
"dist",
|
|
||||||
"providers",
|
|
||||||
"data",
|
|
||||||
);
|
|
||||||
const OUT = path.join(import.meta.dirname, "..", "src", "core", "prices.ts");
|
|
||||||
|
|
||||||
/** One raw catalog entry (fields we consume; the files carry more). */
|
|
||||||
interface CatalogModel {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
/** pi-ai 1.0 (catalog schema v6): every entry is type-tagged. */
|
|
||||||
type?: "chat" | "image" | "classifier";
|
|
||||||
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The pure walk: every catalog entry that carries pricing, keyed
|
|
||||||
* "provider:id". Exported for the drift-guard test (tests/prices-sync)
|
|
||||||
* so the guard asserts EXACTLY what this script would generate — the
|
|
||||||
* test and the generator can never disagree (owner, 2026-09-29). */
|
|
||||||
export function buildPrices(): Map<string, CatalogModel["cost"]> {
|
|
||||||
const priced = new Map<string, CatalogModel["cost"]>();
|
|
||||||
for (const file of fs.readdirSync(DATA_DIR).filter((f) => f.endsWith(".json"))) {
|
|
||||||
const provider = file.slice(0, -".json".length);
|
|
||||||
const catalog = JSON.parse(fs.readFileSync(path.join(DATA_DIR, file), "utf-8")) as Record<
|
|
||||||
string,
|
|
||||||
Record<string, CatalogModel>
|
|
||||||
>;
|
|
||||||
for (const models of Object.values(catalog)) {
|
|
||||||
for (const [id, m] of Object.entries(models)) {
|
|
||||||
// pi-ai 1.0 (catalog schema v6): keys are type-qualified
|
|
||||||
// ("chat:<id>", "image:<id>", "classifier:<id>") and entries
|
|
||||||
// carry their own `type`. We price CHAT models only — our
|
|
||||||
// usage records are chat — and keys stay "provider:id"
|
|
||||||
// (owner format, 2026-09-20): strip the chat: prefix, skip the
|
|
||||||
// rest.
|
|
||||||
if (m.type && m.type !== "chat") continue;
|
|
||||||
const plainId = id.startsWith("chat:") ? id.slice("chat:".length) : id;
|
|
||||||
// A catalog entry without pricing data gets no key at all
|
|
||||||
// (cost renders "—", never zero).
|
|
||||||
if (!m.cost) continue;
|
|
||||||
// First writer wins (an id can appear under two apis within one
|
|
||||||
// file with identical pricing; cross-file collisions cannot
|
|
||||||
// happen — keys are provider-qualified).
|
|
||||||
priced.set(`${provider}:${plainId}`, m.cost);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return priced;
|
|
||||||
}
|
|
||||||
|
|
||||||
function main(): void {
|
|
||||||
const priced = new Map<string, { cost: CatalogModel["cost"] }>(
|
|
||||||
[...buildPrices()].map(([k, cost]) => [k, { cost }] as const),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sort by key for a stable, diff-friendly generated table.
|
|
||||||
const rows = [...priced.entries()]
|
|
||||||
.sort(([a], [b]) => a.localeCompare(b))
|
|
||||||
.map(([key, m]) => ({
|
|
||||||
price: `\t${JSON.stringify(key)}: { input: ${m.cost.input}, output: ${m.cost.output}, cacheRead: ${m.cost.cacheRead}, cacheWrite: ${m.cost.cacheWrite} },`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const tables = `/** Token prices, USD per 1M tokens. A missing model makes cost render
|
|
||||||
* as absent ("—"), never zero. Keys are uniformly provider-qualified
|
|
||||||
* ("provider:id") — NO legacy bare-slug compat (owner decision, sync
|
|
||||||
* script 2026-09-20): pre-wave records stamped with bare slugs render
|
|
||||||
* unpriced.
|
|
||||||
* Context-window limits are NOT a table (owner amendment, 2026-09-23):
|
|
||||||
* every meter's denominator reads pi's LIVE catalog (GET /api/models /
|
|
||||||
* the activity feed's server-side join) — missing model, usage-only.
|
|
||||||
* Refresh: bump @earendil-works/pi-ai, then npx tsx scripts/sync-pricing.ts
|
|
||||||
* (reads pi-ai's shipped catalogs — the same source the engine and the
|
|
||||||
* picker use). Forgetting the re-run is exactly how models go unpriced
|
|
||||||
* (deepseek-v4.1-flash, 2026-09-29) — the drift-guard test
|
|
||||||
* (tests/prices-sync.test.ts) fails the gates until you do. */
|
|
||||||
export const PRICES: Record<string, ModelPrice> = {
|
|
||||||
${rows.map((r) => r.price).join("\n")}
|
|
||||||
};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const src = fs.readFileSync(OUT, "utf-8");
|
|
||||||
const start = src.indexOf("/** Token prices");
|
|
||||||
const end = src.indexOf("// ─── Pure helpers");
|
|
||||||
if (start === -1 || end === -1) throw new Error("prices.ts markers not found — file structure changed");
|
|
||||||
fs.writeFileSync(OUT, src.slice(0, start) + tables + "\n" + src.slice(end), "utf-8");
|
|
||||||
|
|
||||||
console.log(`prices.ts regenerated from pi-ai's catalogs: ${priced.size} keys priced.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run only as a script, not when imported (the drift-guard test).
|
|
||||||
if (process.argv[1] && import.meta.filename && path.resolve(process.argv[1]) === import.meta.filename) {
|
|
||||||
main();
|
|
||||||
}
|
|
||||||
@@ -14,6 +14,12 @@ interface BridgeState {
|
|||||||
const key = Symbol.for("lh.chatgpt.bridge");
|
const key = Symbol.for("lh.chatgpt.bridge");
|
||||||
const unavailable = (): ChatGPTStatus => ({ available: false, pending: false, allowBackgroundUse: true, activeId: null, accounts: [], error: null });
|
const unavailable = (): ChatGPTStatus => ({ available: false, pending: false, allowBackgroundUse: true, activeId: null, accounts: [], error: null });
|
||||||
const validStamp = (s: Stamp) => s && [s.epoch, s.authorityRevision, s.revision].every((v) => Number.isSafeInteger(v) && v >= 0);
|
const validStamp = (s: Stamp) => s && [s.epoch, s.authorityRevision, s.revision].every((v) => Number.isSafeInteger(v) && v >= 0);
|
||||||
|
/** Next dev also uses parent/child IPC. IPC alone is not our desktop
|
||||||
|
* transport: require the same launch markers set by Electron startServer.
|
||||||
|
* These identify the transport, not a substitute for admission/lease checks. */
|
||||||
|
function desktopIPCConnected(): 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 bridge(): BridgeState {
|
function bridge(): BridgeState {
|
||||||
const global = globalThis as typeof globalThis & { [key]?: BridgeState };
|
const global = globalThis as typeof globalThis & { [key]?: BridgeState };
|
||||||
if (!global[key]) {
|
if (!global[key]) {
|
||||||
@@ -21,7 +27,7 @@ function bridge(): BridgeState {
|
|||||||
global[key] = state;
|
global[key] = state;
|
||||||
process.on("message", (raw: unknown) => {
|
process.on("message", (raw: unknown) => {
|
||||||
const m = raw as { channel?: string; kind?: string; id?: string; op?: string; ok?: boolean; value?: unknown; stamp: Stamp; status: ChatGPTStatus; invalidate?: string; error?: string };
|
const m = raw as { channel?: string; kind?: string; id?: string; op?: string; ok?: boolean; value?: unknown; stamp: Stamp; status: ChatGPTStatus; invalidate?: string; error?: string };
|
||||||
if (m?.channel !== "lh:chatgpt") return;
|
if (!desktopIPCConnected() || m?.channel !== "lh:chatgpt") return;
|
||||||
if (m.kind === "state" && validStamp(m.stamp) && m.stamp.revision >= state.stamp.revision) {
|
if (m.kind === "state" && validStamp(m.stamp) && m.stamp.revision >= state.stamp.revision) {
|
||||||
acceptStatus(state, { ...m.stamp, status: m.status });
|
acceptStatus(state, { ...m.stamp, status: m.status });
|
||||||
if (m.invalidate === "all") { state.catalogRequest++; clearCatalog(state); }
|
if (m.invalidate === "all") { state.catalogRequest++; clearCatalog(state); }
|
||||||
@@ -57,7 +63,7 @@ function acceptStatus(state: BridgeState, value: StatusValue) {
|
|||||||
/** Runtime-only inherited IPC. No HTTP credential route or renderer method. */
|
/** Runtime-only inherited IPC. No HTTP credential route or renderer method. */
|
||||||
export function chatGPTRequest<T>(op: "status" | "models" | "lease", fields: Record<string, unknown> = {}): Promise<T> {
|
export function chatGPTRequest<T>(op: "status" | "models" | "lease", fields: Record<string, unknown> = {}): Promise<T> {
|
||||||
const state = bridge();
|
const state = bridge();
|
||||||
if (!process.send || !process.connected) return Promise.reject(new Error("ChatGPT sign-in is available only in the desktop app with a private server connection."));
|
if (!desktopIPCConnected()) return Promise.reject(new Error("ChatGPT sign-in is available only in the desktop app with a private server connection."));
|
||||||
return new Promise<T>((resolve, reject) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
const id = `${process.pid}:${++state.counter}`;
|
const id = `${process.pid}:${++state.counter}`;
|
||||||
const timer = setTimeout(() => { state.pending.delete(id); reject(new Error("The ChatGPT desktop connection timed out.")); }, 45000);
|
const timer = setTimeout(() => { state.pending.delete(id); reject(new Error("The ChatGPT desktop connection timed out.")); }, 45000);
|
||||||
@@ -68,7 +74,7 @@ export function chatGPTRequest<T>(op: "status" | "models" | "lease", fields: Rec
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
export function chatGPTStatus(): ChatGPTStatus { return bridge().status; }
|
export function chatGPTStatus(): ChatGPTStatus { return bridge().status; }
|
||||||
export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return s.available && s.accounts.some((a) => a.id === s.activeId && a.connected && a.planEnabled === true); }
|
export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return desktopIPCConnected() && s.available && s.accounts.some((a) => a.id === s.activeId && a.connected && a.planEnabled === true); }
|
||||||
export function chatGPTBackgroundAllowed(): boolean { const s = chatGPTStatus(); return chatGPTConnected() && s.allowBackgroundUse === true; }
|
export function chatGPTBackgroundAllowed(): boolean { const s = chatGPTStatus(); return chatGPTConnected() && s.allowBackgroundUse === true; }
|
||||||
export function onChatGPTChange(listener: () => void): () => void { bridge().listeners.add(listener); return () => { bridge().listeners.delete(listener); }; }
|
export function onChatGPTChange(listener: () => void): () => void { bridge().listeners.add(listener); return () => { bridge().listeners.delete(listener); }; }
|
||||||
export function chatGPTModels(): Model<"openai-responses">[] { return bridge().models; }
|
export function chatGPTModels(): Model<"openai-responses">[] { return bridge().models; }
|
||||||
@@ -80,7 +86,7 @@ export function chatGPTScope(selectedModelId: string): AdmissionScope {
|
|||||||
}
|
}
|
||||||
export function chatGPTScopeCurrent(scope: AdmissionScope): boolean {
|
export function chatGPTScopeCurrent(scope: AdmissionScope): boolean {
|
||||||
const s = bridge();
|
const s = bridge();
|
||||||
return !!process.connected && chatGPTConnected() && scope.accountId === s.catalogAccount && scope.accountId === s.status.activeId && scope.epoch === s.stamp.epoch && scope.authorityRevision === s.stamp.authorityRevision && !!chatGPTModel(scope.selectedModelId);
|
return desktopIPCConnected() && chatGPTConnected() && scope.accountId === s.catalogAccount && scope.accountId === s.status.activeId && scope.epoch === s.stamp.epoch && scope.authorityRevision === s.stamp.authorityRevision && !!chatGPTModel(scope.selectedModelId);
|
||||||
}
|
}
|
||||||
export async function chatGPTLease(scope: AdmissionScope, background: boolean): Promise<LeaseValue> {
|
export async function chatGPTLease(scope: AdmissionScope, background: boolean): Promise<LeaseValue> {
|
||||||
if (!chatGPTScopeCurrent(scope)) throw new Error("ChatGPT catalog authority changed");
|
if (!chatGPTScopeCurrent(scope)) throw new Error("ChatGPT catalog authority changed");
|
||||||
@@ -102,7 +108,7 @@ export async function chatGPTDenial(scope: AdmissionScope | undefined, controlle
|
|||||||
/** Preserve account order; unknown capabilities/windows are never fabricated. */
|
/** Preserve account order; unknown capabilities/windows are never fabricated. */
|
||||||
export async function refreshChatGPTModels(supported: Model<"openai-responses">[]): Promise<void> {
|
export async function refreshChatGPTModels(supported: Model<"openai-responses">[]): Promise<void> {
|
||||||
const state = bridge();
|
const state = bridge();
|
||||||
if (!process.send || !process.connected) { clearCatalog(state); return; }
|
if (!desktopIPCConnected()) { clearCatalog(state); return; }
|
||||||
const sequence = ++state.catalogRequest;
|
const sequence = ++state.catalogRequest;
|
||||||
try {
|
try {
|
||||||
const value = await chatGPTRequest<ModelsValue>("models");
|
const value = await chatGPTRequest<ModelsValue>("models");
|
||||||
|
|||||||
@@ -1,122 +1,4 @@
|
|||||||
import type { AgentEvent, AgentMessage, PrepareRequestContext } from "@earendil-works/pi-agent-core";
|
/** Compatibility surface: ChatGPT keeps its scope/behavior while all subscription
|
||||||
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
* connections share the same provider-neutral request-admission state machine. */
|
||||||
import { nativeInputCause, needsContinuation, type LhMessage } from "../core/native-messages.ts";
|
export { SubscriptionRequestCycle as ChatGPTRequestCycle } from "./subscription-request-cycle.ts";
|
||||||
|
export type { AdmissionScope, NoDispatchKind, SubscriptionDispatchLifecycle as ChatGPTDispatchLifecycle } from "./subscription-request-cycle.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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+6
-2
@@ -15,6 +15,8 @@ import packageMetadata from "../../package.json" with { type: "json" };
|
|||||||
import { chatGPTModel } from "./chatgpt-bridge.ts";
|
import { chatGPTModel } from "./chatgpt-bridge.ts";
|
||||||
import { chatGPTStream } from "./chatgpt-stream.ts";
|
import { chatGPTStream } from "./chatgpt-stream.ts";
|
||||||
import type { ChatGPTDispatchLifecycle } from "./chatgpt-request-cycle.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.
|
/** App identity is immutable and shared by Models and every native Agent.
|
||||||
* Agent's default Pi attribution otherwise overrides the Models identity. */
|
* Agent's default Pi attribution otherwise overrides the Models identity. */
|
||||||
@@ -98,7 +100,8 @@ function defaultThinkingLevel(model: Model<Api>): ThinkingLevel {
|
|||||||
* hardcodes OpenRouter's. */
|
* hardcodes OpenRouter's. */
|
||||||
export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | (() => boolean) = true, lifecycle?: ChatGPTDispatchLifecycle): PiEngine {
|
export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | (() => boolean) = true, lifecycle?: ChatGPTDispatchLifecycle): PiEngine {
|
||||||
const models: Models = piModels();
|
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) {
|
if (!model) {
|
||||||
throw new Error(`${spec.provider} catalog has no model ${spec.id}`);
|
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
|
// `reasoning` (undefined = off on pi-ai's scale); the clamp above keeps
|
||||||
// the pin honest even when config or settings drift off the map.
|
// the pin honest even when config or settings drift off the map.
|
||||||
streamFn: (requestModel, context, options) => {
|
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 });
|
return stream(requestModel, context, { ...options, reasoning: level === "off" ? undefined : level });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -777,6 +777,7 @@ export function callUsage(usage: Usage): CallUsage {
|
|||||||
outputTokens: usage.output,
|
outputTokens: usage.output,
|
||||||
cacheReadTokens: usage.cacheRead,
|
cacheReadTokens: usage.cacheRead,
|
||||||
cacheWriteTokens: usage.cacheWrite,
|
cacheWriteTokens: usage.cacheWrite,
|
||||||
|
cacheWrite1hTokens: usage.cacheWrite1h ?? 0,
|
||||||
// Optional in pi's Usage (providers that never emit reasoning
|
// Optional in pi's Usage (providers that never emit reasoning
|
||||||
// tokens leave it unset) — persisted as 0, never dropped.
|
// tokens leave it unset) — persisted as 0, never dropped.
|
||||||
reasoningTokens: usage.reasoning ?? 0,
|
reasoningTokens: usage.reasoning ?? 0,
|
||||||
|
|||||||
@@ -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 type { AssistantMessage, ToolResultMessage, Usage } from "@earendil-works/pi-ai";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { APP_IDENTITY, piEngineFor, type PiEngine } from "../models.ts";
|
import { APP_IDENTITY, piEngineFor, type PiEngine } from "../models.ts";
|
||||||
|
import { usageWasAdmitted } from "../subscription-request-cycle.ts";
|
||||||
import { providerEventObserver } from "../provider-events.ts";
|
import { providerEventObserver } from "../provider-events.ts";
|
||||||
import type { ModelRole } from "../../../config.ts";
|
import type { ModelRole } from "../../../config.ts";
|
||||||
|
|
||||||
@@ -107,6 +108,7 @@ export async function runSubagent(tools: AgentTool<any, any>[], opts: SubagentRu
|
|||||||
aborted = true;
|
aborted = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
if (!usageWasAdmitted(m.usage)) { if (m.stopReason === "error") errorMessage = m.errorMessage; break; }
|
||||||
step++;
|
step++;
|
||||||
opts.onUsage?.(m.usage);
|
opts.onUsage?.(m.usage);
|
||||||
opts.onAssistantMessage?.(m, step);
|
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 { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts";
|
||||||
import { toLlmMessages, nativeInputCause } from "../core/native-messages.ts";
|
import { toLlmMessages, nativeInputCause } from "../core/native-messages.ts";
|
||||||
import type { ChatGPTRequestCycle } from "./chatgpt-request-cycle.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
|
* 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) {
|
if (event.type === "message_end" && event.message.role === "assistant" && "usage" in event.message) {
|
||||||
// A cancelled call is not a billed step (partial/estimated
|
// A cancelled call is not a billed step (partial/estimated
|
||||||
// usage on an aborted message is not money spent).
|
// 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);
|
opts.onUsage!(event.message.usage);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+62
-51
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* COMPACTION (owner round 14, 2026-10-05): the context-fitting mechanic,
|
* COMPACTION (owner round 14, 2026-10-05): the context-fitting mechanic,
|
||||||
* mirroring pi's design (docs aside, this is OUR implementation — pi's
|
* with a bounded local port of Pi coding-agent 1.4.0 cutoff selection.
|
||||||
* compaction lives in the coding-agent package we don't depend on).
|
* No coding-agent dependency. See docs/compaction-accounting.md for the
|
||||||
|
* behavior boundary, native golden fixtures, and upstream MIT notice.
|
||||||
*
|
*
|
||||||
* THE CONTRACT: full history stays persisted in the record — extraction
|
* THE CONTRACT: full history stays persisted in the record — extraction
|
||||||
* and the user's transcript keep reading everything. A compaction marker
|
* and the user's transcript keep reading everything. A compaction marker
|
||||||
@@ -38,7 +39,7 @@ import { COMPACTED_HISTORY_HEADER } from "./contracts/chat.ts";
|
|||||||
import type { LhMessage, LhMarkerMessage } from "./native-messages.ts";
|
import type { LhMessage, LhMarkerMessage } from "./native-messages.ts";
|
||||||
import { newMessageId } from "./native-messages.ts";
|
import { newMessageId } from "./native-messages.ts";
|
||||||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||||
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
|
import type { AssistantMessage, ToolResultMessage, SystemMessage } from "@earendil-works/pi-ai";
|
||||||
|
|
||||||
// ─── constants (the tunables, owner-approved) ────────────────────────────────
|
// ─── constants (the tunables, owner-approved) ────────────────────────────────
|
||||||
|
|
||||||
@@ -77,9 +78,8 @@ export function calculateContextTokens(usage: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Estimate a message's context tokens from its WIRE-relevant text only
|
/** Pi 1.4.0's heuristic: text + thinking + toolCall name/args for
|
||||||
* (pi's estimator): text + thinking + toolCall name/args for assistants,
|
* assistants, content text plus a fixed image allowance for other roles. NEVER JSON.stringify of the
|
||||||
* content text for user/toolResult/custom. NEVER JSON.stringify of the
|
|
||||||
* whole message — record scaffolding (ids, usage, responseId, app-side
|
* whole message — record scaffolding (ids, usage, responseId, app-side
|
||||||
* details, escaped quotes/backslashes) inflates ~3x (measured on the
|
* details, escaped quotes/backslashes) inflates ~3x (measured on the
|
||||||
* 2026-10-05 relational-databases incident: 84k estimated vs ~30k real). */
|
* 2026-10-05 relational-databases incident: 84k estimated vs ~30k real). */
|
||||||
@@ -100,23 +100,32 @@ export function estimateMessageTokens(message: LhMessage | AgentMessage): number
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
chars += textCharsOfContent(content);
|
chars += textCharsOfContent(content);
|
||||||
|
if (message.role === "system") {
|
||||||
|
const system = message as SystemMessage;
|
||||||
|
for (const section of Object.values(system.sections ?? {})) chars += section?.length ?? 0;
|
||||||
|
if (system.toolsAdded) chars += JSON.stringify(system.toolsAdded).length;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Math.ceil(chars / 4);
|
return Math.ceil(chars / 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pi coding-agent 1.4.0 estimateTokens: fixed image allowance, NOT a
|
||||||
|
// measurement of encrypted reasoning or actual provider vision tokens.
|
||||||
|
const ESTIMATED_IMAGE_CHARS = 4_800;
|
||||||
function textCharsOfContent(content: unknown): number {
|
function textCharsOfContent(content: unknown): number {
|
||||||
if (typeof content === "string") return content.length;
|
if (typeof content === "string") return content.length;
|
||||||
if (!Array.isArray(content)) return 0;
|
if (!Array.isArray(content)) return 0;
|
||||||
let chars = 0;
|
let chars = 0;
|
||||||
for (const block of content as Array<string | { type?: string; text?: string }>) {
|
for (const block of content as Array<string | { type?: string; text?: string }>) {
|
||||||
if (typeof block === "string") chars += block.length;
|
if (typeof block === "string") chars += block.length;
|
||||||
else if (block?.type === "text" || "text" in block) chars += block.text?.length ?? 0;
|
else if (block?.type === "image") chars += ESTIMATED_IMAGE_CHARS;
|
||||||
|
else if (block?.type === "text") chars += block.text?.length ?? 0;
|
||||||
}
|
}
|
||||||
return chars;
|
return chars;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The newest assistant message carrying VALID provider-reported usage
|
/** The newest assistant message carrying VALID provider-reported usage
|
||||||
* (pi's rule: skip aborted messages and all-zero usage — they carry no
|
* (pi's rule: skip aborted/error messages and all-zero usage — they carry no
|
||||||
* truth about the wire). The scan STOPS at the newest compaction
|
* truth about the wire). The scan STOPS at the newest compaction
|
||||||
* marker: usage captured before a compaction described the pre-cut
|
* marker: usage captured before a compaction described the pre-cut
|
||||||
* context — the next request carries the summary + kept tail instead,
|
* context — the next request carries the summary + kept tail instead,
|
||||||
@@ -131,7 +140,7 @@ export function lastAssistantUsageIndex(
|
|||||||
if ((m as { role?: string }).role === "custom" && (m as LhMarkerMessage).customType === "compaction") return null;
|
if ((m as { role?: string }).role === "custom" && (m as LhMarkerMessage).customType === "compaction") return null;
|
||||||
if ((m as { role?: string }).role !== "assistant") continue;
|
if ((m as { role?: string }).role !== "assistant") continue;
|
||||||
const a = m as AssistantMessage & { stopReason?: string };
|
const a = m as AssistantMessage & { stopReason?: string };
|
||||||
if (a.stopReason === "aborted") continue;
|
if (a.stopReason === "aborted" || a.stopReason === "error") continue;
|
||||||
if (calculateContextTokens((a.usage ?? {}) as Parameters<typeof calculateContextTokens>[0]) === 0) continue;
|
if (calculateContextTokens((a.usage ?? {}) as Parameters<typeof calculateContextTokens>[0]) === 0) continue;
|
||||||
return i;
|
return i;
|
||||||
}
|
}
|
||||||
@@ -180,51 +189,53 @@ export function estimateTokens(messages: readonly (LhMessage | AgentMessage)[]):
|
|||||||
|
|
||||||
// ─── the cut walk ────────────────────────────────────────────────────────────
|
// ─── the cut walk ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/** Model-visible entry adapter for the local Pi 1.4.0 cutoff port.
|
||||||
* Find the compaction cut index: walk BACKWARDS from the end accumulating
|
* Compaction entries are boundaries, not ordinary cut candidates; UI-only
|
||||||
* estimates until ~keepRecentTokens are kept; the cut lands there.
|
* markers have no context, just like native metadata entries. */
|
||||||
*
|
function cutMessage(message: LhMessage): LhMessage | null {
|
||||||
* HARD INVARIANT — a tool result is never separated from its call: if the
|
if (message.role !== "custom") return message;
|
||||||
* walk stops on a toolResult, the KEPT region extends to the assistant
|
if (["compaction", "plan-block", "problem-dismissed"].includes(message.customType)) return null;
|
||||||
* message carrying its call (keeping the result means keeping the call).
|
return message;
|
||||||
* This is the provider protocol's shape law: dropping a call while
|
|
||||||
* keeping its result strands an orphan the provider rejects. A PENDING
|
|
||||||
* question/problem call (no result yet — the pose→answer window) sits at
|
|
||||||
* the transcript end and is always kept: the walk never crosses it.
|
|
||||||
*
|
|
||||||
* Returns 0 when everything must be summarized (the whole history is the
|
|
||||||
* span — valid), or messages.length when there is nothing meaningful to
|
|
||||||
* cut (kept budget covers everything — caller checks before compacting).
|
|
||||||
*/
|
|
||||||
export function compactionCutIndex(messages: readonly LhMessage[], keepRecentTokens = KEEP_RECENT_TOKENS): number {
|
|
||||||
let kept = 0;
|
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
|
||||||
if (kept >= keepRecentTokens) {
|
|
||||||
// The cut candidate: extend past orphaned tool results to the
|
|
||||||
// assistant message carrying their calls.
|
|
||||||
let cut = i + 1;
|
|
||||||
while (cut < messages.length && (messages[cut] as { role?: string }).role === "toolResult") {
|
|
||||||
const callIndex = assistantIndexOfCall(messages, (messages[cut] as ToolResultMessage).toolCallId, cut);
|
|
||||||
if (callIndex === -1) break; // no call found (defensive): stop extending
|
|
||||||
cut = callIndex;
|
|
||||||
}
|
|
||||||
return cut;
|
|
||||||
}
|
|
||||||
kept += estimateMessageTokens(messages[i]!);
|
|
||||||
}
|
|
||||||
return 0; // the whole history fits inside the kept budget — summarize all
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The newest assistant message at or before `before` whose content
|
/** Local port of pi-coding-agent 1.4.0 findCutPoint (MIT).
|
||||||
* carries the tool call `toolCallId` — the call a result answers. */
|
* Walk backwards to >= budget, then choose the nearest valid cut AT OR
|
||||||
function assistantIndexOfCall(messages: readonly LhMessage[], toolCallId: string, before: number): number {
|
* AFTER that entry. If oversized trailing results exhaust the budget,
|
||||||
for (let i = before - 1; i >= 0; i--) {
|
* retain their preceding assistant call, not the entire session.
|
||||||
const m = messages[i]!;
|
* No encrypted-signature sizing and no exact retained-token guarantee.
|
||||||
if ((m as { role?: string }).role !== "assistant") continue;
|
* Returns the first kept record index; a zero/no-progress cut drops nothing.
|
||||||
const assistant = m as AssistantMessage;
|
* Keep this and estimateMessageTokens aligned deliberately when upgrading Pi.
|
||||||
if (assistant.content.some((b) => b.type === "toolCall" && b.id === toolCallId)) return i;
|
*/
|
||||||
|
export function compactionCutIndex(messages: readonly LhMessage[], keepRecentTokens = KEEP_RECENT_TOKENS): number {
|
||||||
|
const previous = activeCompactionOf(messages);
|
||||||
|
const previousKept = previous ? keptIndexFor(messages, previous) : -1;
|
||||||
|
const start = Math.max(0, previousKept);
|
||||||
|
const points: number[] = [];
|
||||||
|
for (let i = start; i < messages.length; i++) {
|
||||||
|
const message = cutMessage(messages[i]!);
|
||||||
|
if (message && (message.role === "user" || message.role === "assistant" || message.role === "custom")) points.push(i);
|
||||||
}
|
}
|
||||||
return -1;
|
if (!points.length) return start;
|
||||||
|
let cut = points[0]!;
|
||||||
|
let accumulated = 0;
|
||||||
|
for (let i = messages.length - 1; i >= start; i--) {
|
||||||
|
const message = cutMessage(messages[i]!);
|
||||||
|
const tokens = message ? estimateMessageTokens(message) : 0;
|
||||||
|
if (tokens === 0) continue;
|
||||||
|
accumulated += tokens;
|
||||||
|
if (accumulated >= keepRecentTokens) {
|
||||||
|
cut = points.find((candidate) => candidate >= i) ?? points.at(-1)!;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Native entries preserve adjacent non-context metadata, but never walk
|
||||||
|
// backwards across a previous compaction entry.
|
||||||
|
while (cut > start) {
|
||||||
|
const before = messages[cut - 1]!;
|
||||||
|
if ((before.role === "custom" && before.customType === "compaction") || cutMessage(before)) break;
|
||||||
|
cut--;
|
||||||
|
}
|
||||||
|
return cut;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── the marker ──────────────────────────────────────────────────────────────
|
// ─── the marker ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
||||||
|
import type { LhMessage } from "./native-messages.ts";
|
||||||
|
|
||||||
|
/** Latest completed conversation measurement, never a request-size estimate.
|
||||||
|
* Ordered compaction markers invalidate every earlier reading. Live callers
|
||||||
|
* must exclude assistant slots that have not received message_end.
|
||||||
|
* Reasoning is already inside output; totalTokens is deliberately not used.
|
||||||
|
*/
|
||||||
|
export function measuredContextTokens(
|
||||||
|
messages: readonly LhMessage[],
|
||||||
|
incompleteIds: ReadonlySet<string> = new Set(),
|
||||||
|
): number | null {
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
const message = messages[i]!;
|
||||||
|
if (message.role === "custom" && message.customType === "compaction") return null;
|
||||||
|
if (message.role !== "assistant" || incompleteIds.has(message.id)) continue;
|
||||||
|
const assistant = message as AssistantMessage;
|
||||||
|
if (!["stop", "length", "toolUse"].includes(assistant.stopReason)) continue;
|
||||||
|
const usage = assistant.usage;
|
||||||
|
if (!usage) continue;
|
||||||
|
const buckets = [usage.input, usage.output, usage.cacheRead, usage.cacheWrite];
|
||||||
|
if (buckets.some((value) => !Number.isFinite(value) || value < 0)) continue;
|
||||||
|
const tokens = buckets.reduce((sum, value) => sum + value, 0);
|
||||||
|
if (tokens > 0) return tokens;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -87,8 +87,8 @@ export interface ChatSettings {
|
|||||||
* billed (CallUsage per agent step). Steps, not a sum: cost and context
|
* billed (CallUsage per agent step). Steps, not a sum: cost and context
|
||||||
* folds happen at read time (core/prices.ts). */
|
* folds happen at read time (core/prices.ts). */
|
||||||
export interface ExtractionStageUsage {
|
export interface ExtractionStageUsage {
|
||||||
/** The model that ran this stage, provider-qualified ("provider:id" —
|
/** The model that ran this stage, provider-qualified ("provider:id")
|
||||||
* model slots wave, 2026-09-20; matches PRICES' uniform keys). */
|
* for key-independent Pi catalog lookup at read time. */
|
||||||
model: string;
|
model: string;
|
||||||
steps: CallUsage[];
|
steps: CallUsage[];
|
||||||
}
|
}
|
||||||
@@ -118,7 +118,7 @@ export interface ExtractionRunUsage {
|
|||||||
* flattened to the fields we persist (undefined = provider didn't report;
|
* flattened to the fields we persist (undefined = provider didn't report;
|
||||||
* renderers show absent, never zero). */
|
* renderers show absent, never zero). */
|
||||||
export interface CallUsage {
|
export interface CallUsage {
|
||||||
/** Total input (prompt) tokens, cached portion included. */
|
/** Pi-normalized fresh input tokens; cache buckets are disjoint. */
|
||||||
inputTokens: number | undefined;
|
inputTokens: number | undefined;
|
||||||
outputTokens: number | undefined;
|
outputTokens: number | undefined;
|
||||||
/** Cached input tokens READ (billed at the cache-read rate, if any). */
|
/** Cached input tokens READ (billed at the cache-read rate, if any). */
|
||||||
@@ -126,6 +126,9 @@ export interface CallUsage {
|
|||||||
/** Input tokens written to the cache this call (OpenRouter bills writes
|
/** Input tokens written to the cache this call (OpenRouter bills writes
|
||||||
* for some providers; undefined = not reported/not billed). */
|
* for some providers; undefined = not reported/not billed). */
|
||||||
cacheWriteTokens: number | undefined;
|
cacheWriteTokens: number | undefined;
|
||||||
|
/** Subset of cacheWriteTokens written with one-hour retention. Missing
|
||||||
|
* on older logs; new native captures explicitly record zero if absent. */
|
||||||
|
cacheWrite1hTokens?: number;
|
||||||
/** The reasoning portion of the output tokens. */
|
/** The reasoning portion of the output tokens. */
|
||||||
reasoningTokens: number | undefined;
|
reasoningTokens: number | undefined;
|
||||||
}
|
}
|
||||||
@@ -144,8 +147,8 @@ export interface TurnUsage {
|
|||||||
* entry to the transcript (the block this turn "belongs" to). */
|
* entry to the transcript (the block this turn "belongs" to). */
|
||||||
messageId: string;
|
messageId: string;
|
||||||
at: string;
|
at: string;
|
||||||
/** The model that ran the turn, provider-qualified ("provider:id" —
|
/** The model that ran the turn, provider-qualified ("provider:id")
|
||||||
* model slots wave, 2026-09-20; matches PRICES' uniform keys). */
|
* for key-independent Pi catalog lookup at read time. */
|
||||||
model: string;
|
model: string;
|
||||||
/** One entry per agent step (model call) in this turn. */
|
/** One entry per agent step (model call) in this turn. */
|
||||||
steps: CallUsage[];
|
steps: CallUsage[];
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ export interface ChatGPTStatus {
|
|||||||
/** App-wide ChatGPT background permission, independent of account selection. */
|
/** App-wide ChatGPT background permission, independent of account selection. */
|
||||||
allowBackgroundUse: boolean;
|
allowBackgroundUse: boolean;
|
||||||
activeId: string | null;
|
activeId: string | null;
|
||||||
accounts: { id: string; email: string; registration: string; connected: boolean; planEnabled?: boolean }[];
|
accounts: {
|
||||||
|
id: string; email: string; registration: string; connected: boolean; planEnabled?: boolean;
|
||||||
|
/** Main-owned authorization recovery eligibility. Missing means unknown, not eligible.
|
||||||
|
* False for storage failures, paused/closed connections, and healthy authorization. */
|
||||||
|
canSignInAgain?: boolean;
|
||||||
|
}[];
|
||||||
error: string | null;
|
error: string | null;
|
||||||
catalogError?: string | null;
|
catalogError?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-10
@@ -43,7 +43,7 @@ export interface JobRecord {
|
|||||||
* money-channel doctrine; cost renders "—", never $0). */
|
* money-channel doctrine; cost renders "—", never $0). */
|
||||||
export interface JobUsage {
|
export interface JobUsage {
|
||||||
/** The model that ran the sub-agent, provider-qualified ("provider:id" —
|
/** The model that ran the sub-agent, provider-qualified ("provider:id" —
|
||||||
* model slots wave, 2026-09-20; matches PRICES' uniform keys). */
|
* model slots wave, 2026-09-20; matches Pi catalog lookup keys). */
|
||||||
model: string;
|
model: string;
|
||||||
/** Running totals summed over the run's model calls so far. */
|
/** Running totals summed over the run's model calls so far. */
|
||||||
inputTokens: number | undefined;
|
inputTokens: number | undefined;
|
||||||
@@ -51,6 +51,10 @@ export interface JobUsage {
|
|||||||
cacheReadTokens: number | undefined;
|
cacheReadTokens: number | undefined;
|
||||||
cacheWriteTokens: number | undefined;
|
cacheWriteTokens: number | undefined;
|
||||||
reasoningTokens: number | undefined;
|
reasoningTokens: number | undefined;
|
||||||
|
cacheWrite1hTokens?: number;
|
||||||
|
/** Individual calls for current-catalog per-call tier pricing. Absent on
|
||||||
|
* older jobs; never backfill them from cumulative totals. */
|
||||||
|
calls?: CallUsage[];
|
||||||
/** How many model calls the run has made (each bills separately). */
|
/** How many model calls the run has made (each bills separately). */
|
||||||
steps: number;
|
steps: number;
|
||||||
/** The MOST RECENT model call's accounting (2026-09 dock contract): the
|
/** The MOST RECENT model call's accounting (2026-09 dock contract): the
|
||||||
@@ -67,7 +71,7 @@ export interface JobUsage {
|
|||||||
* object with these fields folds, including the real thing and test fakes). */
|
* object with these fields folds, including the real thing and test fakes). */
|
||||||
export interface UsageStepLike {
|
export interface UsageStepLike {
|
||||||
inputTokens?: number | undefined;
|
inputTokens?: number | undefined;
|
||||||
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } | undefined;
|
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number; cacheWrite1hTokens?: number } | undefined;
|
||||||
outputTokens?: number | undefined;
|
outputTokens?: number | undefined;
|
||||||
outputTokenDetails?: { reasoningTokens?: number } | undefined;
|
outputTokenDetails?: { reasoningTokens?: number } | undefined;
|
||||||
}
|
}
|
||||||
@@ -80,12 +84,13 @@ export function piUsageToStep(usage: {
|
|||||||
output: number;
|
output: number;
|
||||||
cacheRead: number;
|
cacheRead: number;
|
||||||
cacheWrite: number;
|
cacheWrite: number;
|
||||||
|
cacheWrite1h?: number;
|
||||||
reasoning?: number;
|
reasoning?: number;
|
||||||
}): UsageStepLike {
|
}): UsageStepLike {
|
||||||
return {
|
return {
|
||||||
inputTokens: usage.input,
|
inputTokens: usage.input,
|
||||||
outputTokens: usage.output,
|
outputTokens: usage.output,
|
||||||
inputTokenDetails: { cacheReadTokens: usage.cacheRead, cacheWriteTokens: usage.cacheWrite },
|
inputTokenDetails: { cacheReadTokens: usage.cacheRead, cacheWriteTokens: usage.cacheWrite, cacheWrite1hTokens: usage.cacheWrite1h ?? 0 },
|
||||||
outputTokenDetails: usage.reasoning !== undefined ? { reasoningTokens: usage.reasoning } : undefined,
|
outputTokenDetails: usage.reasoning !== undefined ? { reasoningTokens: usage.reasoning } : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -101,6 +106,14 @@ export function accumulateJobUsage(
|
|||||||
): JobUsage {
|
): JobUsage {
|
||||||
const add = (a: number | undefined, b: number | undefined): number | undefined =>
|
const add = (a: number | undefined, b: number | undefined): number | undefined =>
|
||||||
a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0);
|
a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0);
|
||||||
|
const call: CallUsage = {
|
||||||
|
inputTokens: step.inputTokens,
|
||||||
|
outputTokens: step.outputTokens,
|
||||||
|
cacheReadTokens: step.inputTokenDetails?.cacheReadTokens,
|
||||||
|
cacheWriteTokens: step.inputTokenDetails?.cacheWriteTokens,
|
||||||
|
cacheWrite1hTokens: step.inputTokenDetails?.cacheWrite1hTokens,
|
||||||
|
reasoningTokens: step.outputTokenDetails?.reasoningTokens,
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
model,
|
model,
|
||||||
inputTokens: add(total?.inputTokens, step.inputTokens),
|
inputTokens: add(total?.inputTokens, step.inputTokens),
|
||||||
@@ -108,14 +121,12 @@ export function accumulateJobUsage(
|
|||||||
cacheReadTokens: add(total?.cacheReadTokens, step.inputTokenDetails?.cacheReadTokens),
|
cacheReadTokens: add(total?.cacheReadTokens, step.inputTokenDetails?.cacheReadTokens),
|
||||||
cacheWriteTokens: add(total?.cacheWriteTokens, step.inputTokenDetails?.cacheWriteTokens),
|
cacheWriteTokens: add(total?.cacheWriteTokens, step.inputTokenDetails?.cacheWriteTokens),
|
||||||
reasoningTokens: add(total?.reasoningTokens, step.outputTokenDetails?.reasoningTokens),
|
reasoningTokens: add(total?.reasoningTokens, step.outputTokenDetails?.reasoningTokens),
|
||||||
|
cacheWrite1hTokens: ((total?.cacheWriteTokens ?? 0) > 0 && total?.cacheWrite1hTokens === undefined) ||
|
||||||
|
((call.cacheWriteTokens ?? 0) > 0 && call.cacheWrite1hTokens === undefined)
|
||||||
|
? undefined : add(total?.cacheWrite1hTokens, call.cacheWrite1hTokens),
|
||||||
steps: (total?.steps ?? 0) + 1,
|
steps: (total?.steps ?? 0) + 1,
|
||||||
lastStep: {
|
...(!total || total.calls ? { calls: [...(total?.calls ?? []), call] } : {}),
|
||||||
inputTokens: step.inputTokens,
|
lastStep: call,
|
||||||
outputTokens: step.outputTokens,
|
|
||||||
cacheReadTokens: step.inputTokenDetails?.cacheReadTokens,
|
|
||||||
cacheWriteTokens: step.inputTokenDetails?.cacheWriteTokens,
|
|
||||||
reasoningTokens: step.outputTokenDetails?.reasoningTokens,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { BackgroundModelRole, ModelSlotName } from "../settings.ts";
|
|||||||
|
|
||||||
/** One picker row: everything the UI shows about a model, projected from
|
/** One picker row: everything the UI shows about a model, projected from
|
||||||
* pi-ai's catalog. Costs are USD per 1M tokens (the same catalog, the same
|
* pi-ai's catalog. Costs are USD per 1M tokens (the same catalog, the same
|
||||||
* units core/prices.ts's generated table carries). */
|
* units as Pi's server-side pricing calculator). */
|
||||||
export interface ModelCatalogEntry {
|
export interface ModelCatalogEntry {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+27
-15
@@ -9,7 +9,7 @@
|
|||||||
* the same ruling as wave 7's stages.embedding.)
|
* the same ruling as wave 7's stages.embedding.)
|
||||||
*
|
*
|
||||||
* MONEY CHANNEL LAW: dollars are INFERRED here at read time via
|
* MONEY CHANNEL LAW: dollars are INFERRED here at read time via
|
||||||
* core/prices.ts — never stored anywhere. An unpriced model contributes
|
* an injected server-side Pi pricer — never stored anywhere. An unpriced model contributes
|
||||||
* ZERO dollars but is counted in `unpricedRuns` so the UI can render
|
* ZERO dollars but is counted in `unpricedRuns` so the UI can render
|
||||||
* "— unpriced" honestly, never a silent zero. Tokens always sum.
|
* "— unpriced" honestly, never a silent zero. Tokens always sum.
|
||||||
*
|
*
|
||||||
@@ -18,15 +18,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CallUsage, EmissionStats, ExtractionRunUsage } from "./contracts.ts";
|
import type { CallUsage, EmissionStats, ExtractionRunUsage } from "./contracts.ts";
|
||||||
import { callCost, sumSteps, isChatGPTPlan } from "./prices.ts";
|
import { sumSteps, isSubscriptionModel, subscriptionLabels, type CallPricer } from "./prices.ts";
|
||||||
|
|
||||||
/** One pipeline stage's cumulative cost across every run. */
|
/** One pipeline stage's cumulative cost across every run. */
|
||||||
export interface StageCost {
|
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;
|
subscriptionRuns?: number;
|
||||||
|
subscriptionProviders?: string[];
|
||||||
/** Inferred USD summed over PRICED runs (partial when unpricedRuns > 0). */
|
/** Inferred USD summed over PRICED runs (partial when unpricedRuns > 0). */
|
||||||
dollars: number;
|
dollars: number;
|
||||||
/** Stage-runs whose model is missing from PRICES (render "—", never zero). */
|
/** Stage-runs whose model is missing from Pi's catalog (render "—", never zero). */
|
||||||
unpricedRuns: number;
|
unpricedRuns: number;
|
||||||
/** Runs that billed this stage. */
|
/** Runs that billed this stage. */
|
||||||
runs: number;
|
runs: number;
|
||||||
@@ -37,6 +38,7 @@ export interface StageCost {
|
|||||||
/** One run in the recent-runs list (extraction or naming). */
|
/** One run in the recent-runs list (extraction or naming). */
|
||||||
export interface RecentRun {
|
export interface RecentRun {
|
||||||
subscriptionRuns?: number;
|
subscriptionRuns?: number;
|
||||||
|
subscriptionProviders?: string[];
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
/** ISO wall-clock. */
|
/** ISO wall-clock. */
|
||||||
at: string;
|
at: string;
|
||||||
@@ -55,6 +57,7 @@ export interface RecentRun {
|
|||||||
/** The cost pill's whole payload: stage totals + recent runs. */
|
/** The cost pill's whole payload: stage totals + recent runs. */
|
||||||
export interface GraphCostSummary {
|
export interface GraphCostSummary {
|
||||||
subscriptionRuns?: number;
|
subscriptionRuns?: number;
|
||||||
|
subscriptionProviders?: string[];
|
||||||
/** Inferred USD across all priced stages of all runs. */
|
/** Inferred USD across all priced stages of all runs. */
|
||||||
totalDollars: number;
|
totalDollars: number;
|
||||||
/** Runs with no priced API stage and at least one unknown stage. */
|
/** Runs with no priced API stage and at least one unknown stage. */
|
||||||
@@ -92,11 +95,11 @@ const emptyStage = (): StageCost => ({ dollars: 0, unpricedRuns: 0, runs: 0, tok
|
|||||||
|
|
||||||
/** One stage's dollars over its steps; null when the model is unpriced
|
/** One stage's dollars over its steps; null when the model is unpriced
|
||||||
* (all-or-nothing: a stage runs one model). */
|
* (all-or-nothing: a stage runs one model). */
|
||||||
function stageDollars(model: string, steps: readonly CallUsage[]): number | null {
|
function stageDollars(model: string, steps: readonly CallUsage[], price: CallPricer): number | null {
|
||||||
if (isChatGPTPlan(model)) return null;
|
if (isSubscriptionModel(model) || steps.length === 0) return null;
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const step of steps) {
|
for (const step of steps) {
|
||||||
const c = callCost(step, model);
|
const c = price(step, model);
|
||||||
if (c === null) return null;
|
if (c === null) return null;
|
||||||
total += c;
|
total += c;
|
||||||
}
|
}
|
||||||
@@ -106,6 +109,9 @@ function stageDollars(model: string, steps: readonly CallUsage[]): number | null
|
|||||||
/** Merge one step-set's tokens into an accumulating CallUsage. */
|
/** Merge one step-set's tokens into an accumulating CallUsage. */
|
||||||
function mergeTokens(into: CallUsage, steps: readonly CallUsage[]): void {
|
function mergeTokens(into: CallUsage, steps: readonly CallUsage[]): void {
|
||||||
const summed = sumSteps(steps);
|
const summed = sumSteps(steps);
|
||||||
|
// Fold this before updating total writes, so an older write-bearing
|
||||||
|
// aggregate with a missing split remains unknown even after fresh data.
|
||||||
|
into.cacheWrite1hTokens = sumSteps([into, summed]).cacheWrite1hTokens;
|
||||||
into.inputTokens = (into.inputTokens ?? 0) + (summed.inputTokens ?? 0);
|
into.inputTokens = (into.inputTokens ?? 0) + (summed.inputTokens ?? 0);
|
||||||
into.outputTokens = (into.outputTokens ?? 0) + (summed.outputTokens ?? 0);
|
into.outputTokens = (into.outputTokens ?? 0) + (summed.outputTokens ?? 0);
|
||||||
into.cacheReadTokens = (into.cacheReadTokens ?? 0) + (summed.cacheReadTokens ?? 0);
|
into.cacheReadTokens = (into.cacheReadTokens ?? 0) + (summed.cacheReadTokens ?? 0);
|
||||||
@@ -115,7 +121,7 @@ function mergeTokens(into: CallUsage, steps: readonly CallUsage[]): void {
|
|||||||
|
|
||||||
/** Fold every usage channel into the pill's payload. Pure: same input,
|
/** Fold every usage channel into the pill's payload. Pure: same input,
|
||||||
* same output — no clock, no fs, no store reads. */
|
* same output — no clock, no fs, no store reads. */
|
||||||
export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
|
export function graphCostSummary(input: CostSummaryInput, price: CallPricer): GraphCostSummary {
|
||||||
const extraction = emptyStage();
|
const extraction = emptyStage();
|
||||||
const audit = emptyStage();
|
const audit = emptyStage();
|
||||||
const runs: RecentRun[] = [];
|
const runs: RecentRun[] = [];
|
||||||
@@ -125,15 +131,21 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
|
|||||||
// Per-stage: tokens always count; dollars only when priced.
|
// Per-stage: tokens always count; dollars only when priced.
|
||||||
extraction.runs++;
|
extraction.runs++;
|
||||||
mergeTokens(extraction.tokens, stages.extractor.steps);
|
mergeTokens(extraction.tokens, stages.extractor.steps);
|
||||||
const extDollars = stageDollars(stages.extractor.model, 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 if (extDollars === null) extraction.unpricedRuns++;
|
||||||
else extraction.dollars += extDollars;
|
else extraction.dollars += extDollars;
|
||||||
|
|
||||||
audit.runs++;
|
audit.runs++;
|
||||||
mergeTokens(audit.tokens, stages.auditor.steps);
|
mergeTokens(audit.tokens, stages.auditor.steps);
|
||||||
const audDollars = stageDollars(stages.auditor.model, 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 if (audDollars === null) audit.unpricedRuns++;
|
||||||
else audit.dollars += audDollars;
|
else audit.dollars += audDollars;
|
||||||
|
|
||||||
@@ -142,10 +154,10 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
|
|||||||
const runDollars = allStages.every((d) => d === null)
|
const runDollars = allStages.every((d) => d === null)
|
||||||
? null
|
? null
|
||||||
: allStages.reduce<number>((a, d) => a + (d ?? 0), 0);
|
: 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];
|
const allSteps = [...stages.extractor.steps, ...stages.auditor.steps];
|
||||||
runs.push({
|
runs.push({
|
||||||
...(subscriptionRuns ? { subscriptionRuns } : {}),
|
...(subscriptionRuns ? { subscriptionRuns, subscriptionProviders: subscriptionLabels([stages.extractor.model, stages.auditor.model]) } : {}),
|
||||||
startedAt: run.startedAt,
|
startedAt: run.startedAt,
|
||||||
at: run.at,
|
at: run.at,
|
||||||
durationMs: run.durationMs,
|
durationMs: run.durationMs,
|
||||||
@@ -171,7 +183,7 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
|
|||||||
const unpricedRuns = runs.filter((r) => r.dollars === null && (r.subscriptionRuns ?? 0) < 2).length;
|
const unpricedRuns = runs.filter((r) => r.dollars === null && (r.subscriptionRuns ?? 0) < 2).length;
|
||||||
const subscriptionRuns = (extraction.subscriptionRuns ?? 0) + (audit.subscriptionRuns ?? 0);
|
const subscriptionRuns = (extraction.subscriptionRuns ?? 0) + (audit.subscriptionRuns ?? 0);
|
||||||
return {
|
return {
|
||||||
...(subscriptionRuns ? { subscriptionRuns } : {}),
|
...(subscriptionRuns ? { subscriptionRuns, subscriptionProviders: [...new Set([...(extraction.subscriptionProviders ?? []), ...(audit.subscriptionProviders ?? [])])] } : {}),
|
||||||
totalDollars: extraction.dollars + audit.dollars,
|
totalDollars: extraction.dollars + audit.dollars,
|
||||||
unpricedRuns,
|
unpricedRuns,
|
||||||
stages: { extraction, audit },
|
stages: { extraction, audit },
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ export * from "./input-events.ts";
|
|||||||
export * from "./view.ts";
|
export * from "./view.ts";
|
||||||
export * from "./tool-parts.ts";
|
export * from "./tool-parts.ts";
|
||||||
export * from "./prices.ts";
|
export * from "./prices.ts";
|
||||||
|
export * from "./context-usage.ts";
|
||||||
export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.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 "./cost-summary.ts";
|
||||||
export * from "./fuzzy.ts";
|
export * from "./fuzzy.ts";
|
||||||
// settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19):
|
// settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19):
|
||||||
|
|||||||
+48
-1742
File diff suppressed because it is too large
Load Diff
+18
-15
@@ -7,9 +7,10 @@ import {
|
|||||||
type TeachingAgent,
|
type TeachingAgent,
|
||||||
} from "../agent/teacher-agent.ts";
|
} from "../agent/teacher-agent.ts";
|
||||||
import { piEngineForSpec, type PiEngine } from "../agent/models.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 { 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 { reconstructRequestCause } from "../core/native-messages.ts";
|
||||||
import { createJobRunner } from "../agent/subagents/runner.ts";
|
import { createJobRunner } from "../agent/subagents/runner.ts";
|
||||||
import { createExtractionPipeline } from "../agent/subagents/extraction.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
|
// persist (nothing durable changes); wake-pipeline turns ride the same
|
||||||
// path. The affected tier is strong — the conversation chain's own tier
|
// path. The affected tier is strong — the conversation chain's own tier
|
||||||
// (a chat.model override still resolves through it).
|
// (a chat.model override still resolves through it).
|
||||||
if (conversation.provider === "chatgpt") {
|
if (isSubscriptionProvider(conversation.provider)) {
|
||||||
await refreshConnectionCatalog();
|
await refreshConnectionCatalog(conversation.provider);
|
||||||
if (input.type === "steers" && !chatGPTBackgroundAllowed()) {
|
if (input.type === "steers" && !connectionBackgroundAllowed(conversation.provider)) {
|
||||||
return Response.json({ error: "ChatGPT background use is off in this app. Automatic follow-up remains deferred in its queue." }, { status: 403 });
|
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)) {
|
if (!providerKeyPresent(conversation.provider)) {
|
||||||
@@ -228,21 +229,21 @@ export async function handleChatInput(
|
|||||||
// beginTurn, no liveness effects — and the record re-reads after
|
// beginTurn, no liveness effects — and the record re-reads after
|
||||||
// because compaction appends the marker.
|
// because compaction appends the marker.
|
||||||
const callPurpose = { background: input.type === "steers" || (input.type === "continue" && reconstructRequestCause(record.messages) !== "foreground") };
|
const callPurpose = { background: input.type === "steers" || (input.type === "continue" && reconstructRequestCause(record.messages) !== "foreground") };
|
||||||
let requestCycle: ChatGPTRequestCycle | undefined;
|
let requestCycle: SubscriptionRequestCycle | undefined;
|
||||||
const lifecycle: ChatGPTDispatchLifecycle = {
|
const lifecycle: SubscriptionDispatchLifecycle = {
|
||||||
captured: (scope) => requestCycle?.captured(scope),
|
captured: (scope) => requestCycle?.captured(scope),
|
||||||
beforeDispatch: (scope) => requestCycle?.beforeDispatch(scope),
|
beforeDispatch: (scope) => requestCycle?.beforeDispatch(scope),
|
||||||
noDispatch: (kind) => requestCycle?.noDispatch(kind),
|
noDispatch: (kind) => requestCycle?.noDispatch(kind),
|
||||||
};
|
};
|
||||||
let conversationEngine: PiEngine;
|
let conversationEngine: PiEngine;
|
||||||
try { conversationEngine = piEngineForSpec(conversation, () => callPurpose.background, lifecycle); }
|
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}`;
|
const modelIdForCompaction = `${conversationEngine.model.provider}:${conversationEngine.model.id}`;
|
||||||
await ensureCompacted(
|
await ensureCompacted(
|
||||||
{
|
{
|
||||||
chatStore: stores.chatStore,
|
chatStore: stores.chatStore,
|
||||||
engine: piEngineForSpec(conversation, true),
|
engine: piEngineForSpec(conversation, true),
|
||||||
canStart: () => conversation.provider !== "chatgpt" || chatGPTBackgroundAllowed(),
|
canStart: () => connectionBackgroundAllowed(conversation.provider),
|
||||||
modelId: modelIdForCompaction,
|
modelId: modelIdForCompaction,
|
||||||
kind: record.kind === "practice" ? "practice" : hasProbeDone(record.messages) ? "teach" : "probe",
|
kind: record.kind === "practice" ? "practice" : hasProbeDone(record.messages) ? "teach" : "probe",
|
||||||
thresholdTokens: effectiveThresholdTokens(
|
thresholdTokens: effectiveThresholdTokens(
|
||||||
@@ -308,7 +309,7 @@ export async function handleChatInput(
|
|||||||
// ride this same write-ahead - the pump de-queued them at fire, so this
|
// ride this same write-ahead - the pump de-queued them at fire, so this
|
||||||
// is the ONLY copy ever appended.)
|
// is the ONLY copy ever appended.)
|
||||||
const merged = [...record.messages, ...appends];
|
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;
|
let persistBaseCount = stagedAutomatic ? record.messages.length : merged.length;
|
||||||
try {
|
try {
|
||||||
if (!stagedAutomatic) stores.chatStore.saveMessages(chatId, merged);
|
if (!stagedAutomatic) stores.chatStore.saveMessages(chatId, merged);
|
||||||
@@ -333,6 +334,7 @@ export async function handleChatInput(
|
|||||||
outputTokens: usage.output,
|
outputTokens: usage.output,
|
||||||
cacheReadTokens: usage.cacheRead,
|
cacheReadTokens: usage.cacheRead,
|
||||||
cacheWriteTokens: usage.cacheWrite,
|
cacheWriteTokens: usage.cacheWrite,
|
||||||
|
cacheWrite1hTokens: usage.cacheWrite1h ?? 0,
|
||||||
// Optional in pi's Usage — persisted as 0, never dropped.
|
// Optional in pi's Usage — persisted as 0, never dropped.
|
||||||
reasoningTokens: usage.reasoning ?? 0,
|
reasoningTokens: usage.reasoning ?? 0,
|
||||||
});
|
});
|
||||||
@@ -362,12 +364,12 @@ export async function handleChatInput(
|
|||||||
// enabled). The definitive toggleable set is TOGGLEABLE_TOOLS — the
|
// enabled). The definitive toggleable set is TOGGLEABLE_TOOLS — the
|
||||||
// sub-agent JOB tools (research/demo/visual/pdf; adjudicated with the
|
// sub-agent JOB tools (research/demo/visual/pdf; adjudicated with the
|
||||||
// user: quiz/ask/fs/plan/probe_done are always-on).
|
// 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),
|
initialMessages: asAgentMessages(merged),
|
||||||
staged: stagedAutomatic ? asAgentMessages(appends) : undefined,
|
staged: stagedAutomatic ? asAgentMessages(appends) : undefined,
|
||||||
foregroundOwed: !callPurpose.background,
|
foregroundOwed: !callPurpose.background,
|
||||||
purpose: callPurpose,
|
purpose: callPurpose,
|
||||||
scopeCurrent: chatGPTScopeCurrent,
|
scopeCurrent: (scope) => connectionScopeCurrent(conversation.provider, scope),
|
||||||
commitStaged: () => { stores.chatStore.saveMessages(chatId, merged); persistBaseCount = merged.length; },
|
commitStaged: () => { stores.chatStore.saveMessages(chatId, merged); persistBaseCount = merged.length; },
|
||||||
restoreAutomatic: (automaticIds, retainedIds) => restoreUncommittedAutomatic(chatId, constructed, automaticIds, retainedIds),
|
restoreAutomatic: (automaticIds, retainedIds) => restoreUncommittedAutomatic(chatId, constructed, automaticIds, retainedIds),
|
||||||
});
|
});
|
||||||
@@ -490,6 +492,7 @@ export async function handleChatInput(
|
|||||||
// transcript directly (the retry context the model saw).
|
// transcript directly (the retry context the model saw).
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
if (conversation.provider === "chatgpt") return chatGPTStreamError(String(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);
|
console.error(`[chat ${chatId}] turn error:`, error);
|
||||||
return "An error occurred.";
|
return "An error occurred.";
|
||||||
},
|
},
|
||||||
@@ -560,7 +563,7 @@ export async function handleChatInput(
|
|||||||
kickCompaction(() => {
|
kickCompaction(() => {
|
||||||
const fresh = effectiveSettings();
|
const fresh = effectiveSettings();
|
||||||
const spec = resolveConversationSpec(stores.chatStore.getChat(chatId)?.settings, fresh.models.strong);
|
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;
|
let kickEngine: PiEngine;
|
||||||
try { kickEngine = piEngineForSpec(spec, true); } catch { return null; }
|
try { kickEngine = piEngineForSpec(spec, true); } catch { return null; }
|
||||||
const kickModelId = `${kickEngine.model.provider}:${kickEngine.model.id}`;
|
const kickModelId = `${kickEngine.model.provider}:${kickEngine.model.id}`;
|
||||||
@@ -569,7 +572,7 @@ export async function handleChatInput(
|
|||||||
return {
|
return {
|
||||||
chatStore: stores.chatStore,
|
chatStore: stores.chatStore,
|
||||||
engine: kickEngine,
|
engine: kickEngine,
|
||||||
canStart: () => spec.provider !== "chatgpt" || chatGPTBackgroundAllowed(),
|
canStart: () => connectionBackgroundAllowed(spec.provider),
|
||||||
modelId: kickModelId,
|
modelId: kickModelId,
|
||||||
kind: freshRecord.kind === "practice" ? "practice" : hasProbeDone(freshRecord.messages) ? "teach" : "probe",
|
kind: freshRecord.kind === "practice" ? "practice" : hasProbeDone(freshRecord.messages) ? "teach" : "probe",
|
||||||
thresholdTokens: effectiveThresholdTokens(
|
thresholdTokens: effectiveThresholdTokens(
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ async function compact(deps: CompactionDeps, chatId: string): Promise<void> {
|
|||||||
if (tokensBefore <= deps.thresholdTokens) return; // settled beneath while we waited
|
if (tokensBefore <= deps.thresholdTokens) return; // settled beneath while we waited
|
||||||
|
|
||||||
const cut = compactionCutIndex(messages);
|
const cut = compactionCutIndex(messages);
|
||||||
if (cut >= messages.length) return; // nothing to drop (defensive)
|
if (cut <= 0 || cut >= messages.length) return; // no history can be dropped
|
||||||
|
|
||||||
// The previous compaction's kept boundary (chained compactions
|
// The previous compaction's kept boundary (chained compactions
|
||||||
// summarize the old summary + what rode after it).
|
// summarize the old summary + what rode after it).
|
||||||
@@ -240,6 +240,7 @@ async function compact(deps: CompactionDeps, chatId: string): Promise<void> {
|
|||||||
outputTokens: usage.output,
|
outputTokens: usage.output,
|
||||||
cacheReadTokens: usage.cacheRead,
|
cacheReadTokens: usage.cacheRead,
|
||||||
cacheWriteTokens: usage.cacheWrite,
|
cacheWriteTokens: usage.cacheWrite,
|
||||||
|
cacheWrite1hTokens: usage.cacheWrite1h ?? 0,
|
||||||
reasoningTokens: usage.reasoning ?? 0,
|
reasoningTokens: usage.reasoning ?? 0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
+2
-1
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "../core/graph-deletion.ts";
|
} from "../core/graph-deletion.ts";
|
||||||
import { extractionStatuses } from "../agent/subagents/extraction.ts";
|
import { extractionStatuses } from "../agent/subagents/extraction.ts";
|
||||||
import { stores } from "./stores.ts";
|
import { stores } from "./stores.ts";
|
||||||
|
import { callCost } from "./pricing.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-side graph entry for the home view (GET /api/graph). A PURE READ:
|
* Server-side graph entry for the home view (GET /api/graph). A PURE READ:
|
||||||
@@ -41,7 +42,7 @@ export function graphCosts(): GraphCostSummary {
|
|||||||
.readSources()
|
.readSources()
|
||||||
.filter((s) => s.extractions?.length)
|
.filter((s) => s.extractions?.length)
|
||||||
.map((s) => ({ filePath: s.filePath, extractions: s.extractions! }));
|
.map((s) => ({ filePath: s.filePath, extractions: s.extractions! }));
|
||||||
return graphCostSummary({ chats, sources });
|
return graphCostSummary({ chats, sources }, callCost);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The graph's version stamp (session-deletion wave 6): the max mtime
|
/** The graph's version stamp (session-deletion wave 6): the max mtime
|
||||||
|
|||||||
@@ -43,3 +43,4 @@ export * from "./compaction.ts";
|
|||||||
export { runPiTurn, piTurnResponse, type PiTurnHandle, type PiTurnSpec } from "./pi/ui-stream.ts";
|
export { runPiTurn, piTurnResponse, type PiTurnHandle, type PiTurnSpec } from "./pi/ui-stream.ts";
|
||||||
export * from "./observability.ts";
|
export * from "./observability.ts";
|
||||||
export * from "./model-catalog.ts";
|
export * from "./model-catalog.ts";
|
||||||
|
export { sessionCost } from "./pricing.ts";
|
||||||
|
|||||||
+4
-4
@@ -68,7 +68,7 @@ import { danglingQuestionOf, endsInDismissal, type LhMessage } from "../core/nat
|
|||||||
import { fireJobAbort, isJobLive } from "../agent/subagents/aborts.ts";
|
import { fireJobAbort, isJobLive } from "../agent/subagents/aborts.ts";
|
||||||
import { stores, effectiveSettings } from "./stores.ts";
|
import { stores, effectiveSettings } from "./stores.ts";
|
||||||
import { resolveConversationSpec } from "../core/settings-resolution.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 { isTurnRunning } from "./turn-liveness.ts";
|
||||||
import type { Agent } from "@earendil-works/pi-agent-core";
|
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 {
|
export function automaticFollowUpAllowed(chatId: string, agent?: Agent): boolean {
|
||||||
if (agent && state().automaticDeferred.has(agent)) return false;
|
if (agent && state().automaticDeferred.has(agent)) return false;
|
||||||
const provider = agent?.state?.model?.provider ?? resolveConversationSpec(stores.chatStore.getChat(chatId)?.settings, effectiveSettings().models.strong)?.provider;
|
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 {
|
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.
|
// 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.
|
// No retry timer, inference, or API-key fallback is started while consent is off.
|
||||||
state().stopConnectionWatch?.();
|
state().stopConnectionWatch?.();
|
||||||
state().stopConnectionWatch = onChatGPTChange(() => {
|
state().stopConnectionWatch = onSubscriptionChange(() => {
|
||||||
const s = state();
|
const s = state();
|
||||||
for (const [chatId, agent] of s.liveAgents) {
|
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 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));
|
const pending = (s.queues.get(chatId) ?? []).filter((entry) => entry.handedTo === agent && !consumed.has(entry.steer.id));
|
||||||
if (pending.length === 0) continue;
|
if (pending.length === 0) continue;
|
||||||
|
|||||||
+2
-2
@@ -52,7 +52,7 @@ import {
|
|||||||
validateKeyShape,
|
validateKeyShape,
|
||||||
} from "../core/auth-keys.ts";
|
} from "../core/auth-keys.ts";
|
||||||
import { authStore } from "../store/auth.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
|
// 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
|
// 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
|
* (gate by what would run). Providers absent from AUTH_PROVIDER_KEYS take
|
||||||
* no key at all: nothing to gate, present by definition. */
|
* no key at all: nothing to gate, present by definition. */
|
||||||
export function providerKeyPresent(provider: string): boolean {
|
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);
|
const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider);
|
||||||
return info ? keyValue(info.envVar) !== undefined : true;
|
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 type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||||
import { piModels } from "../agent/models.ts";
|
import { piModels } from "../agent/models.ts";
|
||||||
import { chatGPTConnected, chatGPTModel, chatGPTModels, refreshChatGPTModels } from "../agent/chatgpt-bridge.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> {
|
export async function refreshConnectionCatalog(provider?: string): Promise<void> {
|
||||||
await refreshChatGPTModels(piModels().getModels("openai").filter((m): m is Model<"openai-responses"> => m.api === "openai-responses"));
|
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 {
|
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 { settingsStore } from "../store/settings.ts";
|
||||||
import { effectiveSettings } from "./stores.ts";
|
import { effectiveSettings } from "./stores.ts";
|
||||||
import { keyValue } from "./keys.ts";
|
import { keyValue } from "./keys.ts";
|
||||||
@@ -134,7 +139,10 @@ export function catalogProviders(): CatalogProvider[] {
|
|||||||
id: p.id,
|
id: p.id,
|
||||||
envVar: p.envVar,
|
envVar: p.envVar,
|
||||||
keyPresent: keyValue(p.envVar) !== undefined,
|
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
|
/** 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);
|
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;
|
return index;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +204,7 @@ export function modelCatalog(): ModelCatalogResponse {
|
|||||||
.flatMap((p) => piModels().getModels(p.id))
|
.flatMap((p) => piModels().getModels(p.id))
|
||||||
.map(toModelDto)
|
.map(toModelDto)
|
||||||
.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)))
|
.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 {
|
return {
|
||||||
models,
|
models,
|
||||||
defaults: catalogDefaults(),
|
defaults: catalogDefaults(),
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { chatStore } from "../store/chats.ts";
|
|||||||
import { graphRevision } from "./graph.ts";
|
import { graphRevision } from "./graph.ts";
|
||||||
import { catalogContextLimits } from "./model-catalog.ts";
|
import { catalogContextLimits } from "./model-catalog.ts";
|
||||||
import { listJobs } from "./jobs.ts";
|
import { listJobs } from "./jobs.ts";
|
||||||
|
import { jobCost } from "./pricing.ts";
|
||||||
import type { JobRecord } from "../core/contracts.ts";
|
import type { JobRecord } from "../core/contracts.ts";
|
||||||
|
|
||||||
export { abortExtraction, extractionStatuses, type ExtractionPhase, type RunningExtraction } from "../agent/subagents/extraction.ts";
|
export { abortExtraction, extractionStatuses, type ExtractionPhase, type RunningExtraction } from "../agent/subagents/extraction.ts";
|
||||||
@@ -36,6 +37,7 @@ export { chatStore } from "../store/chats.ts";
|
|||||||
* model is missing from the catalog (the blob then renders no context
|
* model is missing from the catalog (the blob then renders no context
|
||||||
* line — never a fake window). */
|
* line — never a fake window). */
|
||||||
export interface ActivityJob extends JobRecord {
|
export interface ActivityJob extends JobRecord {
|
||||||
|
costUsd: number | null;
|
||||||
title: string;
|
title: string;
|
||||||
contextLimit: number | null;
|
contextLimit: number | null;
|
||||||
}
|
}
|
||||||
@@ -56,9 +58,8 @@ export interface ActivityExtraction {
|
|||||||
|
|
||||||
/** The dock's whole world in one payload: every running process, every view.
|
/** The dock's whole world in one payload: every running process, every view.
|
||||||
* Jobs are RUNNING-ONLY — finished jobs belong to their chat's transcript;
|
* Jobs are RUNNING-ONLY — finished jobs belong to their chat's transcript;
|
||||||
* the blob disappears when the process ends. Cost math is NOT done here:
|
* the blob disappears when the process ends. Costs are joined here through
|
||||||
* raw usage rides on the records and the client folds it with the pure
|
* the server-side Pi pricing adapter; clients never reconstruct rates. */
|
||||||
* helpers in core/prices.ts (transport stays thin). */
|
|
||||||
/** The dock's whole world + the graph's version stamp (wave 6 session
|
/** The dock's whole world + the graph's version stamp (wave 6 session
|
||||||
* deletion): the payload carries graphRevision (max mtime across the
|
* deletion): the payload carries graphRevision (max mtime across the
|
||||||
* graph data files — see server/graph.ts) so any view can detect "the
|
* graph data files — see server/graph.ts) so any view can detect "the
|
||||||
@@ -78,6 +79,7 @@ export function activityFeed(): {
|
|||||||
if (job.status === "running") {
|
if (job.status === "running") {
|
||||||
jobs.push({
|
jobs.push({
|
||||||
...job,
|
...job,
|
||||||
|
costUsd: jobCost(job),
|
||||||
title: chat.title ?? chat.id,
|
title: chat.title ?? chat.id,
|
||||||
contextLimit: job.usage ? (limits.get(job.usage.model) ?? null) : null,
|
contextLimit: job.usage ? (limits.get(job.usage.model) ?? null) : null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/** Read-time API-dollar estimates. Pi owns both rates and arithmetic.
|
||||||
|
* Catalog resolution is key-independent; no provider dispatch/auth is needed.
|
||||||
|
* Never mutate saved native messages: calculateCost mutates its usage.cost.
|
||||||
|
*/
|
||||||
|
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 { 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 || isSubscriptionModel(id)) return undefined;
|
||||||
|
return piModels().getModel(id.slice(0, colon), id.slice(colon + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function callCost(call: Partial<CallUsage>, modelId: string): number | null {
|
||||||
|
const model = catalogModel(modelId);
|
||||||
|
if (!model || call.inputTokens === undefined || call.outputTokens === undefined) return null;
|
||||||
|
const input = call.inputTokens;
|
||||||
|
const output = call.outputTokens;
|
||||||
|
const cacheRead = call.cacheReadTokens ?? 0;
|
||||||
|
const cacheWrite = call.cacheWriteTokens ?? 0;
|
||||||
|
const cacheWrite1h = call.cacheWrite1hTokens ?? 0;
|
||||||
|
if ([input, output, cacheRead, cacheWrite, cacheWrite1h].some((v) => !Number.isFinite(v) || v < 0) || cacheWrite1h > cacheWrite) return null;
|
||||||
|
// Old records discarded this split. Only price them if it cannot affect
|
||||||
|
// the result; do not invent short-retention precision from a missing field.
|
||||||
|
if (cacheWrite > 0 && call.cacheWrite1hTokens === undefined &&
|
||||||
|
[model.cost, ...(model.cost.tiers ?? [])].some((rate) => rate.cacheWrite !== 2 * rate.input)) return null;
|
||||||
|
const usage: Usage = {
|
||||||
|
input, output, cacheRead, cacheWrite, cacheWrite1h,
|
||||||
|
totalTokens: input + output + cacheRead + cacheWrite,
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
};
|
||||||
|
const total = calculateCost(model, usage).total;
|
||||||
|
return Number.isFinite(total) && total >= 0 ? total : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function turnCost(turn: TurnUsage): number | null {
|
||||||
|
return foldTurnCost(turn, callCost);
|
||||||
|
}
|
||||||
|
export function sessionCost(record: Pick<ChatRecord, "turns">) {
|
||||||
|
return foldSessionCost(record, callCost);
|
||||||
|
}
|
||||||
|
export function jobCost(job: Pick<JobRecord, "usage">): number | null {
|
||||||
|
const usage = job.usage;
|
||||||
|
if (!usage || usage.steps < 1) return null;
|
||||||
|
const hasCalls = usage.calls?.length === usage.steps;
|
||||||
|
if (!hasCalls && usage.steps > 1 && catalogModel(usage.model)?.cost.tiers?.length) return null;
|
||||||
|
return foldJobCost(job, callCost);
|
||||||
|
}
|
||||||
@@ -88,10 +88,13 @@ test("activityFeed: contextLimit is the server's live-catalog join (owner amendm
|
|||||||
// pi 1.0.2's catalog: glm-5.3-flash contextWindow 1_048_575 (was
|
// pi 1.0.2's catalog: glm-5.3-flash contextWindow 1_048_575 (was
|
||||||
// 1_048_576 at 0.87.1 — upstream models.dev refresh).
|
// 1_048_576 at 0.87.1 — upstream models.dev refresh).
|
||||||
assert.equal(knownJob.contextLimit, 1_048_575, "pi's catalog, joined server-side onto the feed");
|
assert.equal(knownJob.contextLimit, 1_048_575, "pi's catalog, joined server-side onto the feed");
|
||||||
|
assert.ok(knownJob.costUsd !== null && Math.abs(knownJob.costUsd - 0.0002) < 1e-12, "Pi's price is projected on the server");
|
||||||
const unknownJob = feed.jobs.find((j) => j.id === unknown.id)!;
|
const unknownJob = feed.jobs.find((j) => j.id === unknown.id)!;
|
||||||
assert.equal(unknownJob.contextLimit, null, "missing from the live catalog → null, never a guessed window");
|
assert.equal(unknownJob.contextLimit, null, "missing from the live catalog → null, never a guessed window");
|
||||||
|
assert.equal(unknownJob.costUsd, null);
|
||||||
const bareJob = feed.jobs.find((j) => j.id === bare.id)!;
|
const bareJob = feed.jobs.find((j) => j.id === bare.id)!;
|
||||||
assert.equal(bareJob.contextLimit, null, "no usage yet → no context line");
|
assert.equal(bareJob.contextLimit, null, "no usage yet → no context line");
|
||||||
|
assert.equal(bareJob.costUsd, null);
|
||||||
} finally {
|
} finally {
|
||||||
fs.unlinkSync(`${PATHS.chats}/${chat.id}.json`);
|
fs.unlinkSync(`${PATHS.chats}/${chat.id}.json`);
|
||||||
const jobsDir = path.join(PATHS.data, "jobs");
|
const jobsDir = path.join(PATHS.data, "jobs");
|
||||||
|
|||||||
+146
-35
@@ -7,7 +7,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { exportJWK, generateKeyPair, SignJWT } from "jose";
|
import { exportJWK, generateKeyPair, SignJWT } from "jose";
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const { createChatGPT, encryptedStore, allowedAuthEndpoint, SCOPES } = require("../electron/chatgpt.cjs");
|
const { createChatGPT, fileStore, allowedAuthEndpoint, SCOPES } = require("../electron/chatgpt.cjs");
|
||||||
const { privateKey, publicKey } = await generateKeyPair("RS256");
|
const { privateKey, publicKey } = await generateKeyPair("RS256");
|
||||||
const jwk = { ...await exportJWK(publicKey), kid: "scratch-key", alg: "RS256", use: "sig" };
|
const jwk = { ...await exportJWK(publicKey), kid: "scratch-key", alg: "RS256", use: "sig" };
|
||||||
const tokenUrl = "https://auth.openai.com/api/accounts/oauth/token";
|
const tokenUrl = "https://auth.openai.com/api/accounts/oauth/token";
|
||||||
@@ -16,14 +16,14 @@ async function signed(client: string, nonce: string, subject = "scratch-user", o
|
|||||||
return new SignJWT({ nonce, email: "dummy@example.invalid", ...overrides }).setProtectedHeader({ alg: "RS256", kid: "scratch-key" }).setIssuer("https://auth.openai.com").setSubject(subject).setAudience(client).setIssuedAt().setExpirationTime("1h").sign(privateKey);
|
return new SignJWT({ nonce, email: "dummy@example.invalid", ...overrides }).setProtectedHeader({ alg: "RS256", kid: "scratch-key" }).setIssuer("https://auth.openai.com").setSubject(subject).setAudience(client).setIssuedAt().setExpirationTime("1h").sign(privateKey);
|
||||||
}
|
}
|
||||||
function deferred<T = void>() { let resolve!: (value: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { resolve, promise }; }
|
function deferred<T = void>() { let resolve!: (value: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { resolve, promise }; }
|
||||||
function fixture(options: { state?: any; expires?: number; timeoutMs?: number; token?: (form: URLSearchParams, url: URL) => Promise<Response>; write?: (next: any) => Promise<void>; secure?: () => void; change?: (push: any) => void; provider?: (target: string, init: RequestInit) => Promise<Response | undefined> } = {}) {
|
function fixture(options: { state?: any; expires?: number; timeoutMs?: number; token?: (form: URLSearchParams, url: URL) => Promise<Response>; write?: (next: any) => Promise<void>; read?: () => void; change?: (push: any) => void; provider?: (target: string, init: RequestInit) => Promise<Response | undefined> } = {}) {
|
||||||
let saved = structuredClone(options.state ?? initial());
|
let saved = structuredClone(options.state ?? initial());
|
||||||
let url!: URL;
|
let url!: URL;
|
||||||
let writeCount = 0;
|
let writeCount = 0;
|
||||||
const changed = deferred();
|
const changed = deferred();
|
||||||
const requests: { url: string; form?: URLSearchParams }[] = [];
|
const requests: { url: string; form?: URLSearchParams }[] = [];
|
||||||
const service = createChatGPT({
|
const service = createChatGPT({
|
||||||
store: { secure() { options.secure?.(); }, async read() { return structuredClone(saved); }, async write(next: any) { writeCount++; await options.write?.(next); saved = structuredClone(next); } },
|
store: { async read() { options.read?.(); return structuredClone(saved); }, async write(next: any) { writeCount++; await options.write?.(next); saved = structuredClone(next); } },
|
||||||
openExternal: async (value: string) => { url = new URL(value); },
|
openExternal: async (value: string) => { url = new URL(value); },
|
||||||
timeoutMs: options.timeoutMs ?? 5000,
|
timeoutMs: options.timeoutMs ?? 5000,
|
||||||
onChange: (push: any) => { options.change?.(push); if (!push.status.pending) changed.resolve(); },
|
onChange: (push: any) => { options.change?.(push); if (!push.status.pending) changed.resolve(); },
|
||||||
@@ -153,7 +153,7 @@ test("missing plan scope retains verified registration but refuses inference and
|
|||||||
} finally { f.service.close(); }
|
} finally { f.service.close(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
test("durable encrypted save failure never activates a pending login", async () => {
|
test("durable file save failure never activates a pending login", async () => {
|
||||||
const f = fixture({ write: async () => { throw new Error("scratch disk failure containing no real data"); } });
|
const f = fixture({ write: async () => { throw new Error("scratch disk failure containing no real data"); } });
|
||||||
try {
|
try {
|
||||||
await f.service.login(); await f.callback(); await f.changed.promise;
|
await f.service.login(); await f.callback(); await f.changed.promise;
|
||||||
@@ -162,6 +162,40 @@ test("durable encrypted save failure never activates a pending login", async ()
|
|||||||
} finally { f.service.close(); }
|
} finally { f.service.close(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("rotated-token persistence failure invalidates existing leases and never reloads the stale saved token", async () => {
|
||||||
|
let failWrite = false, reads = 0, refreshes = 0;
|
||||||
|
const pushes: any[] = [];
|
||||||
|
const f = await loggedIn({ read: () => { reads++; }, change: (push) => pushes.push(push),
|
||||||
|
write: async () => { if (failWrite) throw Object.assign(new Error("storage"), { code: "storage" }); },
|
||||||
|
token: async (form, url) => {
|
||||||
|
if (form.get("grant_type") === "refresh_token") {
|
||||||
|
refreshes++;
|
||||||
|
return Response.json({ access_token: "rotated-access", refresh_token: "rotated-refresh", token_type: "Bearer", expires_in: 3600 });
|
||||||
|
}
|
||||||
|
return Response.json({ access_token: "old-access", refresh_token: "old-refresh", id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), token_type: "Bearer", expires_in: 3600, scope: SCOPES.join(" ") });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const catalog = await f.service.models();
|
||||||
|
const expected = { expectedAccountId: catalog.accountId, expectedAuthorityRevision: catalog.authorityRevision, selectedModelId: "gpt-5" };
|
||||||
|
await f.rawLease(false, expected);
|
||||||
|
// Advance time rather than mutating the service's private state.
|
||||||
|
const now = Date.now; Date.now = () => now() + 3600000;
|
||||||
|
try {
|
||||||
|
failWrite = true;
|
||||||
|
await assert.rejects(f.rawLease(false, expected), /storage/);
|
||||||
|
} finally { Date.now = now; }
|
||||||
|
assert.equal(refreshes, 1);
|
||||||
|
assert.equal(f.saved.registrations[0].tokens.refreshToken, "old-refresh", "failed write leaves only the stale predecessor on disk");
|
||||||
|
assert(pushes.some((push) => push.invalidate === "all" && push.stamp.epoch > catalog.epoch));
|
||||||
|
failWrite = false;
|
||||||
|
await f.service.status(); await f.service.models(); f.service.resume(); await f.service.consent(true);
|
||||||
|
await assert.rejects(f.rawLease(false, expected)); await assert.rejects(f.rawLease(true, expected));
|
||||||
|
assert.equal((await f.service.status()).accounts[0].connected, false);
|
||||||
|
assert.equal(reads, 1); assert.equal(refreshes, 1, "no stale-token retry after storage recovers");
|
||||||
|
} finally { f.service.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
test("serialized concurrent refresh rotates once and persists before leases", async () => {
|
test("serialized concurrent refresh rotates once and persists before leases", async () => {
|
||||||
let refreshes = 0;
|
let refreshes = 0;
|
||||||
const f = await loggedIn({ expires: 1, token: async (form, url) => {
|
const f = await loggedIn({ expires: 1, token: async (form, url) => {
|
||||||
@@ -208,29 +242,34 @@ test("account catalog is scoped, visibility filtered, and cleared on disconnect"
|
|||||||
} finally { f.service.close(); }
|
} finally { f.service.close(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
test("safeStorage refuses basic_text/unavailable; encrypted file owner-only, atomic, no plaintext fallback", async () => {
|
test("private JSON file round-trips global state; creation and atomic replacement are owner-only", async () => {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-storage-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-storage-"));
|
||||||
const file = path.join(dir, "credentials.enc.json");
|
const file = path.join(dir, "private", "chatgpt-credentials.json");
|
||||||
const secure = { isEncryptionAvailable: () => true, getSelectedStorageBackend: () => "keychain", encryptString: (s: string) => Buffer.from(s.split("").reverse().join("")), decryptString: (b: Buffer) => b.toString().split("").reverse().join("") };
|
|
||||||
try {
|
try {
|
||||||
const store = encryptedStore(file, secure);
|
const store = fileStore(file);
|
||||||
const data = { ...initial(), marker: "scratch-secret-never-plaintext" };
|
assert.equal(await store.read(), null);
|
||||||
|
const data = { ...initial(), allowBackgroundUse: false };
|
||||||
await store.write(data);
|
await store.write(data);
|
||||||
assert(!(await fs.readFile(file, "utf8")).includes(data.marker));
|
assert.deepEqual(JSON.parse(await fs.readFile(file, "utf8")), data, "ordinary JSON, not an encrypted envelope");
|
||||||
assert.deepEqual(await store.read(), data);
|
assert.deepEqual(await store.read(), data);
|
||||||
|
assert.equal((await fs.stat(path.dirname(file))).mode & 0o777, 0o700);
|
||||||
assert.equal((await fs.stat(file)).mode & 0o777, 0o600);
|
assert.equal((await fs.stat(file)).mode & 0o777, 0o600);
|
||||||
assert.deepEqual(await fs.readdir(dir), ["credentials.enc.json"]);
|
await fs.chmod(file, 0o644);
|
||||||
for (const backend of [{ ...secure, getSelectedStorageBackend: () => "basic_text" }, { ...secure, isEncryptionAvailable: () => false }]) {
|
await store.write({ ...data, allowBackgroundUse: true });
|
||||||
await assert.rejects(encryptedStore(file, backend).read(), /secure_storage/);
|
assert.equal((await fs.stat(file)).mode & 0o777, 0o600, "replacement never inherits a permissive old mode");
|
||||||
await assert.rejects(encryptedStore(file, backend).write(data), /secure_storage/);
|
assert.equal((await store.read()).allowBackgroundUse, true);
|
||||||
}
|
assert.deepEqual(await fs.readdir(path.dirname(file)), ["chatgpt-credentials.json"]);
|
||||||
assert(!allowedAuthEndpoint("https://auth.openai.com.evil.invalid/revoke"));
|
|
||||||
assert(!allowedAuthEndpoint("http://127.0.0.1/revoke"));
|
|
||||||
assert(!allowedAuthEndpoint("https://auth.openai.com@evil.invalid/revoke"));
|
|
||||||
} finally { await fs.rm(dir, { recursive: true, force: true }); }
|
} finally { await fs.rm(dir, { recursive: true, force: true }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
test("invalid rotating replacement is durably revocation-only; verified mapping and prior validated ID survive restart", async () => {
|
test("revocation endpoint remains restricted to the exact secure issuer", () => {
|
||||||
|
assert(allowedAuthEndpoint("https://auth.openai.com/api/accounts/oauth/revoke"));
|
||||||
|
assert(!allowedAuthEndpoint("https://auth.openai.com.evil.invalid/revoke"));
|
||||||
|
assert(!allowedAuthEndpoint("http://127.0.0.1/revoke"));
|
||||||
|
assert(!allowedAuthEndpoint("https://auth.openai.com@evil.invalid/revoke"));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const holdVerification of [false, true]) test(`invalid rotating replacement is durably revocation-only; verified mapping and prior validated ID survive restart${holdVerification ? " (held JWKS regression)" : ""}`, { timeout: 10000 }, async () => {
|
||||||
const f = await loggedIn({ expires: 1, token: async (form, url) => form.get("grant_type") === "refresh_token"
|
const f = await loggedIn({ expires: 1, token: async (form, url) => form.get("grant_type") === "refresh_token"
|
||||||
? Response.json({ access_token: "never-save-this-access", refresh_token: "likely-latest-revoke-handle", id_token: "invalid.signature.jwt", token_type: "Bearer", expires_in: 3600 })
|
? Response.json({ access_token: "never-save-this-access", refresh_token: "likely-latest-revoke-handle", id_token: "invalid.signature.jwt", token_type: "Bearer", expires_in: 3600 })
|
||||||
: Response.json({ access_token: "old-verified-access", refresh_token: "old-refresh", id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), token_type: "Bearer", expires_in: 1, scope: SCOPES.join(" ") }) });
|
: Response.json({ access_token: "old-verified-access", refresh_token: "old-refresh", id_token: await signed(form.get("client_id")!, url.searchParams.get("nonce")!), token_type: "Bearer", expires_in: 1, scope: SCOPES.join(" ") }) });
|
||||||
@@ -242,7 +281,22 @@ test("invalid rotating replacement is durably revocation-only; verified mapping
|
|||||||
for (const field of ["id", "issuer", "subject", "email", "clientId"]) assert.equal(restricted[field], before[field]);
|
for (const field of ["id", "issuer", "subject", "email", "clientId"]) assert.equal(restricted[field], before[field]);
|
||||||
assert.deepEqual(restricted.tokens, { ...before.tokens, refreshToken: "likely-latest-revoke-handle" });
|
assert.deepEqual(restricted.tokens, { ...before.tokens, refreshToken: "likely-latest-revoke-handle" });
|
||||||
assert(!JSON.stringify(f.saved).includes("never-save-this-access"));
|
assert(!JSON.stringify(f.saved).includes("never-save-this-access"));
|
||||||
const restarted = fixture({ state: f.saved });
|
const reconnectComplete = deferred<any>();
|
||||||
|
const verificationEntered = deferred(), verificationRelease = deferred();
|
||||||
|
let reconnectArmed = false, sawPending = false, completed = false;
|
||||||
|
const restarted = fixture({ state: f.saved,
|
||||||
|
change: (push) => {
|
||||||
|
if (!reconnectArmed) return;
|
||||||
|
if (push.status.pending) sawPending = true;
|
||||||
|
else if (sawPending) { completed = true; reconnectComplete.resolve(push.status); }
|
||||||
|
},
|
||||||
|
provider: async (url) => {
|
||||||
|
if (holdVerification && reconnectArmed && url.endsWith("jwks.json")) {
|
||||||
|
verificationEntered.resolve(); await verificationRelease.promise;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await restarted.service.status();
|
await restarted.service.status();
|
||||||
await restarted.service.select(restricted.id);
|
await restarted.service.select(restricted.id);
|
||||||
@@ -254,40 +308,97 @@ test("invalid rotating replacement is durably revocation-only; verified mapping
|
|||||||
await restarted.service.logout(restricted.id);
|
await restarted.service.logout(restricted.id);
|
||||||
assert.equal(restarted.requests.find((r) => r.form?.has("token_type_hint"))!.form!.get("token"), "likely-latest-revoke-handle");
|
assert.equal(restarted.requests.find((r) => r.form?.has("token_type_hint"))!.form!.get("token"), "likely-latest-revoke-handle");
|
||||||
assert.equal(restarted.saved.registrations[0].reconnectRequired, true);
|
assert.equal(restarted.saved.registrations[0].reconnectRequired, true);
|
||||||
await restarted.service.login(restricted.id); await restarted.callback(); await restarted.changed.promise;
|
// Earlier select/consent/logout notifications have already settled changed.
|
||||||
// Wait for callback's asynchronous encrypted activation, not the prior status push.
|
// Arm before this login and await its pending -> terminal transition instead.
|
||||||
for (let i = 0; i < 30 && restarted.saved.registrations[0].reconnectRequired; i++) await new Promise((resolve) => setImmediate(resolve));
|
reconnectArmed = true;
|
||||||
|
await restarted.service.login(restricted.id);
|
||||||
|
assert.equal(sawPending, true);
|
||||||
|
await restarted.callback(); // HTTP acknowledgement precedes verification/save.
|
||||||
|
if (holdVerification) {
|
||||||
|
await verificationEntered.promise;
|
||||||
|
assert.equal(completed, false, "the current attempt cannot complete before identity verification");
|
||||||
|
const held = await restarted.service.status();
|
||||||
|
assert.equal(held.pending, true); assert.equal(held.error, null);
|
||||||
|
assert.equal(held.accounts[0].connected, false); assert.equal(held.accounts[0].planEnabled, false);
|
||||||
|
assert.equal(restarted.saved.registrations[0].reconnectRequired, true);
|
||||||
|
assert.deepEqual((await restarted.service.models()).models, []);
|
||||||
|
const expected = { expectedAccountId: restricted.id, expectedAuthorityRevision: restarted.service.stamp().authorityRevision, selectedModelId: "gpt-5" };
|
||||||
|
await assert.rejects(restarted.rawLease(false, expected), /disconnected/);
|
||||||
|
await assert.rejects(restarted.rawLease(true, expected), /disconnected/);
|
||||||
|
assert.equal(completed, false, "status/catalog/lease checks cannot settle the pending attempt");
|
||||||
|
verificationRelease.resolve();
|
||||||
|
}
|
||||||
|
const terminal = await reconnectComplete.promise;
|
||||||
|
assert.equal(terminal.pending, false); assert.equal(terminal.error, null);
|
||||||
|
assert.equal(terminal.activeId, restricted.id);
|
||||||
|
assert.equal(terminal.accounts[0].connected, true);
|
||||||
assert.equal(restarted.saved.registrations[0].reconnectRequired, undefined);
|
assert.equal(restarted.saved.registrations[0].reconnectRequired, undefined);
|
||||||
} finally { restarted.service.close(); }
|
} finally { verificationRelease.resolve(); restarted.service.close(); }
|
||||||
} finally { f.service.close(); }
|
} finally { f.service.close(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
test("detected secure backend loss emits full invalidation, denies all leases and cannot be repaired by consent/resume", async () => {
|
for (const holdVerification of [false, true]) test(`observed file write failure invalidates all work; status, consent and resume cannot reload stale credentials or repair authority${holdVerification ? " (held JWKS regression)" : ""}`, { timeout: 10000 }, async () => {
|
||||||
let lost = false; const pushes: any[] = [];
|
let lost = false, reads = 0; const pushes: any[] = [];
|
||||||
const f = await loggedIn({ secure: () => { if (lost) throw Object.assign(new Error("secure_storage"), { code: "secure_storage" }); }, change: (push) => pushes.push(push) });
|
const reconnectComplete = deferred<any>();
|
||||||
|
const verificationEntered = deferred(), verificationRelease = deferred();
|
||||||
|
let reconnectArmed = false, sawPending = false, completed = false;
|
||||||
|
const f = await loggedIn({ read: () => { reads++; }, write: async () => { if (lost) throw Object.assign(new Error("storage"), { code: "storage" }); },
|
||||||
|
change: (push) => {
|
||||||
|
pushes.push(push);
|
||||||
|
if (!reconnectArmed) return;
|
||||||
|
if (push.status.pending) sawPending = true;
|
||||||
|
else if (sawPending) { completed = true; reconnectComplete.resolve(push.status); }
|
||||||
|
},
|
||||||
|
provider: async (url) => {
|
||||||
|
if (holdVerification && reconnectArmed && url.endsWith("jwks.json")) {
|
||||||
|
verificationEntered.resolve(); await verificationRelease.promise;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await f.service.lease(false); const epoch = f.service.stamp().epoch;
|
await f.service.lease(false); const epoch = f.service.stamp().epoch;
|
||||||
lost = true;
|
lost = true;
|
||||||
assert.equal((await f.service.status()).available, false);
|
await assert.rejects(f.service.consent(false), /storage/);
|
||||||
assert(pushes.some((p) => p.invalidate === "all" && p.stamp.epoch > epoch));
|
assert(pushes.some((p) => p.invalidate === "all" && p.stamp.epoch > epoch));
|
||||||
await assert.rejects(f.service.lease(false)); await assert.rejects(f.service.lease(true));
|
await assert.rejects(f.service.lease(false)); await assert.rejects(f.service.lease(true));
|
||||||
lost = false; f.service.resume(); await f.service.consent(true);
|
lost = false; f.service.resume(); await f.service.consent(true);
|
||||||
|
await f.service.status(); await f.service.models();
|
||||||
await assert.rejects(f.service.lease(false));
|
await assert.rejects(f.service.lease(false));
|
||||||
assert.equal((await f.service.status()).accounts[0].connected, false);
|
assert.equal((await f.service.status()).accounts[0].connected, false);
|
||||||
} finally { f.service.close(); }
|
assert.equal(reads, 1, "a recovered disk never causes a stale credential reload");
|
||||||
|
const writesBeforeReconnect = f.writeCount;
|
||||||
|
// The initial login already settled fixture.changed; observe this attempt instead.
|
||||||
|
reconnectArmed = true;
|
||||||
|
await f.service.login(f.saved.activeId); assert.equal(sawPending, true); await f.callback();
|
||||||
|
if (holdVerification) {
|
||||||
|
await verificationEntered.promise;
|
||||||
|
assert.equal(completed, false, "HTTP acknowledgement cannot complete a pending identity verification");
|
||||||
|
const held = await f.service.status();
|
||||||
|
assert.equal(held.pending, true); assert.equal(held.error, null);
|
||||||
|
assert.equal(held.accounts[0].connected, false);
|
||||||
|
assert.equal(f.writeCount, writesBeforeReconnect, "unverified recovery cannot persist credentials");
|
||||||
|
}
|
||||||
|
verificationRelease.resolve();
|
||||||
|
const terminal = await reconnectComplete.promise;
|
||||||
|
assert.equal(terminal.pending, false); assert.equal(terminal.error, null);
|
||||||
|
assert.equal(terminal.accounts[0].connected, true, "verified login plus durable save can explicitly reconnect");
|
||||||
|
assert.equal(f.writeCount, writesBeforeReconnect + 1, "recovery requires a new durable credential write");
|
||||||
|
assert.equal(f.saved.activeId, terminal.activeId);
|
||||||
|
assert.equal(reads, 1, "explicit recovery still never reloads stale credentials");
|
||||||
|
} finally { verificationRelease.resolve(); f.service.close(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
test("strict encrypted schema rejects malformed marker/duplicates/token/active identity without changing original bytes", async () => {
|
test("strict JSON schema rejects malformed marker/duplicates/token/active identity without changing original bytes", async () => {
|
||||||
const f = await loggedIn(); f.service.close();
|
const f = await loggedIn(); f.service.close();
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-corrupt-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-corrupt-"));
|
||||||
const file = path.join(dir, "credentials.enc.json");
|
const file = path.join(dir, "chatgpt-credentials.json");
|
||||||
const secure = { isEncryptionAvailable: () => true, encryptString: (s: string) => Buffer.from(s), decryptString: (b: Buffer) => b.toString() };
|
|
||||||
try {
|
try {
|
||||||
for (const mutate of [(s: any) => s.registrations[0].reconnectRequired = false, (s: any) => s.registrations[0].reconnectRequired = "true", (s: any) => s.registrations.push(s.registrations[0]), (s: any) => s.activeId = "missing", (s: any) => s.registrations[0].tokens.scopes = "not-array", (s: any) => s.registrations[0].tokens.expiresAt = -1, (s: any) => s.allowBackgroundUse = "true", (s: any) => s.allowBackgroundUse = null, (s: any) => s.registrations[0].background = "false"]) {
|
for (const mutate of [(s: any) => s.registrations[0].reconnectRequired = false, (s: any) => s.registrations[0].reconnectRequired = "true", (s: any) => s.registrations.push(s.registrations[0]), (s: any) => s.activeId = "missing", (s: any) => s.registrations[0].tokens.scopes = "not-array", (s: any) => s.registrations[0].tokens.expiresAt = -1, (s: any) => s.allowBackgroundUse = "true", (s: any) => s.allowBackgroundUse = null, (s: any) => s.registrations[0].background = "false"]) {
|
||||||
const state = structuredClone(f.saved); mutate(state);
|
const state = structuredClone(f.saved); mutate(state);
|
||||||
const bytes = JSON.stringify({ version: 1, encrypted: Buffer.from(JSON.stringify(state)).toString("base64") });
|
const bytes = JSON.stringify(state);
|
||||||
await fs.writeFile(file, bytes);
|
await fs.writeFile(file, bytes);
|
||||||
const service = createChatGPT({ store: encryptedStore(file, secure), openExternal: () => assert.fail("no OAuth"), fetch: () => assert.fail("no provider") });
|
const service = createChatGPT({ store: fileStore(file), openExternal: () => assert.fail("no OAuth"), fetch: () => assert.fail("no provider") });
|
||||||
try { assert.equal((await service.status()).available, false); await assert.rejects(service.login()); assert.equal(await fs.readFile(file, "utf8"), bytes); }
|
try { assert.equal((await service.status()).available, false); await assert.rejects(service.login()); assert.equal(await fs.readFile(file, "utf8"), bytes); }
|
||||||
finally { service.close(); }
|
finally { service.close(); }
|
||||||
}
|
}
|
||||||
@@ -459,7 +570,7 @@ test("disconnect racing refresh revokes the latest rotated handle before forgett
|
|||||||
} finally { release.resolve(); f.service.close(); }
|
} finally { release.resolve(); f.service.close(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
test("disconnect persistence failure is not reported as local forgetting and leaves encrypted state fail-closed", async () => {
|
test("disconnect persistence failure is not reported as local forgetting and leaves stored state fail-closed", async () => {
|
||||||
let failWrite = false;
|
let failWrite = false;
|
||||||
const f = await loggedIn({ write: async () => { if (failWrite) throw Object.assign(new Error("storage"), { code: "storage" }); } });
|
const f = await loggedIn({ write: async () => { if (failWrite) throw Object.assign(new Error("storage"), { code: "storage" }); } });
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { test, after } from "node:test";
|
import { test, before, after } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
@@ -21,7 +21,20 @@ let revision = 1;
|
|||||||
function connection(background: boolean) {
|
function connection(background: boolean) {
|
||||||
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 1, authorityRevision: 1, revision: revision++ }, invalidate: background ? "none" : "background", status: { available: true, pending: false, allowBackgroundUse: background, activeId: "scratch-account", accounts: [{ id: "scratch-account", connected: true, planEnabled: true, background: !background }], error: null } });
|
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 1, authorityRevision: 1, revision: revision++ }, invalidate: background ? "none" : "background", status: { available: true, pending: false, allowBackgroundUse: background, activeId: "scratch-account", accounts: [{ id: "scratch-account", connected: true, planEnabled: true, background: !background }], error: null } });
|
||||||
}
|
}
|
||||||
after(() => { fs.rmSync(dir, { recursive: true, force: true }); });
|
const originalSend = Object.getOwnPropertyDescriptor(process, "send"), originalConnected = Object.getOwnPropertyDescriptor(process, "connected");
|
||||||
|
const desktopMarker = process.env.LH_DESKTOP_SERVER, desktopSecret = process.env.LH_DESKTOP_API_SECRET;
|
||||||
|
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 desktop request")); return false; } });
|
||||||
|
});
|
||||||
|
after(() => {
|
||||||
|
fs.rmSync(dir, { 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 (desktopMarker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = desktopMarker;
|
||||||
|
if (desktopSecret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = desktopSecret;
|
||||||
|
});
|
||||||
|
|
||||||
for (const revokedAfterQueue of [false, true]) {
|
for (const revokedAfterQueue of [false, true]) {
|
||||||
test(`mixed learner+automatic batch: foreground runs, automatic result stays owned/deferred without consent (${revokedAfterQueue ? "revoked after handoff" : "initially off"})`, { timeout: 5000 }, async () => {
|
test(`mixed learner+automatic batch: foreground runs, automatic result stays owned/deferred without consent (${revokedAfterQueue ? "revoked after handoff" : "initially off"})`, { timeout: 5000 }, async () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
|||||||
import { piModels } from "../src/agent/models.ts";
|
import { piModels } from "../src/agent/models.ts";
|
||||||
import { refreshChatGPTModels, chatGPTModels, chatGPTStatus, chatGPTScope, chatGPTScopeCurrent, chatGPTLease, chatGPTAbortController } from "../src/agent/chatgpt-bridge.ts";
|
import { refreshChatGPTModels, chatGPTModels, chatGPTStatus, chatGPTScope, chatGPTScopeCurrent, chatGPTLease, chatGPTAbortController } from "../src/agent/chatgpt-bridge.ts";
|
||||||
const send = Object.getOwnPropertyDescriptor(process, "send"), connected = Object.getOwnPropertyDescriptor(process, "connected");
|
const send = Object.getOwnPropertyDescriptor(process, "send"), connected = Object.getOwnPropertyDescriptor(process, "connected");
|
||||||
|
const desktopMarker = process.env.LH_DESKTOP_SERVER, desktopSecret = process.env.LH_DESKTOP_API_SECRET;
|
||||||
const pending: any[] = [];
|
const pending: any[] = [];
|
||||||
const model = piModels().getModels("openai").find((m) => m.api === "openai-responses")!;
|
const model = piModels().getModels("openai").find((m) => m.api === "openai-responses")!;
|
||||||
const ipc = (m: unknown) => (process.emit as (name: string, value: unknown) => boolean)("message", m);
|
const ipc = (m: unknown) => (process.emit as (name: string, value: unknown) => boolean)("message", m);
|
||||||
@@ -10,10 +11,13 @@ const status = (account: string, background = true) => ({ available: true, pendi
|
|||||||
const value = (name: string, revision: number, account = "A", authorityRevision = 1) => ({ epoch: account === "A" ? 1 : 2, authorityRevision, revision, status: status(account), accountId: account, models: [{ id: model.id, name }] });
|
const value = (name: string, revision: number, account = "A", authorityRevision = 1) => ({ epoch: account === "A" ? 1 : 2, authorityRevision, revision, status: status(account), accountId: account, models: [{ id: model.id, name }] });
|
||||||
function reply(request: any, v: unknown) { ipc({ channel: "lh:chatgpt", id: request.id, op: request.op, ok: true, value: v }); }
|
function reply(request: any, v: unknown) { ipc({ channel: "lh:chatgpt", id: request.id, op: request.op, ok: true, value: v }); }
|
||||||
before(() => {
|
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, "connected", { configurable: true, value: true });
|
||||||
Object.defineProperty(process, "send", { configurable: true, value: (m: unknown, cb?: () => void) => { pending.push(m); cb?.(); return true; } });
|
Object.defineProperty(process, "send", { configurable: true, value: (m: unknown, cb?: () => void) => { pending.push(m); cb?.(); return true; } });
|
||||||
});
|
});
|
||||||
after(() => {
|
after(() => {
|
||||||
|
if (desktopMarker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = desktopMarker;
|
||||||
|
if (desktopSecret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = desktopSecret;
|
||||||
if (send) Object.defineProperty(process, "send", send); else delete process.send;
|
if (send) Object.defineProperty(process, "send", send); else delete process.send;
|
||||||
if (connected) Object.defineProperty(process, "connected", connected); else delete (process as { connected?: boolean }).connected;
|
if (connected) Object.defineProperty(process, "connected", connected); else delete (process as { connected?: boolean }).connected;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ 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, localStorage: { getItem: () => null, setItem() {} } });
|
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, localStorage: { getItem: () => null, setItem() {} } });
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
let root: Root | undefined, host: HTMLDivElement | undefined;
|
let root: Root | undefined, host: HTMLDivElement | undefined;
|
||||||
const account = (id = "private-registration-id", email = "learner@example.invalid", connected = true) => ({ id, email, registration: "private-issued-client", connected, planEnabled: connected });
|
const account = (id = "private-registration-id", email = "learner@example.invalid", connected = true) => ({ id, email, registration: "private-issued-client", connected, planEnabled: connected, canSignInAgain: !connected });
|
||||||
const disconnected = (allowBackgroundUse = true): ChatGPTStatus => ({ available: true, pending: false, allowBackgroundUse, activeId: null, accounts: [account("signed-out-record", "signed-out@example.invalid", false)], error: null });
|
const disconnected = (allowBackgroundUse = true): ChatGPTStatus => ({ available: true, pending: false, allowBackgroundUse, activeId: null, accounts: [account("signed-out-record", "signed-out@example.invalid", false)], error: null });
|
||||||
const connected = (allowBackgroundUse = true): ChatGPTStatus => ({ ...disconnected(allowBackgroundUse), activeId: "private-registration-id", accounts: [account(), ...disconnected().accounts] });
|
const connected = (allowBackgroundUse = true): ChatGPTStatus => ({ ...disconnected(allowBackgroundUse), activeId: "private-registration-id", accounts: [account(), ...disconnected().accounts] });
|
||||||
const storageError = "ChatGPT credentials could not be saved securely. Reconnect before making requests.";
|
const storageError = "ChatGPT credentials could not be read or saved. Check local file access, then reconnect before making requests.";
|
||||||
const warning = "Account disconnected locally. Remote revocation could not be confirmed.";
|
const warning = "Account disconnected locally. Remote revocation could not be confirmed.";
|
||||||
const errorReply = () => ({ error: storageError } as ChatGPTStatus);
|
const errorReply = () => ({ error: storageError } as ChatGPTStatus);
|
||||||
function deferred<T>() { let resolve!: (v: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { promise, resolve }; }
|
function deferred<T>() { let resolve!: (v: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { promise, resolve }; }
|
||||||
@@ -42,6 +42,16 @@ function fixture(initial = disconnected(), overrides: Partial<ChatGPTControlBrid
|
|||||||
window.lh = { expandWindow() {}, chatgpt: bridge };
|
window.lh = { expandWindow() {}, chatgpt: bridge };
|
||||||
return { calls, bridge, push, get status() { return status; }, complete() { push({ ...connected(status.allowBackgroundUse), pending: false }); } };
|
return { calls, bridge, push, get status() { return status; }, complete() { push({ ...connected(status.allowBackgroundUse), pending: false }); } };
|
||||||
}
|
}
|
||||||
|
function recoveryController({ writeFails = false, tokens = "valid" }: { writeFails?: boolean; tokens?: "valid" | "missing" | "restricted" | "scope" | "expired" } = {}) {
|
||||||
|
const require = createRequire(import.meta.url); const { createChatGPT, SCOPES } = require("../electron/chatgpt.cjs"); const { chatGPTControl } = require("../electron/chatgpt-ipc.cjs");
|
||||||
|
const registration: any = { id: "fixture", clientId: "oaiapp_fixture", issuer: "https://auth.openai.com", subject: "fixture", email: "fixture@example.invalid", tokens: { accessToken: "fake-access", refreshToken: "fake-refresh", idToken: "fake-id", expiresAt: tokens === "expired" ? 0 : Date.now() + 3600000, scopes: tokens === "scope" ? ["openid"] : [...SCOPES] } };
|
||||||
|
if (tokens === "missing") delete registration.tokens; if (tokens === "restricted") registration.reconnectRequired = true;
|
||||||
|
let saved = { version: 1, hostId: "urn:uuid:offline-recovery", activeId: "fixture", allowBackgroundUse: true, registrations: [registration] };
|
||||||
|
let browserAttempts = 0, tokenRequests = 0; const listeners = new Set<() => void>();
|
||||||
|
const service = createChatGPT({ store: { read: async () => structuredClone(saved), write: async (next: typeof saved) => { if (writeFails) throw Object.assign(new Error("fake-storage-failure"), { code: "storage" }); saved = structuredClone(next); } }, openExternal: () => { browserAttempts++; throw new Error("No real browser"); }, fetch: async (url: string) => { tokenRequests++; assert.equal(tokens, "expired"); assert.equal(url, "https://auth.openai.com/api/accounts/oauth/token"); return Response.json({ error: "invalid_grant" }, { status: 401 }); }, onChange: () => { for (const cb of listeners) cb(); } });
|
||||||
|
window.lh = { expandWindow() {}, chatgpt: { status: () => chatGPTControl({ op: "status" }, service), consent: (enabled) => chatGPTControl({ op: "consent", enabled }, service), login: (id) => chatGPTControl({ op: "login", id }, service), cancel: () => chatGPTControl({ op: "cancel" }, service), select: (id) => chatGPTControl({ op: "select", id }, service), disconnect: (id) => chatGPTControl({ op: "disconnect", id }, service), logout: (id) => chatGPTControl({ op: "logout", id }, service), onChange: (cb) => { listeners.add(cb); return () => listeners.delete(cb); } } };
|
||||||
|
return { service, get saved() { return saved; }, get browserAttempts() { return browserAttempts; }, get tokenRequests() { return tokenRequests; } };
|
||||||
|
}
|
||||||
async function mount(children: React.ReactNode = <ChatGPTConnection />) {
|
async function mount(children: React.ReactNode = <ChatGPTConnection />) {
|
||||||
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
|
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
|
||||||
await act(async () => root!.render(<ChatGPTUIProvider>{children}</ChatGPTUIProvider>));
|
await act(async () => root!.render(<ChatGPTUIProvider>{children}</ChatGPTUIProvider>));
|
||||||
@@ -82,12 +92,15 @@ 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"));
|
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 />);
|
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(!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/);
|
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));
|
await click(trigger); assert.equal(f.calls.login.length, 0); assert(document.body.textContent?.includes(CHATGPT_NOTICE));
|
||||||
|
assert.equal(byTest("chatgpt-account-dialog").querySelector("h2")?.textContent, "ChatGPT");
|
||||||
|
assert.equal(byTest("chatgpt-proceed").textContent, "Continue with ChatGPT"); assert(byTest("chatgpt-proceed").querySelector('img[src="/siwc/chatgpt-logo-white.svg"]'));
|
||||||
|
assert(!button("Continue", byTest("chatgpt-account-dialog")), "ChatGPT remains one step, with disclosure and toggle together");
|
||||||
assert.equal(toggle().getAttribute("aria-checked"), "true");
|
assert.equal(toggle().getAttribute("aria-checked"), "true");
|
||||||
assert.equal(toggle().getAttribute("aria-label"), "Allow background use for all ChatGPT accounts");
|
assert.equal(toggle().getAttribute("aria-label"), "Allow background use for all ChatGPT accounts");
|
||||||
assert(byTest("chatgpt-global-background").textContent?.includes("Background work counts toward your ChatGPT plan usage."));
|
assert(byTest("chatgpt-global-background").textContent?.includes("Background work counts toward your ChatGPT plan usage."));
|
||||||
@@ -180,23 +193,33 @@ test("email-only connected selector and Manage remain responsive; account select
|
|||||||
assert.deepEqual([...document.querySelectorAll('select[aria-label="ChatGPT account"] option')].map((o) => o.textContent), ["learner@example.invalid", "second@example.invalid", "ChatGPT account"]);
|
assert.deepEqual([...document.querySelectorAll('select[aria-label="ChatGPT account"] option')].map((o) => o.textContent), ["learner@example.invalid", "second@example.invalid", "ChatGPT account"]);
|
||||||
assert.doesNotMatch(byTest("chatgpt-connection").textContent!, /private-registration|private-issued| · active|signed-out/);
|
assert.doesNotMatch(byTest("chatgpt-connection").textContent!, /private-registration|private-issued| · active|signed-out/);
|
||||||
assert.match(byTest("chatgpt-connection").firstElementChild!.className, /items-center/);
|
assert.match(byTest("chatgpt-connection").firstElementChild!.className, /items-center/);
|
||||||
|
const row = byTest("chatgpt-connection").firstElementChild!;
|
||||||
|
assert.equal(row.children[0]!.textContent, "ChatGPT"); assert.equal(row.children[1]!.tagName, "SELECT"); assert.equal(row.children[2]!.textContent, "Manage…");
|
||||||
|
assert.match(row.children[2]!.className, /text-muted-foreground/); assert(!row.textContent?.includes("Connected"));
|
||||||
await selectAccount("ChatGPT account", "second"); assert.deepEqual(f.calls.select, ["second"]); assert.equal(f.status.allowBackgroundUse, false);
|
await selectAccount("ChatGPT account", "second"); assert.deepEqual(f.calls.select, ["second"]); assert.equal(f.status.allowBackgroundUse, false);
|
||||||
await click(button("Manage…")); assert(byTest("chatgpt-managed-identity").textContent?.includes("second@example.invalid"));
|
await click(button("Manage…")); assert(byTest("chatgpt-managed-identity").textContent?.includes("second@example.invalid"));
|
||||||
assert.deepEqual([...byTest("chatgpt-account-footer").querySelectorAll("button")].map((b) => b.textContent), ["Add accounts", "Close"]);
|
assert.deepEqual([...byTest("chatgpt-account-footer").querySelectorAll("button")].map((b) => b.textContent), ["Disconnect", "Close"]);
|
||||||
const dialog = byTest("chatgpt-account-dialog").innerHTML;
|
const dialog = byTest("chatgpt-account-dialog").innerHTML;
|
||||||
assert(dialog.indexOf('data-testid="chatgpt-managed-identity"') < dialog.indexOf('data-testid="chatgpt-account-actions"'));
|
assert(dialog.indexOf('data-testid="chatgpt-managed-identity"') < dialog.indexOf('data-testid="chatgpt-account-actions"'));
|
||||||
assert(dialog.indexOf('data-testid="chatgpt-account-actions"') < dialog.indexOf('data-testid="chatgpt-global-background"'));
|
assert(dialog.indexOf('data-testid="chatgpt-global-background"') < dialog.indexOf('data-testid="chatgpt-managed-identity"'));
|
||||||
|
assert(!byTest("chatgpt-account-dialog").querySelector('[data-testid="chatgpt-privacy-notice"]'));
|
||||||
|
assert.equal(byTest("chatgpt-account-dialog").querySelector("h2")?.textContent, "ChatGPT");
|
||||||
|
assert(byTest("chatgpt-account-dialog").querySelector('a[href="https://openai.com/policies/privacy-policy/"]'));
|
||||||
|
assert(byTest("chatgpt-account-dialog").querySelector('a[href="https://chatgpt.com/settings/usage"]'));
|
||||||
await click(toggle()); assert.deepEqual(f.calls.consent, [true]); assert.equal(toggle().getAttribute("aria-checked"), "true");
|
await click(toggle()); assert.deepEqual(f.calls.consent, [true]); assert.equal(toggle().getAttribute("aria-checked"), "true");
|
||||||
await click(button("Close")); await selectAccount("ChatGPT account", "private-registration-id"); assert.equal(f.status.allowBackgroundUse, true);
|
await click(button("Close")); await selectAccount("ChatGPT account", "private-registration-id"); assert.equal(f.status.allowBackgroundUse, true);
|
||||||
assert.equal(f.calls.logout, 0);
|
assert.equal(f.calls.logout, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Disconnect account stays under identity, outside the nondestructive Manage footer; confirmation Cancel never forgets", async () => {
|
test("Disconnect is the quiet red left-footer action below identity; confirmation Cancel never forgets", async () => {
|
||||||
assert.deepEqual(CHATGPT_DISCONNECT_COPY, { title: "Disconnect account?", body: "This removes the saved account and its credentials from this device." });
|
assert.deepEqual(CHATGPT_DISCONNECT_COPY, { title: "Disconnect account?", body: "This removes the saved account and its credentials from this device." });
|
||||||
const f = fixture(connected()); await mount(); await click(button("Manage…"));
|
const f = fixture(connected()); await mount(); await click(button("Manage…"));
|
||||||
assert.equal(byTest("chatgpt-account-actions").textContent, "Disconnect account"); assert.match(byTest("chatgpt-disconnect").className, /text-red/);
|
assert.equal(byTest("chatgpt-account-actions").textContent, "Disconnect"); assert.match(byTest("chatgpt-disconnect").className, /text-red/);
|
||||||
assert(!byTest("chatgpt-account-footer").contains(byTest("chatgpt-disconnect")));
|
assert.equal(byTest("chatgpt-account-footer").firstElementChild, byTest("chatgpt-account-actions"));
|
||||||
|
assert.equal(byTest("chatgpt-account-footer").lastElementChild?.lastElementChild, button("Close"));
|
||||||
|
assert(!byTest("chatgpt-account-footer").contains(byTest("chatgpt-add-accounts")));
|
||||||
await click(byTest("chatgpt-disconnect")); assert.equal(f.calls.disconnect.length, 0);
|
await click(byTest("chatgpt-disconnect")); assert.equal(f.calls.disconnect.length, 0);
|
||||||
|
assert.equal(byTest("chatgpt-account-dialog").querySelector("h2")?.textContent, "ChatGPT");
|
||||||
assert(byTest("chatgpt-disconnect-confirmation").textContent?.includes(CHATGPT_DISCONNECT_COPY.title));
|
assert(byTest("chatgpt-disconnect-confirmation").textContent?.includes(CHATGPT_DISCONNECT_COPY.title));
|
||||||
assert(byTest("chatgpt-disconnect-confirmation").textContent?.includes(CHATGPT_DISCONNECT_COPY.body));
|
assert(byTest("chatgpt-disconnect-confirmation").textContent?.includes(CHATGPT_DISCONNECT_COPY.body));
|
||||||
assert(!document.body.textContent?.includes("delete your OpenAI"));
|
assert(!document.body.textContent?.includes("delete your OpenAI"));
|
||||||
@@ -208,8 +231,9 @@ for (const count of [1, 3]) test(`Add accounts reuses the actual add flow with $
|
|||||||
const initial = connected(count === 3);
|
const initial = connected(count === 3);
|
||||||
initial.accounts = [account(), ...Array.from({ length: count - 1 }, (_, i) => account(`saved-${i}`, `saved-${i}@example.invalid`))];
|
initial.accounts = [account(), ...Array.from({ length: count - 1 }, (_, i) => account(`saved-${i}`, `saved-${i}@example.invalid`))];
|
||||||
const f = fixture(initial); const before = structuredClone(initial); await mount(); await click(button("Manage…"));
|
const f = fixture(initial); const before = structuredClone(initial); await mount(); await click(button("Manage…"));
|
||||||
assert.deepEqual([...byTest("chatgpt-account-footer").querySelectorAll("button")].map((b) => b.textContent), ["Add accounts", "Close"]);
|
assert.deepEqual([...byTest("chatgpt-account-footer").querySelectorAll("button")].map((b) => b.textContent), ["Disconnect", "Close"]);
|
||||||
assert.equal(byTest("chatgpt-add-accounts").getAttribute("data-variant"), "outline");
|
assert.equal(byTest("chatgpt-add-accounts").getAttribute("data-variant"), "ghost");
|
||||||
|
assert.equal(byTest("chatgpt-add-accounts").parentElement, byTest("chatgpt-account-strip"));
|
||||||
assert.match(byTest("chatgpt-add-accounts").className, /cursor-pointer/);
|
assert.match(byTest("chatgpt-add-accounts").className, /cursor-pointer/);
|
||||||
await click(byTest("chatgpt-add-accounts"));
|
await click(byTest("chatgpt-add-accounts"));
|
||||||
assert(byTest("chatgpt-privacy-notice").textContent?.includes(CHATGPT_NOTICE)); assert(byTest("chatgpt-proceed"));
|
assert(byTest("chatgpt-privacy-notice").textContent?.includes(CHATGPT_NOTICE)); assert(byTest("chatgpt-proceed"));
|
||||||
@@ -226,6 +250,68 @@ for (const count of [1, 3]) test(`Add accounts reuses the actual add flow with $
|
|||||||
assert.deepEqual(f.status, before); assert.equal(f.calls.cancel, 1); assert.deepEqual(f.calls.consent, []); assert.deepEqual(f.calls.disconnect, []);
|
assert.deepEqual(f.status, before); assert.equal(f.calls.cancel, 1); assert.deepEqual(f.calls.consent, []); assert.deepEqual(f.calls.disconnect, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("ChatGPT Sign in again is reserved for a saved disconnected account and keeps its existing registration flow", async () => {
|
||||||
|
const f = fixture(connected(false)); await mount(); await click(button("Manage…"));
|
||||||
|
assert(!button("Sign in again")); await selectAccount("Manage ChatGPT account", "signed-out-record"); assert(button("Sign in again"));
|
||||||
|
await click(button("Sign in again")); assert.equal(byTest("chatgpt-account-dialog").querySelector("h2")?.textContent, "ChatGPT");
|
||||||
|
assert(byTest("chatgpt-privacy-notice")); assert(toggle()); assert.equal(toggle().getAttribute("aria-checked"), "false");
|
||||||
|
assert.equal(document.querySelector<HTMLSelectElement>('select[aria-label="Saved ChatGPT account"]')?.value, "signed-out-record"); assert.deepEqual(f.calls.login, []);
|
||||||
|
await click(byTest("chatgpt-proceed")); assert.deepEqual(f.calls.login, ["signed-out-record"]); assert.equal(f.status.activeId, "private-registration-id");
|
||||||
|
await click(button("Cancel", byTest("chatgpt-account-dialog"))); assert.equal(f.calls.cancel, 1); assert.deepEqual(f.calls.disconnect, []); assert.deepEqual(f.calls.consent, []);
|
||||||
|
});
|
||||||
|
test("actual controller storage-write failure is not ChatGPT authorization recovery even while available stays true", async () => {
|
||||||
|
const f = recoveryController({ writeFails: true });
|
||||||
|
try {
|
||||||
|
await mount(); await click(button("Manage…")); assert(!button("Sign in again")); await click(toggle());
|
||||||
|
const status = await f.service.status(); assert.equal(status.available, true); assert.equal(status.pending, false); assert.equal(status.accounts[0].connected, false); assert.equal(status.accounts[0].planEnabled, false); assert.equal(status.error, storageError);
|
||||||
|
assert(!button("Sign in again"), "a failed credential write must not offer reauthorization");
|
||||||
|
assert.equal(status.accounts[0].canSignInAgain, false); assert.equal(f.browserAttempts, 0); assert.equal(f.tokenRequests, 0);
|
||||||
|
} finally { await act(async () => f.service.close()); }
|
||||||
|
});
|
||||||
|
for (const tokens of ["missing", "restricted", "scope"] as const) test(`actual controller ${tokens} authorization permits recovery, but paused/closed state does not`, async () => {
|
||||||
|
const f = recoveryController({ tokens });
|
||||||
|
try {
|
||||||
|
await mount(); await click(byTest("chatgpt-login")); await selectAccount("Saved ChatGPT account", "fixture"); await click(button("Manage…"));
|
||||||
|
assert.equal((await f.service.status()).accounts[0].canSignInAgain, true); assert(button("Sign in again"));
|
||||||
|
assert.equal(f.saved.registrations[0].canSignInAgain, undefined, "eligibility is projected, never persisted");
|
||||||
|
await act(async () => f.service.pause()); assert.equal((await f.service.status()).accounts[0].canSignInAgain, false); assert(!button("Sign in again"));
|
||||||
|
await act(async () => f.service.resume()); assert.equal((await f.service.status()).accounts[0].canSignInAgain, true); assert(button("Sign in again"));
|
||||||
|
await act(async () => f.service.close()); assert.equal((await f.service.status()).accounts[0].canSignInAgain, false); assert(!button("Sign in again"));
|
||||||
|
assert.equal(f.browserAttempts, 0); assert.equal(f.tokenRequests, 0);
|
||||||
|
} finally { await act(async () => f.service.close()); }
|
||||||
|
});
|
||||||
|
test("storage denial overrides an otherwise eligible remembered ChatGPT authorization recovery", async () => {
|
||||||
|
const f = recoveryController({ tokens: "missing", writeFails: true });
|
||||||
|
try {
|
||||||
|
await mount(); await click(byTest("chatgpt-login")); await selectAccount("Saved ChatGPT account", "fixture"); await click(button("Manage…")); assert(button("Sign in again"));
|
||||||
|
await click(toggle()); const status = await f.service.status(); assert.equal(status.available, true); assert.equal(status.error, storageError); assert.equal(status.accounts[0].canSignInAgain, false); assert(!button("Sign in again"));
|
||||||
|
assert.equal(f.browserAttempts, 0); assert.equal(f.tokenRequests, 0);
|
||||||
|
} finally { await act(async () => f.service.close()); }
|
||||||
|
});
|
||||||
|
test("actual invalid_grant recovery preserves remembered identity and stages the existing registration flow", async () => {
|
||||||
|
const f = recoveryController({ tokens: "expired" });
|
||||||
|
try {
|
||||||
|
await mount(); await click(button("Manage…")); assert.equal((await f.service.status()).accounts[0].canSignInAgain, false); assert(!button("Sign in again"));
|
||||||
|
await act(async () => { await f.service.models(); });
|
||||||
|
const status = await f.service.status(); assert.equal(status.available, true); assert.equal(status.accounts[0].connected, false); assert.equal(status.accounts[0].canSignInAgain, true);
|
||||||
|
assert.equal(status.accounts[0].id, "fixture"); assert.equal(status.accounts[0].registration, "oaiapp_fixture"); assert.equal(f.saved.registrations[0].tokens, undefined);
|
||||||
|
await click(button("Sign in again")); assert(byTest("chatgpt-privacy-notice")); assert(toggle()); assert.equal(document.querySelector<HTMLSelectElement>('select[aria-label="Saved ChatGPT account"]')?.value, "fixture");
|
||||||
|
assert.equal(f.tokenRequests, 1, "only the injected refresh response was requested"); assert.equal(f.browserAttempts, 0, "staging recovery never starts OAuth");
|
||||||
|
} finally { await act(async () => f.service.close()); }
|
||||||
|
});
|
||||||
|
test("missing eligibility remains compatible but never implies authorization recovery; malformed eligibility is rejected", async () => {
|
||||||
|
const initial = connected(); initial.accounts = [account()]; const f = fixture(initial); await mount(); await click(button("Manage…"));
|
||||||
|
const legacy: ChatGPTStatus = { ...initial, accounts: initial.accounts.map(({ canSignInAgain: _ignored, ...a }) => ({ ...a, connected: false, planEnabled: false })) };
|
||||||
|
await act(async () => f.push(legacy)); assert(!button("Sign in again")); assert(!document.querySelector('[role="alert"]'));
|
||||||
|
for (const value of [null, "true", 1]) {
|
||||||
|
await act(async () => f.push({ ...legacy, accounts: legacy.accounts.map((a) => ({ ...a, canSignInAgain: value })) } as unknown as ChatGPTStatus));
|
||||||
|
assert(!button("Sign in again")); assert.equal(document.querySelector('[role="alert"]')?.textContent, "The ChatGPT connection could not be changed. Try again.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("connected setup row retains the email dropdown before muted Manage", async () => {
|
||||||
|
fixture(connected()); await mount(<ChatGPTConnection setup />);
|
||||||
|
const row = byTest("chatgpt-connection").firstElementChild!; assert.equal(row.children[1]!.tagName, "SELECT"); assert.equal(row.children[2]!.textContent, "Manage…"); assert.match(row.children[2]!.className, /text-muted-foreground/);
|
||||||
|
});
|
||||||
test("Add accounts reuses the existing dialog's initial focus for the pre-auth transition", async () => {
|
test("Add accounts reuses the existing dialog's initial focus for the pre-auth transition", async () => {
|
||||||
const previous = HTMLElement.prototype.focus; const focused: HTMLElement[] = [];
|
const previous = HTMLElement.prototype.focus; const focused: HTMLElement[] = [];
|
||||||
HTMLElement.prototype.focus = function () { focused.push(this); };
|
HTMLElement.prototype.focus = function () { focused.push(this); };
|
||||||
@@ -261,7 +347,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 () => {
|
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…"));
|
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(byTest("chatgpt-managed-identity").textContent?.includes("signed-out@example.invalid"));
|
||||||
assert.deepEqual(f.calls.select, []); assert.equal(f.calls.login.length, 0); await confirmDisconnect();
|
assert.deepEqual(f.calls.select, []); assert.equal(f.calls.login.length, 0); await confirmDisconnect();
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test, { after } from "node:test";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-dev-ipc-"));
|
||||||
|
process.env.LH_DATA_DIR = scratch;
|
||||||
|
process.env.LH_AUTH_FILE = path.join(scratch, "auth.json");
|
||||||
|
fs.writeFileSync(process.env.LH_AUTH_FILE, "{}", { mode: 0o600 });
|
||||||
|
process.env.OPENAI_API_KEY = "sk-fixture-not-used-for-generation";
|
||||||
|
const { GET } = await import("../app/api/models/route.ts");
|
||||||
|
const { chatGPTRequest, chatGPTConnected, chatGPTModels } = await import("../src/agent/chatgpt-bridge.ts");
|
||||||
|
after(() => fs.rmSync(scratch, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
test("Next-style connected IPC is not desktop transport and never delays keyed API catalogs", async () => {
|
||||||
|
const send = Object.getOwnPropertyDescriptor(process, "send");
|
||||||
|
const connected = Object.getOwnPropertyDescriptor(process, "connected");
|
||||||
|
const marker = process.env.LH_DESKTOP_SERVER, secret = process.env.LH_DESKTOP_API_SECRET;
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let sends = 0;
|
||||||
|
try {
|
||||||
|
globalThis.fetch = async () => { throw new Error("provider/network calls forbidden in catalog fixture"); };
|
||||||
|
Object.defineProperty(process, "send", { configurable: true, value: (_message: unknown, callback?: (error: Error) => void) => { sends++; callback?.(new Error("not a desktop parent")); return false; } });
|
||||||
|
Object.defineProperty(process, "connected", { configurable: true, value: true });
|
||||||
|
for (const [mode, capability] of [[undefined, undefined], ["1", undefined], [undefined, "d".repeat(64)], ["1", "short"]]) {
|
||||||
|
if (mode === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = mode;
|
||||||
|
if (capability === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = capability;
|
||||||
|
// A framework parent cannot establish ChatGPT state by sharing only IPC.
|
||||||
|
(process.emit as (name: string, value: unknown) => boolean)("message", { channel: "lh:chatgpt", kind: "state", stamp: { epoch: 1, authorityRevision: 1, revision: 1 }, status: { available: true, activeId: "fixture", accounts: [{ id: "fixture", connected: true, planEnabled: true }] } });
|
||||||
|
const response = await GET();
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
const catalog = await response.json();
|
||||||
|
assert.ok(catalog.models.some((m: { provider: string }) => m.provider === "openai"));
|
||||||
|
assert.equal(catalog.providers.find((p: { id: string }) => p.id === "openai").keyPresent, true);
|
||||||
|
assert.equal(catalog.providers.find((p: { id: string }) => p.id === "chatgpt").keyPresent, false);
|
||||||
|
assert.equal(chatGPTConnected(), false);
|
||||||
|
assert.deepEqual(chatGPTModels(), []);
|
||||||
|
await assert.rejects(chatGPTRequest("status"), /available only in the desktop app/);
|
||||||
|
assert.equal(sends, 0, "no desktop request/timer may be started over framework IPC");
|
||||||
|
}
|
||||||
|
process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64);
|
||||||
|
Object.defineProperty(process, "connected", { configurable: true, value: false });
|
||||||
|
await GET();
|
||||||
|
await assert.rejects(chatGPTRequest("status"), /available only in the desktop app/);
|
||||||
|
assert.equal(sends, 0, "desktop markers without connected IPC do not suffice");
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
if (send) Object.defineProperty(process, "send", send); else delete process.send;
|
||||||
|
if (connected) Object.defineProperty(process, "connected", connected); 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { createChatGPT, fileStore } = require("../electron/chatgpt.cjs");
|
||||||
|
const initial = () => ({ version: 1, hostId: "urn:uuid:scratch-host", activeId: null, registrations: [] });
|
||||||
|
const account = (id: string, subject: string) => ({
|
||||||
|
id, clientId: `oaiapp_${id}`, issuer: "https://auth.openai.com", subject, email: `${subject}@example.invalid`,
|
||||||
|
tokens: { accessToken: `scratch-access-${id}`, refreshToken: `scratch-refresh-${id}`, idToken: `scratch-id-${id}`, expiresAt: Date.now() + 3600000, scopes: ["openid", "profile", "email", "offline_access", "resource.invoke", "chatgpt.tokens.use.direct"] },
|
||||||
|
});
|
||||||
|
function service(store: unknown) {
|
||||||
|
return createChatGPT({ store, openExternal: () => assert.fail("no browser sign-in expected"), fetch: () => assert.fail("no provider request expected") });
|
||||||
|
}
|
||||||
|
|
||||||
|
test("ordinary startup restores saved accounts and global OFF from JSON without OAuth or native storage", async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-restore-"));
|
||||||
|
const file = path.join(dir, "chatgpt-credentials.json");
|
||||||
|
const state = { ...initial(), activeId: "B", registrations: [account("A", "first"), account("B", "second")], allowBackgroundUse: false };
|
||||||
|
let connection;
|
||||||
|
try {
|
||||||
|
await fileStore(file).write(state);
|
||||||
|
connection = service(fileStore(file));
|
||||||
|
const status = await connection.status();
|
||||||
|
assert.equal(status.available, true);
|
||||||
|
assert.equal(status.activeId, "B");
|
||||||
|
assert.equal(status.allowBackgroundUse, false);
|
||||||
|
assert.deepEqual(status.accounts.map((a: { email: string; connected: boolean }) => [a.email, a.connected]), [["first@example.invalid", true], ["second@example.invalid", true]]);
|
||||||
|
assert.doesNotMatch(JSON.stringify(status), /scratch-access|scratch-refresh|scratch-id/);
|
||||||
|
await connection.status();
|
||||||
|
assert.deepEqual(await fileStore(file).read(), state, "startup/status does not change saved credentials");
|
||||||
|
} finally { connection?.close(); await fs.rm(dir, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("new store ignores experimental encrypted files and never alters the separate API-key file", async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-fresh-"));
|
||||||
|
const file = path.join(dir, "chatgpt-credentials.json");
|
||||||
|
const experimental = path.join(dir, "chatgpt-credentials.enc.json");
|
||||||
|
const auth = path.join(dir, "auth.json");
|
||||||
|
const oldBytes = "not an envelope to parse, decrypt, migrate or delete";
|
||||||
|
const apiBytes = '{"OPENAI_API_KEY":"scratch-api-key"}\n';
|
||||||
|
let connection;
|
||||||
|
try {
|
||||||
|
await fs.writeFile(experimental, oldBytes);
|
||||||
|
await fs.writeFile(auth, apiBytes);
|
||||||
|
connection = service(fileStore(file));
|
||||||
|
const status = await connection.status();
|
||||||
|
assert.equal(status.available, true); assert.equal(status.allowBackgroundUse, true);
|
||||||
|
assert.equal(status.activeId, null); assert.deepEqual(status.accounts, []);
|
||||||
|
assert.deepEqual((await connection.models()).models, []);
|
||||||
|
await connection.consent(false);
|
||||||
|
connection.close(); connection = service(fileStore(file));
|
||||||
|
assert.equal((await connection.status()).allowBackgroundUse, false, "zero-account preference survives a real file-backed restart");
|
||||||
|
assert.equal(await fs.readFile(experimental, "utf8"), oldBytes);
|
||||||
|
assert.equal(await fs.readFile(auth, "utf8"), apiBytes);
|
||||||
|
} finally { connection?.close(); await fs.rm(dir, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const bytes of ["{broken-json", "null", "[]", '{"version":1,"encrypted":"ZXhwZXJpbWVudA=="}']) {
|
||||||
|
test(`invalid JSON/record fails closed without overwriting it: ${bytes}`, async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-invalid-"));
|
||||||
|
const file = path.join(dir, "chatgpt-credentials.json");
|
||||||
|
let connection;
|
||||||
|
try {
|
||||||
|
await fs.writeFile(file, bytes);
|
||||||
|
connection = service(fileStore(file));
|
||||||
|
const status = await connection.status();
|
||||||
|
assert.equal(status.available, false); assert.equal(status.activeId, null);
|
||||||
|
assert.match(status.error, /could not be read or saved/);
|
||||||
|
assert.deepEqual((await connection.models()).models, []);
|
||||||
|
await assert.rejects(connection.login(), /storage/);
|
||||||
|
assert.equal(await fs.readFile(file, "utf8"), bytes);
|
||||||
|
} finally { connection?.close(); await fs.rm(dir, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const phase of ["sync", "rename"] as const) {
|
||||||
|
test(`failed ${phase} leaves the previous JSON intact and removes temporary files`, async (t) => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-atomic-"));
|
||||||
|
const file = path.join(dir, "chatgpt-credentials.json");
|
||||||
|
try {
|
||||||
|
const store = fileStore(file);
|
||||||
|
await store.write(initial());
|
||||||
|
const before = await fs.readFile(file, "utf8");
|
||||||
|
if (phase === "rename") t.mock.method(fs, "rename", async () => { throw new Error("scratch rename failure"); });
|
||||||
|
else {
|
||||||
|
const open = fs.open.bind(fs);
|
||||||
|
t.mock.method(fs, "open", async (...args: Parameters<typeof fs.open>) => {
|
||||||
|
const handle = await open(...args);
|
||||||
|
if (args[1] === "wx") t.mock.method(handle, "sync", async () => { throw new Error("scratch sync failure"); });
|
||||||
|
return handle;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await assert.rejects(store.write({ ...initial(), allowBackgroundUse: false }), /^Error: storage$/);
|
||||||
|
assert.equal(await fs.readFile(file, "utf8"), before);
|
||||||
|
assert.deepEqual(await fs.readdir(dir), ["chatgpt-credentials.json"]);
|
||||||
|
} finally { await fs.rm(dir, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("file read/write failures are sanitized and cannot leak credential or path details", async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "lh-chatgpt-io-"));
|
||||||
|
try {
|
||||||
|
// Reading a directory and writing beneath a regular file both fail on all supported platforms.
|
||||||
|
await assert.rejects(fileStore(dir).read(), /^Error: storage$/);
|
||||||
|
const blocker = path.join(dir, "scratch-private-path"); await fs.writeFile(blocker, "blocker");
|
||||||
|
const connection = service(fileStore(path.join(blocker, "chatgpt-credentials.json")));
|
||||||
|
try {
|
||||||
|
const status = await connection.status();
|
||||||
|
assert.equal(status.available, false);
|
||||||
|
assert.doesNotMatch(status.error, /scratch-private-path|ENOTDIR|EISDIR/);
|
||||||
|
await assert.rejects(connection.login(), /^Error: storage$/);
|
||||||
|
} finally { connection.close(); }
|
||||||
|
await assert.rejects(fileStore(path.join(blocker, "credentials.json")).write({ ...initial(), secret: "scratch-token" }), /^Error: storage$/);
|
||||||
|
} finally { await fs.rm(dir, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("desktop wiring uses only the private JSON path, with no Electron safeStorage dependency", async () => {
|
||||||
|
const main = await fs.readFile(new URL("../electron/main.cjs", import.meta.url), "utf8");
|
||||||
|
const auth = await fs.readFile(new URL("../electron/chatgpt.cjs", import.meta.url), "utf8");
|
||||||
|
assert.match(main, /store: fileStore\(path\.join\(app\.getPath\("userData"\), "chatgpt-credentials\.json"\)\)/);
|
||||||
|
assert.doesNotMatch(main + auth, /safeStorage|encryptedStore|chatgpt-credentials\.enc\.json/);
|
||||||
|
});
|
||||||
@@ -20,6 +20,7 @@ import { newUserMessage, newNextMessage, newThinkingMessage, newAnswerResult, re
|
|||||||
const originalSend = Object.getOwnPropertyDescriptor(process, "send");
|
const originalSend = Object.getOwnPropertyDescriptor(process, "send");
|
||||||
const originalConnected = Object.getOwnPropertyDescriptor(process, "connected");
|
const originalConnected = Object.getOwnPropertyDescriptor(process, "connected");
|
||||||
const realFetch = globalThis.fetch;
|
const realFetch = globalThis.fetch;
|
||||||
|
const desktopMarker = process.env.LH_DESKTOP_SERVER, desktopSecret = process.env.LH_DESKTOP_API_SECRET;
|
||||||
const model = piModels().getModel("openai", "gpt-5") as Model<"openai-responses">;
|
const model = piModels().getModel("openai", "gpt-5") as Model<"openai-responses">;
|
||||||
let epoch = 1, revision = 1, background = true;
|
let epoch = 1, revision = 1, background = true;
|
||||||
let cut: "lease" | "payload" | "full" | "afterFetch" | "none" = "none";
|
let cut: "lease" | "payload" | "full" | "afterFetch" | "none" = "none";
|
||||||
@@ -31,6 +32,7 @@ function revoke(full = false) {
|
|||||||
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch, authorityRevision: epoch, revision: ++revision }, status: status(), invalidate: full ? "all" : "background" });
|
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch, authorityRevision: epoch, revision: ++revision }, status: status(), invalidate: full ? "all" : "background" });
|
||||||
}
|
}
|
||||||
before(() => {
|
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, "connected", { configurable: true, value: true });
|
||||||
Object.defineProperty(process, "send", { configurable: true, value: (m: any, callback?: () => void) => {
|
Object.defineProperty(process, "send", { configurable: true, value: (m: any, callback?: () => void) => {
|
||||||
if (m.op === "lease") {
|
if (m.op === "lease") {
|
||||||
@@ -59,6 +61,8 @@ before(() => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
after(() => {
|
after(() => {
|
||||||
|
if (desktopMarker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = desktopMarker;
|
||||||
|
if (desktopSecret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = desktopSecret;
|
||||||
globalThis.fetch = realFetch;
|
globalThis.fetch = realFetch;
|
||||||
fs.rmSync(scratch, { recursive: true, force: true });
|
fs.rmSync(scratch, { recursive: true, force: true });
|
||||||
if (originalSend) Object.defineProperty(process, "send", originalSend); else delete process.send;
|
if (originalSend) Object.defineProperty(process, "send", originalSend); else delete process.send;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { chatGPTHistory, chatGPTPayload } from "../src/agent/chatgpt-stream.ts";
|
|||||||
const originalSend = Object.getOwnPropertyDescriptor(process, "send");
|
const originalSend = Object.getOwnPropertyDescriptor(process, "send");
|
||||||
const originalConnected = Object.getOwnPropertyDescriptor(process, "connected");
|
const originalConnected = Object.getOwnPropertyDescriptor(process, "connected");
|
||||||
const realFetch = globalThis.fetch;
|
const realFetch = globalThis.fetch;
|
||||||
|
const desktopMarker = process.env.LH_DESKTOP_SERVER, desktopSecret = process.env.LH_DESKTOP_API_SECRET;
|
||||||
const originalKey = process.env.OPENAI_API_KEY;
|
const originalKey = process.env.OPENAI_API_KEY;
|
||||||
let leaseError: string | null = null;
|
let leaseError: string | null = null;
|
||||||
let requests: { authorization: string | null; body: Record<string, any>; url: string }[] = [];
|
let requests: { authorization: string | null; body: Record<string, any>; url: string }[] = [];
|
||||||
@@ -17,6 +18,7 @@ const ipc = (value: unknown) => (process.emit as (name: string, value: unknown)
|
|||||||
const model = piModels().getModel("openai", "gpt-5") as Model<"openai-responses">;
|
const model = piModels().getModel("openai", "gpt-5") as Model<"openai-responses">;
|
||||||
const status = { available: true, pending: false, allowBackgroundUse: false, activeId: "scratch-account", accounts: [{ id: "scratch-account", email: "dummy@example.invalid", registration: "oaiapp_scratch", connected: true, planEnabled: true, background: false }], error: null };
|
const status = { available: true, pending: false, allowBackgroundUse: false, activeId: "scratch-account", accounts: [{ id: "scratch-account", email: "dummy@example.invalid", registration: "oaiapp_scratch", connected: true, planEnabled: true, background: false }], error: null };
|
||||||
before(async () => {
|
before(async () => {
|
||||||
|
process.env.LH_DESKTOP_SERVER = "1"; process.env.LH_DESKTOP_API_SECRET = "d".repeat(64);
|
||||||
assert(model);
|
assert(model);
|
||||||
process.env.OPENAI_API_KEY = "sk-scratch-must-never-be-used";
|
process.env.OPENAI_API_KEY = "sk-scratch-must-never-be-used";
|
||||||
Object.defineProperty(process, "connected", { configurable: true, value: true });
|
Object.defineProperty(process, "connected", { configurable: true, value: true });
|
||||||
@@ -36,6 +38,8 @@ before(async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
after(() => {
|
after(() => {
|
||||||
|
if (desktopMarker === undefined) delete process.env.LH_DESKTOP_SERVER; else process.env.LH_DESKTOP_SERVER = desktopMarker;
|
||||||
|
if (desktopSecret === undefined) delete process.env.LH_DESKTOP_API_SECRET; else process.env.LH_DESKTOP_API_SECRET = desktopSecret;
|
||||||
globalThis.fetch = realFetch;
|
globalThis.fetch = realFetch;
|
||||||
if (originalSend) Object.defineProperty(process, "send", originalSend); else delete process.send;
|
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 (originalConnected) Object.defineProperty(process, "connected", originalConnected); else delete (process as { connected?: boolean }).connected;
|
||||||
@@ -166,7 +170,7 @@ test("case-insensitive Authorization overrides including null are refused before
|
|||||||
assert.equal(requests.length, before);
|
assert.equal(requests.length, before);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("full secure-storage invalidation aborts foreground and background controllers and clears capabilities", () => {
|
test("full credential-storage invalidation aborts foreground and background controllers and clears capabilities", () => {
|
||||||
const foreground = chatGPTAbortController(false), background = chatGPTAbortController(true);
|
const foreground = chatGPTAbortController(false), background = chatGPTAbortController(true);
|
||||||
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 2, authorityRevision: 2, revision: 3 }, status: { ...status, available: false }, invalidate: "all" });
|
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 2, authorityRevision: 2, revision: 3 }, status: { ...status, available: false }, invalidate: "all" });
|
||||||
assert(foreground.controller.signal.aborted); assert(background.controller.signal.aborted); assert.equal(chatGPTModels().length, 0);
|
assert(foreground.controller.signal.aborted); assert(background.controller.signal.aborted); assert.equal(chatGPTModels().length, 0);
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import { notifySettingsSaved } from "../components/settings-signal.ts";
|
|||||||
import { SidebarProvider } from "../components/sidebar.tsx";
|
import { SidebarProvider } from "../components/sidebar.tsx";
|
||||||
import { ChatGPTConnection, ChatGPTUIProvider } from "../components/settings/chatgpt-connection.tsx";
|
import { ChatGPTConnection, ChatGPTUIProvider } from "../components/settings/chatgpt-connection.tsx";
|
||||||
import { GraphCostControl } from "../components/graph-view.tsx";
|
import { GraphCostControl } from "../components/graph-view.tsx";
|
||||||
import { graphCostSummary } from "../src/core/cost-summary.ts";
|
import { graphCostSummary as foldGraphCostSummary, type CostSummaryInput } from "../src/core/cost-summary.ts";
|
||||||
|
const graphCostSummary = (input: CostSummaryInput) => foldGraphCostSummary(input, () => null);
|
||||||
import type { ModelCatalogResponse, ChatGPTStatus } from "../src/core/index.ts";
|
import type { ModelCatalogResponse, ChatGPTStatus } from "../src/core/index.ts";
|
||||||
const dom = parseHTML("<html><body></body></html>");
|
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, localStorage: { getItem: () => null, setItem() {} } });
|
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, localStorage: { getItem: () => null, setItem() {} } });
|
||||||
@@ -56,7 +57,7 @@ test("plan plus unknown graph rows keep the existing unknown suffix without fabr
|
|||||||
await act(async () => { document.querySelector("button")!.click(); });
|
await act(async () => { document.querySelector("button")!.click(); });
|
||||||
const recent = [...document.querySelectorAll(".border-t .shrink-0.tabular-nums")];
|
const recent = [...document.querySelectorAll(".border-t .shrink-0.tabular-nums")];
|
||||||
assert.equal(recent.length, 2);
|
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.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"));
|
assert(!document.body.textContent?.includes("$0.00"));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { callCost, sessionCost, turnCost, jobCost } from "../src/core/prices.ts";
|
import { callCost, sessionCost, turnCost, jobCost } from "../src/server/pricing.ts";
|
||||||
import { graphCostSummary } from "../src/core/cost-summary.ts";
|
import { graphCostSummary as foldGraphCostSummary, type CostSummaryInput } from "../src/core/cost-summary.ts";
|
||||||
|
const graphCostSummary = (input: CostSummaryInput) => foldGraphCostSummary(input, callCost);
|
||||||
import type { TurnUsage } from "../src/core/contracts.ts";
|
import type { TurnUsage } from "../src/core/contracts.ts";
|
||||||
import { planCostLabel } from "../components/usage-format.ts";
|
import { planCostLabel } from "../components/usage-format.ts";
|
||||||
const step = { inputTokens: 1000, outputTokens: 100, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined };
|
const step = { inputTokens: 1000, outputTokens: 100, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined };
|
||||||
@@ -12,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(callCost(step, plan.model), null);
|
||||||
assert.equal(turnCost(turn(plan.model, [])), null);
|
assert.equal(turnCost(turn(plan.model, [])), null);
|
||||||
assert.equal(jobCost({ usage: { model: plan.model, ...step, steps: 1 } }), 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 });
|
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 api = turn("openai:gpt-5");
|
||||||
const mixed = sessionCost({ turns: [plan, api, turn("unknown:missing")] });
|
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(mixed.cost, turnCost(api)); assert.equal(mixed.unpriced, 1); assert.equal(mixed.subscription, 1);
|
||||||
assert.equal(planCostLabel(null, true), "ChatGPT plan");
|
assert.equal(planCostLabel(null, true), "ChatGPT");
|
||||||
assert.match(planCostLabel(mixed.cost, true), /API \+ ChatGPT plan$/);
|
assert.match(planCostLabel(mixed.cost, true), /\$[\d.]+ \+ ChatGPT$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("graph extraction and audit preserve subscription identity separately from priced and unknown stages", () => {
|
test("graph extraction and audit preserve subscription identity separately from priced and unknown stages", () => {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/** Golden outputs from the verified published pi-coding-agent 1.4.0
|
||||||
|
* findCutPoint + estimateTokens helpers, executed in isolation on synthetic
|
||||||
|
* message/metadata entries. No coding-agent dependency or private data.
|
||||||
|
* Source compaction.js SHA256:
|
||||||
|
* f51e78ef1da66b5803fc91e185f07b968f8f989ab9326b7744958416aad3f6f6
|
||||||
|
* Update deliberately alongside the documented local port, not automatically.
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { compactionCutIndex, estimateMessageTokens, newCompactionMarker, KEEP_RECENT_TOKENS, type LhMessage } from "../src/core/index.ts";
|
||||||
|
|
||||||
|
let serial = 0;
|
||||||
|
const user = (chars: number): LhMessage => ({ id: `u${serial++}`, role: "user", content: "u".repeat(chars), timestamp: 1 });
|
||||||
|
const assistant = (chars: number): LhMessage => ({ id: `a${serial++}`, role: "assistant", content: [{ type: "text", text: "a".repeat(chars) }], timestamp: 1 }) as LhMessage;
|
||||||
|
const call = (chars: number): LhMessage => ({ ...assistant(chars), content: [{ type: "text", text: "a".repeat(chars) }, { type: "toolCall", name: "quiz", arguments: {}, id: "c" }] }) as LhMessage;
|
||||||
|
const result = (chars: number): LhMessage => ({ id: `r${serial++}`, role: "toolResult", content: [{ type: "text", text: "r".repeat(chars) }], toolCallId: "c", toolName: "quiz", isError: false, timestamp: 1 });
|
||||||
|
const image = (): LhMessage => ({ id: `i${serial++}`, role: "user", content: [{ type: "image", data: "fixture", mimeType: "image/png" }], timestamp: 1 });
|
||||||
|
const metadata = (): LhMessage => ({ id: `m${serial++}`, role: "custom", customType: "plan-block", content: "invisible".repeat(1000), display: false, details: {}, timestamp: 1 });
|
||||||
|
|
||||||
|
test("local cutoff matches published native golden boundaries and estimator values", () => {
|
||||||
|
const cases: [string, LhMessage[], number, number, number[]][] = [
|
||||||
|
["exact threshold", [user(40), user(40), user(40)], 20, 1, [10, 10, 10]],
|
||||||
|
["under budget", [user(4), assistant(4)], 20, 0, [1, 1]],
|
||||||
|
["split turn", [user(400), assistant(40), assistant(40)], 15, 1, [100, 10, 10]],
|
||||||
|
["completed result boundary", [user(400), call(4), result(400), user(40)], 30, 3, [100, 3, 100, 10]],
|
||||||
|
["oversized trailing result", [user(400), user(40), call(4), result(400)], 30, 2, [100, 10, 3, 100]],
|
||||||
|
["multiple trailing results", [user(400), call(4), result(200), result(200)], 30, 1, [100, 3, 50, 50]],
|
||||||
|
["image allowance", [user(400), image(), user(40)], 1000, 1, [100, 1200, 10]],
|
||||||
|
["empty trailing text", [user(400), assistant(0), user(0)], 30, 0, [100, 0, 0]],
|
||||||
|
["pending call", [user(400), user(400), call(4)], 10, 1, [100, 100, 3]],
|
||||||
|
];
|
||||||
|
for (const [name, messages, budget, expected, tokens] of cases) {
|
||||||
|
assert.equal(compactionCutIndex(messages, budget), expected, name);
|
||||||
|
assert.deepEqual(messages.map(estimateMessageTokens), tokens, name);
|
||||||
|
}
|
||||||
|
assert.equal(compactionCutIndex([user(400), metadata(), assistant(40), metadata(), user(40)], 15), 1, "adjacent invisible metadata stays with the cut");
|
||||||
|
assert.equal(KEEP_RECENT_TOKENS, 20_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chained cutoff operates on active retained history, never the previous summary or discarded prefix", () => {
|
||||||
|
const old = user(400_000);
|
||||||
|
const kept = user(40);
|
||||||
|
const marker = newCompactionMarker("summary".repeat(100_000), { firstKeptMessageId: kept.id, tokensBefore: 100_000, at: "fixture" });
|
||||||
|
const messages = [old, kept, assistant(40), marker, user(40)];
|
||||||
|
assert.equal(compactionCutIndex(messages, 100), 1, "retained tail fits; no progress");
|
||||||
|
assert.equal(compactionCutIndex(messages, 15), 2, "old summary does not consume the kept-tail budget");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opaque signatures remain unmeasured and preserved; system/image allowances match native", () => {
|
||||||
|
const signed = { ...assistant(4), content: [{ type: "thinking", thinking: "", thinkingSignature: "opaque".repeat(100_000) }] } as LhMessage;
|
||||||
|
assert.equal(estimateMessageTokens(signed), 0);
|
||||||
|
assert.equal((signed as { content: { thinkingSignature: string }[] }).content[0]!.thinkingSignature.length, 600_000);
|
||||||
|
const system = { role: "system", content: [{ type: "image", data: "fixture", mimeType: "image/png" }], sections: { a: "abcd" }, toolsAdded: [{ name: "fixture" }] };
|
||||||
|
assert.equal(estimateMessageTokens(system as never), Math.ceil((4800 + 4 + JSON.stringify(system.toolsAdded).length) / 4));
|
||||||
|
});
|
||||||
@@ -32,7 +32,7 @@ const {
|
|||||||
} = await import("../src/server/compaction.ts");
|
} = await import("../src/server/compaction.ts");
|
||||||
import type { CompactionDeps } from "../src/server/compaction.ts";
|
import type { CompactionDeps } from "../src/server/compaction.ts";
|
||||||
const { subscribe } = await import("../src/core/events.ts");
|
const { subscribe } = await import("../src/core/events.ts");
|
||||||
const { activeCompactionOf, newUserMessage, COMPACTED_HISTORY_HEADER } = await import("../src/core/index.ts");
|
const { activeCompactionOf, newUserMessage, measuredContextTokens, COMPACTED_HISTORY_HEADER } = await import("../src/core/index.ts");
|
||||||
import type { LhMessage } from "../src/core/index.ts";
|
import type { LhMessage } from "../src/core/index.ts";
|
||||||
|
|
||||||
after(() => {
|
after(() => {
|
||||||
@@ -204,6 +204,37 @@ describe("the compaction service", () => {
|
|||||||
kickCompaction(() => null, chatId);
|
kickCompaction(() => null, chatId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a usage-heavy zero cut does not spend or append a marker", async () => {
|
||||||
|
const chat = chatStore.createChat("no-progress fixture");
|
||||||
|
const assistant = { id: "measured", role: "assistant", content: [{ type: "text", text: "ok" }], stopReason: "stop", usage: { input: 120_000, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 120_001 }, timestamp: 1 } as LhMessage;
|
||||||
|
chatStore.saveMessages(chat.id, [newUserMessage("tiny visible history"), assistant]);
|
||||||
|
const before = summarizeCalls;
|
||||||
|
await ensureCompacted(depsOf(chat.id, 40_000), chat.id);
|
||||||
|
assert.equal(summarizeCalls, before);
|
||||||
|
assert.equal(activeCompactionOf(chatStore.getChat(chat.id)!.messages), null);
|
||||||
|
assert.equal(measuredContextTokens(chatStore.getChat(chat.id)!.messages), 120_001);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starting/failing compaction preserves the meter; committing its marker clears it, regardless of summary usage", async () => {
|
||||||
|
const id = overThresholdChat("meter validity fixture");
|
||||||
|
const assistant = { id: "measured", role: "assistant", content: [{ type: "text", text: "ok" }], stopReason: "stop", usage: { input: 120_000, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 120_001 }, timestamp: 1 } as LhMessage;
|
||||||
|
chatStore.saveMessages(id, [...chatStore.getChat(id)!.messages, assistant]);
|
||||||
|
let release!: () => void;
|
||||||
|
const gate = new Promise<void>((done) => { release = done; });
|
||||||
|
const deps = depsOf(id, 40_000);
|
||||||
|
const running = ensureCompacted({ ...deps, summarize: async () => {
|
||||||
|
await gate;
|
||||||
|
throw new Error("fixture failure");
|
||||||
|
} }, id);
|
||||||
|
assert.equal(measuredContextTokens(chatStore.getChat(id)!.messages), 120_001);
|
||||||
|
release(); await running;
|
||||||
|
assert.equal(measuredContextTokens(chatStore.getChat(id)!.messages), 120_001);
|
||||||
|
await ensureCompacted(deps, id);
|
||||||
|
const record = chatStore.getChat(id)!;
|
||||||
|
assert.ok(record.turns!.at(-1)!.steps[0]!.inputTokens! > 0);
|
||||||
|
assert.equal(measuredContextTokens(record.messages), null);
|
||||||
|
});
|
||||||
|
|
||||||
it("a summarizer failure leaves the record untouched and the gate re-arms", async () => {
|
it("a summarizer failure leaves the record untouched and the gate re-arms", async () => {
|
||||||
const chatId = overThresholdChat("compact: fail");
|
const chatId = overThresholdChat("compact: fail");
|
||||||
const before = summarizeCalls;
|
const before = summarizeCalls;
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ describe("compactionCutIndex", () => {
|
|||||||
assert.equal((messages[cut] as { content: string }).content.startsWith("m2"), true, "the oldest kept message sits at the cut");
|
assert.equal((messages[cut] as { content: string }).content.startsWith("m2"), true, "the oldest kept message sits at the cut");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("NEVER separates a tool result from its call — the cut extends to the call's assistant message", () => {
|
it("native cut after an oversized result drops both call and result, never keeps an orphan", () => {
|
||||||
const messages = [
|
const messages = [
|
||||||
big("m1"),
|
big("m1"),
|
||||||
big("m2"),
|
big("m2"),
|
||||||
@@ -165,13 +165,13 @@ describe("compactionCutIndex", () => {
|
|||||||
big("m3"),
|
big("m3"),
|
||||||
big("m4"),
|
big("m4"),
|
||||||
];
|
];
|
||||||
// Budget lands such that the naive stop is ON the toolResult (m2 kept,
|
// Budget lands on the result. Pi chooses the next valid cut AT OR
|
||||||
// result next): the walk must extend to a1's assistant message.
|
// AFTER it, dropping the complete exchange rather than extending.
|
||||||
const cut = compactionCutIndex(messages, estimateTokens([big("m3"), big("m4")]) + 1);
|
const cut = compactionCutIndex(messages, estimateTokens([big("m3"), big("m4")]) + 1);
|
||||||
assert.ok(cut <= 2, `the cut must not sit between the call (idx 2) and its result (idx 3) — got ${cut}`);
|
assert.equal(cut, 4);
|
||||||
const kept = messages.slice(cut);
|
const keptIds = new Set(messages.slice(cut).map((m) => m.id));
|
||||||
const keptIds = new Set(kept.map((m) => m.id));
|
assert.equal(keptIds.has("a1"), false);
|
||||||
assert.ok(keptIds.has("a1") && keptIds.has("r_call_1"), "call and result stay together");
|
assert.equal(keptIds.has("r_call_1"), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a PENDING question call (no result — the pose→answer window) always survives", () => {
|
it("a PENDING question call (no result — the pose→answer window) always survives", () => {
|
||||||
@@ -181,7 +181,7 @@ describe("compactionCutIndex", () => {
|
|||||||
assert.ok(kept.some((m) => m.id === "a_pending"), "the pending pose is never dropped");
|
assert.ok(kept.some((m) => m.id === "a_pending"), "the pending pose is never dropped");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("zero budget summarizes everything; whole-history-in-budget returns 0 only when nothing precedes", () => {
|
it("whole history within budget keeps everything (zero cut)", () => {
|
||||||
assert.equal(compactionCutIndex([newUserMessage("hi")], 100), 0);
|
assert.equal(compactionCutIndex([newUserMessage("hi")], 100), 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
||||||
|
import { measuredContextTokens, newCompactionMarker, type LhMessage } from "../src/core/index.ts";
|
||||||
|
import { SessionModel } from "../components/session-core.ts";
|
||||||
|
|
||||||
|
function assistant(id: string, stopReason: AssistantMessage["stopReason"] = "stop", total = 999_999): LhMessage {
|
||||||
|
return { id, role: "assistant", content: [{ type: "text", text: "fixture" }], api: "openai-completions", provider: "openrouter", model: "fixture", stopReason, timestamp: 1,
|
||||||
|
usage: { input: 10_000, output: 1_000, cacheRead: 70_000, cacheWrite: 2_000, reasoning: 500, totalTokens: total, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } } as LhMessage;
|
||||||
|
}
|
||||||
|
const marker = () => newCompactionMarker("educational summary", { firstKeptMessageId: "old", tokensBefore: 110_001, at: "fixture" });
|
||||||
|
|
||||||
|
test("meter sums four disjoint fields, ignoring reasoning subset and totalTokens overrides", () => {
|
||||||
|
assert.equal(measuredContextTokens([assistant("old")]), 83_000);
|
||||||
|
assert.equal(measuredContextTokens([]), null);
|
||||||
|
for (const stop of ["stop", "length", "toolUse"] as const) assert.equal(measuredContextTokens([assistant("a", stop)]), 83_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("newest marker invalidates retained pre-cut usage; summarizer money log cannot supply a measurement", () => {
|
||||||
|
const old = assistant("old");
|
||||||
|
assert.equal(measuredContextTokens([old, marker()]), null);
|
||||||
|
assert.equal(measuredContextTokens([old, marker(), assistant("fresh")]), 83_000);
|
||||||
|
assert.equal(measuredContextTokens([old, marker(), assistant("fresh"), marker()]), null);
|
||||||
|
// The summarizer is logged as a money-channel turn, never an assistant
|
||||||
|
// in the native conversation list. No money-channel input exists here.
|
||||||
|
});
|
||||||
|
|
||||||
|
test("errors, aborts, all-zero/invalid usage and unfinished slots preserve the previous valid reading", () => {
|
||||||
|
const old = assistant("old");
|
||||||
|
for (const stop of ["error", "aborted"] as const) assert.equal(measuredContextTokens([old, assistant("bad", stop)]), 83_000);
|
||||||
|
const zero = assistant("zero") as AssistantMessage & { id: string };
|
||||||
|
zero.usage = { ...zero.usage, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
||||||
|
assert.equal(measuredContextTokens([old, zero]), 83_000);
|
||||||
|
assert.equal(measuredContextTokens([zero]), null);
|
||||||
|
assert.equal(measuredContextTokens([old, assistant("partial")], new Set(["partial"])), 83_000);
|
||||||
|
assert.equal(measuredContextTokens([marker(), assistant("bad", "error")]), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("live terminal-looking message_update is not completion; each message_end updates immediately", () => {
|
||||||
|
const model = new SessionModel({ chatId: "fixture", initialMessages: [assistant("old")] });
|
||||||
|
const fresh = assistant("live") as AssistantMessage & { id: string };
|
||||||
|
fresh.usage = { ...fresh.usage, input: 20_000 };
|
||||||
|
model.applyWireEvent({ type: "message_start", message: fresh });
|
||||||
|
model.applyWireEvent({ type: "message_update", message: fresh });
|
||||||
|
assert.equal(model.getState().contextTokens, 83_000);
|
||||||
|
model.applyWireEvent({ type: "message_end", message: fresh });
|
||||||
|
assert.equal(model.getState().contextTokens, 93_000);
|
||||||
|
model.applyWireEvent({ type: "message_start", message: assistant("next") });
|
||||||
|
assert.equal(model.getState().contextTokens, 93_000);
|
||||||
|
model.applyWireEvent({ type: "message_end", message: assistant("next", "toolUse") });
|
||||||
|
assert.equal(model.getState().contextTokens, 83_000);
|
||||||
|
model.setMessages([assistant("old"), marker()]);
|
||||||
|
assert.equal(model.getState().contextTokens, null);
|
||||||
|
});
|
||||||
@@ -8,16 +8,17 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it as test } from "node:test";
|
import { describe, it as test } from "node:test";
|
||||||
import {
|
import {
|
||||||
graphCostSummary,
|
graphCostSummary as foldGraphCostSummary,
|
||||||
type CostSummaryInput,
|
type CostSummaryInput,
|
||||||
type GraphCostSummary,
|
type GraphCostSummary,
|
||||||
} from "../src/core/cost-summary.ts";
|
} from "../src/core/cost-summary.ts";
|
||||||
import type { CallUsage, ExtractionRunUsage } from "../src/core/contracts.ts";
|
import type { CallUsage, ExtractionRunUsage } from "../src/core/contracts.ts";
|
||||||
import { PRICES, callCost } from "../src/core/prices.ts";
|
import { callCost } from "../src/server/pricing.ts";
|
||||||
|
const graphCostSummary = (input: CostSummaryInput) => foldGraphCostSummary(input, callCost);
|
||||||
|
|
||||||
/** A model with full published pricing (from the generated registry). */
|
/** A model with full published pricing (from the generated registry). */
|
||||||
const PRICED = "openrouter:z-ai/glm-5.3-flash";
|
const PRICED = "openrouter:z-ai/glm-5.3-flash";
|
||||||
assert.ok(PRICES[PRICED], "fixture model must exist in PRICES");
|
assert.notEqual(callCost({ inputTokens: 1, outputTokens: 1 }, PRICED), null);
|
||||||
|
|
||||||
const step = (input: number, output: number): CallUsage => ({
|
const step = (input: number, output: number): CallUsage => ({
|
||||||
inputTokens: input,
|
inputTokens: input,
|
||||||
|
|||||||
@@ -78,8 +78,9 @@ describe("desktop window chrome", () => {
|
|||||||
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), context);
|
runInNewContext(readFileSync(new URL("../electron/preload.cjs", import.meta.url), "utf8"), context);
|
||||||
assert.equal(bridge.platform, "win32");
|
assert.equal(bridge.platform, "win32");
|
||||||
assert.deepEqual(Object.keys(bridge).sort(), [
|
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"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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 { GraphCostControl } from "../components/graph-view.tsx";
|
||||||
|
import { graphCostSummary } from "../src/core/cost-summary.ts";
|
||||||
|
import { callCost } from "../src/server/pricing.ts";
|
||||||
|
import type { CallUsage, ExtractionRunUsage } from "../src/core/contracts.ts";
|
||||||
|
|
||||||
|
const usage = (over: Partial<CallUsage> = {}): CallUsage => ({ inputTokens: 1000, outputTokens: 100, cacheReadTokens: 0, cacheWriteTokens: 20_000, reasoningTokens: 0, ...over });
|
||||||
|
const run = (steps: CallUsage[], model = "anthropic:claude-haiku-4-5", at = 1): ExtractionRunUsage => ({ startedAt: at, at: "fixture", durationMs: 1, stages: { extractor: { model, steps }, auditor: { model, steps } } });
|
||||||
|
const summary = (runs: ExtractionRunUsage[]) => graphCostSummary({ chats: [{ title: "fixture", extractions: runs }], sources: [] }, callCost);
|
||||||
|
const headline = (runs: ExtractionRunUsage[]) => renderToStaticMarkup(<GraphCostControl costs={summary(runs)} />);
|
||||||
|
|
||||||
|
test("wholly unpriced historical graph costs render unknown, not known-zero dollars", () => {
|
||||||
|
const historical = run([usage()]); // missing long-cache split
|
||||||
|
const costs = summary([historical]);
|
||||||
|
assert.equal(costs.totalDollars, 0);
|
||||||
|
assert.equal(costs.unpricedRuns, 1);
|
||||||
|
assert.equal(costs.stages.extraction.unpricedRuns, 1);
|
||||||
|
const html = headline([historical]);
|
||||||
|
assert.match(html, />—<\/button>/);
|
||||||
|
assert.doesNotMatch(html, /\$0\.00/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("known zero, mixed priced/unknown, and subscription headline semantics stay distinct", () => {
|
||||||
|
const zero = run([usage({ inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0 })]);
|
||||||
|
assert.match(headline([zero]), />\$0\.00<\/button>/, "known zero is not unknown");
|
||||||
|
const priced = run([usage({ cacheWrite1hTokens: 0 })]);
|
||||||
|
const historical = run([usage()], undefined, 2);
|
||||||
|
const mixed = summary([priced, historical]);
|
||||||
|
assert.equal(mixed.unpricedRuns, 1);
|
||||||
|
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<\/button>/);
|
||||||
|
assert.match(headline([plan, zero]), />\$0\.00 \+ ChatGPT<\/button>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("graph stage and recent-run aggregates preserve known one-hour cache writes", () => {
|
||||||
|
const costs = summary([run([usage({ cacheWrite1hTokens: 20_000 })]), run([usage({ cacheWrite1hTokens: 5000 })], undefined, 2)]);
|
||||||
|
assert.equal(costs.stages.extraction.tokens.cacheWrite1hTokens, 25_000);
|
||||||
|
assert.equal(costs.stages.audit.tokens.cacheWrite1hTokens, 25_000);
|
||||||
|
assert.deepEqual(costs.runs.map((r) => r.tokens.cacheWrite1hTokens), [10_000, 40_000]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown historical long-cache splits stay unknown regardless of merge order", () => {
|
||||||
|
const known = run([usage({ cacheWrite1hTokens: 20_000 })]);
|
||||||
|
const historical = run([usage()], undefined, 2);
|
||||||
|
for (const runs of [[known, historical], [historical, known]]) {
|
||||||
|
const costs = summary(runs);
|
||||||
|
assert.equal(costs.stages.extraction.tokens.cacheWrite1hTokens, undefined);
|
||||||
|
assert.equal(costs.stages.audit.tokens.cacheWrite1hTokens, undefined);
|
||||||
|
}
|
||||||
|
const mixedWithinRun = summary([run([usage({ cacheWrite1hTokens: 20_000 }), usage()])]);
|
||||||
|
assert.equal(mixedWithinRun.stages.extraction.tokens.cacheWrite1hTokens, undefined);
|
||||||
|
const absentNoWrites = run([usage({ cacheWriteTokens: 0 })], undefined, 2);
|
||||||
|
assert.equal(summary([absentNoWrites, known]).stages.extraction.tokens.cacheWrite1hTokens, 20_000, "absence without writes must not poison known write detail");
|
||||||
|
});
|
||||||
@@ -346,8 +346,11 @@ describe("steer helpers (contracts.ts)", () => {
|
|||||||
cacheReadTokens: 4,
|
cacheReadTokens: 4,
|
||||||
cacheWriteTokens: undefined,
|
cacheWriteTokens: undefined,
|
||||||
reasoningTokens: undefined,
|
reasoningTokens: undefined,
|
||||||
|
cacheWrite1hTokens: undefined,
|
||||||
|
calls: [{ inputTokens: 10, outputTokens: 5, cacheReadTokens: 4, cacheWriteTokens: undefined, cacheWrite1hTokens: undefined, reasoningTokens: undefined }],
|
||||||
steps: 1,
|
steps: 1,
|
||||||
lastStep: {
|
lastStep: {
|
||||||
|
cacheWrite1hTokens: undefined,
|
||||||
inputTokens: 10,
|
inputTokens: 10,
|
||||||
outputTokens: 5,
|
outputTokens: 5,
|
||||||
cacheReadTokens: 4,
|
cacheReadTokens: 4,
|
||||||
@@ -364,7 +367,9 @@ describe("steer helpers (contracts.ts)", () => {
|
|||||||
assert.equal(total.cacheWriteTokens, undefined, "never reported stays absent — never zero");
|
assert.equal(total.cacheWriteTokens, undefined, "never reported stays absent — never zero");
|
||||||
assert.equal(total.reasoningTokens, undefined);
|
assert.equal(total.reasoningTokens, undefined);
|
||||||
assert.equal(total.steps, 2, "one step per model call, reported or not");
|
assert.equal(total.steps, 2, "one step per model call, reported or not");
|
||||||
|
assert.equal(total.calls?.length, 2);
|
||||||
assert.deepEqual(total.lastStep, {
|
assert.deepEqual(total.lastStep, {
|
||||||
|
cacheWrite1hTokens: undefined,
|
||||||
inputTokens: 20,
|
inputTokens: 20,
|
||||||
outputTokens: 0,
|
outputTokens: 0,
|
||||||
cacheReadTokens: undefined,
|
cacheReadTokens: undefined,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { normalizeContext } from "@earendil-works/pi-ai";
|
|||||||
import { Type } from "typebox";
|
import { Type } from "typebox";
|
||||||
import { piEngineForSpec, piModels } from "../src/agent/models.ts";
|
import { piEngineForSpec, piModels } from "../src/agent/models.ts";
|
||||||
import { modelCatalog, validateChatSettings, validateModelsSettings } from "../src/server/model-catalog.ts";
|
import { modelCatalog, validateChatSettings, validateModelsSettings } from "../src/server/model-catalog.ts";
|
||||||
import { callCost } from "../src/core/prices.ts";
|
import { callCost } from "../src/server/pricing.ts";
|
||||||
|
|
||||||
const specimens = [
|
const specimens = [
|
||||||
{ id: "recursive/flint-v1.0:free", input: ["text"] },
|
{ id: "recursive/flint-v1.0:free", input: ["text"] },
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
/**
|
|
||||||
* THE PRICE-TABLE DRIFT GUARD (owner, 2026-09-29).
|
|
||||||
*
|
|
||||||
* Failure mode this kills: bumping @earendil-works/pi-ai adds new models
|
|
||||||
* (and price corrections) to the shipped catalogs, but nobody re-runs
|
|
||||||
* scripts/sync-pricing.ts — src/core/prices.ts stays a stale generation
|
|
||||||
* and every run on a new model renders cost as "—" (deepseek-v4.1-flash,
|
|
||||||
* 2026-09-29, commit df654fb). The table must stay a generated static
|
|
||||||
* file (core is client-safe: activity-dock and stats-bar fold costs in
|
|
||||||
* the renderer, and reading the catalog JSON needs fs), so staleness is
|
|
||||||
* guarded instead of eliminated.
|
|
||||||
*
|
|
||||||
* The test asserts PRICES equals EXACTLY what the sync script would
|
|
||||||
* generate right now — it imports the script's own builder, so the guard
|
|
||||||
* and the generator can never disagree:
|
|
||||||
* - a catalog model with pricing missing from PRICES → "run the sync"
|
|
||||||
* - a price field that drifted in the catalog → "run the sync"
|
|
||||||
* - a PRICES key whose model left the catalog → "run the sync"
|
|
||||||
*
|
|
||||||
* On failure: npx tsx scripts/sync-pricing.ts, commit the regenerated
|
|
||||||
* file, re-derive any pinned-cost tests.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import test from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { PRICES } from "../src/core/prices.ts";
|
|
||||||
import { buildPrices } from "../scripts/sync-pricing.ts";
|
|
||||||
|
|
||||||
test("PRICES matches pi-ai's shipped catalogs exactly — the sync was run after the last bump", () => {
|
|
||||||
const catalog = buildPrices();
|
|
||||||
|
|
||||||
const missing = [...catalog.keys()].filter((k) => !(k in PRICES));
|
|
||||||
const stale = Object.keys(PRICES).filter((k) => !catalog.has(k));
|
|
||||||
const drifted: string[] = [];
|
|
||||||
for (const [key, cost] of catalog) {
|
|
||||||
const price = PRICES[key];
|
|
||||||
if (!price) continue;
|
|
||||||
if (
|
|
||||||
price.input !== cost.input ||
|
|
||||||
price.output !== cost.output ||
|
|
||||||
price.cacheRead !== cost.cacheRead ||
|
|
||||||
price.cacheWrite !== cost.cacheWrite
|
|
||||||
) {
|
|
||||||
drifted.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
`${missing.length} / ${stale.length} / ${drifted.length}`,
|
|
||||||
"0 / 0 / 0",
|
|
||||||
`price table is stale: ${missing.length} unpriced catalog models (e.g. ${missing[0] ?? "—"}), ` +
|
|
||||||
`${stale.length} keys gone from the catalog (e.g. ${stale[0] ?? "—"}), ` +
|
|
||||||
`${drifted.length} drifted prices (e.g. ${drifted[0] ?? "—"}). ` +
|
|
||||||
"Run: npx tsx scripts/sync-pricing.ts, commit the regenerated file, re-derive pinned-cost tests.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/** Catalog/calculator parity replaces the old generated-table drift test. */
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { calculateCost, type Usage } from "@earendil-works/pi-ai";
|
||||||
|
import { piModels } from "../src/agent/models.ts";
|
||||||
|
import { callCost, jobCost } from "../src/server/pricing.ts";
|
||||||
|
import { accumulateJobUsage, piUsageToStep } from "../src/core/contracts/jobs.ts";
|
||||||
|
|
||||||
|
const models = piModels().getProviders().flatMap((p) => piModels().getModels(p.id));
|
||||||
|
const nativeUsage = (input: number): Usage => ({ input, output: 1_000, cacheRead: 10_000, cacheWrite: 2_000, cacheWrite1h: 500, totalTokens: input + 13_000, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } });
|
||||||
|
const saved = (u: Usage) => ({ inputTokens: u.input, outputTokens: u.output, cacheReadTokens: u.cacheRead, cacheWriteTokens: u.cacheWrite, cacheWrite1hTokens: u.cacheWrite1h, reasoningTokens: 500 });
|
||||||
|
|
||||||
|
test("all catalog models price through Pi, including every tier boundary; saved usage is never mutated", () => {
|
||||||
|
assert.ok(models.length > 100);
|
||||||
|
for (const model of models) {
|
||||||
|
const sizes = [1_000, ...(model.cost.tiers ?? []).flatMap((t) => [Math.max(0, t.inputTokensAbove - 12_000), Math.max(0, t.inputTokensAbove - 12_000 + 1)])];
|
||||||
|
for (const input of sizes) {
|
||||||
|
const usage = nativeUsage(input);
|
||||||
|
const call = Object.freeze(saved(usage));
|
||||||
|
const before = structuredClone(call);
|
||||||
|
const expected = calculateCost(model, usage).total;
|
||||||
|
assert.equal(callCost(call, `${model.provider}:${model.id}`), expected, `${model.provider}:${model.id} input=${input}`);
|
||||||
|
assert.deepEqual(call, before);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("job capture preserves individual tier sizes and one-hour writes; old tier aggregates are unknown", () => {
|
||||||
|
const model = models.find((m) => m.cost.tiers?.length)!;
|
||||||
|
assert.ok(model);
|
||||||
|
const id = `${model.provider}:${model.id}`;
|
||||||
|
const threshold = model.cost.tiers![0]!.inputTokensAbove;
|
||||||
|
const first = nativeUsage(Math.ceil(threshold / 2));
|
||||||
|
const second = nativeUsage(Math.ceil(threshold / 2));
|
||||||
|
const total = accumulateJobUsage(accumulateJobUsage(undefined, id, piUsageToStep(first)), id, piUsageToStep(second));
|
||||||
|
assert.equal(total.calls?.length, 2);
|
||||||
|
assert.equal(total.cacheWrite1hTokens, 1000);
|
||||||
|
assert.equal(total.lastStep?.cacheWrite1hTokens, 500);
|
||||||
|
assert.equal(jobCost({ usage: total }), calculateCost(model, first).total + calculateCost(model, second).total);
|
||||||
|
assert.equal(jobCost({ usage: { ...total, calls: undefined } }), null);
|
||||||
|
const legacyOne = { ...total, ...saved(first), calls: undefined, steps: 1 };
|
||||||
|
assert.equal(jobCost({ usage: legacyOne }), calculateCost(model, first).total);
|
||||||
|
const resumedLegacy = accumulateJobUsage({ ...total, calls: undefined }, id, piUsageToStep(first));
|
||||||
|
assert.equal(resumedLegacy.calls, undefined, "never pretend the missing old calls were reconstructed");
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test, { after } from "node:test";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-pricing-transport-"));
|
||||||
|
process.env.LH_DATA_DIR = scratch;
|
||||||
|
process.env.LH_AUTH_FILE = path.join(scratch, "empty-auth.json");
|
||||||
|
fs.writeFileSync(process.env.LH_AUTH_FILE, "{}", { mode: 0o600 });
|
||||||
|
const { chatStore } = await import("../src/store/chats.ts");
|
||||||
|
const { GET } = await import("../app/api/chats/[id]/route.ts");
|
||||||
|
const { sessionCost } = await import("../src/server/pricing.ts");
|
||||||
|
after(() => fs.rmSync(scratch, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
test("chat GET supplies derived Pi costs without persisting dollars or changing native history", async () => {
|
||||||
|
const record = chatStore.createChat("price projection fixture");
|
||||||
|
chatStore.saveMessages(record.id, [{ id: "fixture-input", role: "user", content: "synthetic", timestamp: 1 }]);
|
||||||
|
const step = { inputTokens: 10_000, outputTokens: 1_000, cacheReadTokens: 70_000, cacheWriteTokens: 0, cacheWrite1hTokens: 0, reasoningTokens: 500 };
|
||||||
|
for (const [id, model] of [["api", "openrouter:openai/gpt-6.1-sol"], ["plan", "chatgpt:fixture"], ["unknown", "missing:fixture"]]) {
|
||||||
|
chatStore.appendTurnUsage(record.id, { id: id!, model: model!, messageId: "fixture-input", at: "fixture", steps: [step] });
|
||||||
|
}
|
||||||
|
const file = path.join(scratch, "chats", `${record.id}.json`);
|
||||||
|
const before = fs.readFileSync(file, "utf8");
|
||||||
|
const response = await GET(new Request(`http://localhost/api/chats/${record.id}`), { params: Promise.resolve({ id: record.id }) });
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
const body = await response.json();
|
||||||
|
assert.deepEqual(body.messages, chatStore.getChat(record.id)!.messages);
|
||||||
|
assert.deepEqual(body.pricing, sessionCost(chatStore.getChat(record.id)!));
|
||||||
|
assert.equal(body.pricing.cost, 0.037);
|
||||||
|
assert.equal(body.pricing.subscription, 1);
|
||||||
|
assert.equal(body.pricing.unpriced, 1);
|
||||||
|
assert.deepEqual(body.pricing.turnCosts, { api: 0.037, plan: null, unknown: null });
|
||||||
|
assert.equal(fs.readFileSync(file, "utf8"), before, "read-time estimates must never rewrite the usage log");
|
||||||
|
});
|
||||||
@@ -47,6 +47,8 @@ describe("standalone JavaScript tracing exclusions and native workaround policy"
|
|||||||
"node_modules/@napi-rs/canvas-*/**/*",
|
"node_modules/@napi-rs/canvas-*/**/*",
|
||||||
"node_modules/@resvg/resvg-js-*/**/*",
|
"node_modules/@resvg/resvg-js-*/**/*",
|
||||||
"node_modules/@earendil-works/pi-ai/dist/providers/data/**/*",
|
"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/@earendil-works/pi-agent-core/dist/**/*.json",
|
||||||
"node_modules/gcp-metadata/**/*",
|
"node_modules/gcp-metadata/**/*",
|
||||||
"node_modules/json-bigint/**/*",
|
"node_modules/json-bigint/**/*",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, it } from "node:test";
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { ActivitySection, formatElapsed, toBlobs, type ActivityFeed, type FeedExtraction } from "../components/activity-dock.tsx";
|
import { ActivitySection, formatElapsed, toBlobs, type ActivityFeed, type FeedExtraction } from "../components/activity-dock.tsx";
|
||||||
import { formatTokens, formatUsd } from "../components/usage-format.ts";
|
import { formatTokens, formatUsd } from "../components/usage-format.ts";
|
||||||
|
import { jobCost } from "../src/server/pricing.ts";
|
||||||
|
|
||||||
// The section's pure layer: folds, formatting, and blob order — then the SSR
|
// The section's pure layer: folds, formatting, and blob order — then the SSR
|
||||||
// render (blobs, "—" doctrine, the abort affordance, the always-present
|
// render (blobs, "—" doctrine, the abort affordance, the always-present
|
||||||
@@ -27,6 +28,7 @@ function job(over: Partial<ActivityFeed["jobs"][number]> = {}): ActivityFeed["jo
|
|||||||
updatedAt: new Date(NOW - 1000).toISOString(),
|
updatedAt: new Date(NOW - 1000).toISOString(),
|
||||||
title: "Limits lesson",
|
title: "Limits lesson",
|
||||||
contextLimit: null,
|
contextLimit: null,
|
||||||
|
costUsd: jobCost({ usage: over.usage }),
|
||||||
...over,
|
...over,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -57,7 +59,7 @@ describe("activity dock pure layer", () => {
|
|||||||
assert.ok(blobs.every((b) => b.chatId === "src_a"), "abort/link identity stays source-scoped");
|
assert.ok(blobs.every((b) => b.chatId === "src_a"), "abort/link identity stays source-scoped");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("folds cost with jobCost; absent usage is null, never zero", () => {
|
it("uses the server's Pi price projection; absent pricing is null, never zero", () => {
|
||||||
const priced = job({
|
const priced = job({
|
||||||
usage: {
|
usage: {
|
||||||
model: "openrouter:z-ai/glm-5.3-flash",
|
model: "openrouter:z-ai/glm-5.3-flash",
|
||||||
@@ -70,10 +72,10 @@ describe("activity dock pure layer", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const blob = toBlobs({ jobs: [priced], extractions: [] })[0]!;
|
const blob = toBlobs({ jobs: [priced], extractions: [] })[0]!;
|
||||||
// (10000-5000)/1e6*0.15 + 5000/1e6*0.03 + 1000/1e6*0.5 = 0.0014
|
// Pi-normalized fresh input is priced directly, never reduced by reads.
|
||||||
// (pi-ai catalog prices — glm-5.3-flash cacheRead corrected
|
assert.equal(blob.cost, 0.00215);
|
||||||
// 0.05 → 0.03 in the pi 1.0.2 catalog refresh)
|
const missingProjection = toBlobs({ jobs: [{ ...priced, costUsd: undefined }], extractions: [] })[0]!;
|
||||||
assert.ok(blob.cost !== null && blob.cost > 0.0013 && blob.cost < 0.0015);
|
assert.equal(missingProjection.cost, null, "the browser must not invent a fallback price");
|
||||||
const absent = toBlobs({ jobs: [job()], extractions: [] })[0]!;
|
const absent = toBlobs({ jobs: [job()], extractions: [] })[0]!;
|
||||||
assert.equal(absent.cost, null);
|
assert.equal(absent.cost, null);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { StatsBar } from "../components/stats-bar.tsx";
|
import { StatsBar, formatContextMeasurement } from "../components/stats-bar.tsx";
|
||||||
|
import { sessionCost } from "../src/server/pricing.ts";
|
||||||
import { catalogLimits } from "../components/model-picker.tsx";
|
import { catalogLimits } from "../components/model-picker.tsx";
|
||||||
import type { ModelCatalogEntry, ModelCatalogResponse } from "../src/core/contracts.ts";
|
import type { ModelCatalogEntry, ModelCatalogResponse } from "../src/core/contracts.ts";
|
||||||
import { prettyModel } from "../components/usage-format.ts";
|
import { prettyModel } from "../components/usage-format.ts";
|
||||||
@@ -50,7 +51,7 @@ describe("stats bar", () => {
|
|||||||
|
|
||||||
it("folds context + cost from the turns log (core/prices, no local math)", () => {
|
it("folds context + cost from the turns log (core/prices, no local math)", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<StatsBar turns={[turn(), turn()]} />,
|
<StatsBar turns={[turn(), turn()]} contextTokens={11_000} pricing={sessionCost({ turns: [turn(), turn()] })} />,
|
||||||
);
|
);
|
||||||
// last turn's last call: 10k in + 1k out = 11k. The LIMIT is the
|
// last turn's last call: 10k in + 1k out = 11k. The LIMIT is the
|
||||||
// live catalog's — and the bar doesn't hold the index yet (the
|
// live catalog's — and the bar doesn't hold the index yet (the
|
||||||
@@ -68,7 +69,7 @@ describe("stats bar", () => {
|
|||||||
|
|
||||||
it("shows — for cost when every turn is unpriceable, never a fake $0", () => {
|
it("shows — for cost when every turn is unpriceable, never a fake $0", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<StatsBar turns={[turn({ model: "nope/unknown" })]} />,
|
<StatsBar turns={[turn({ model: "nope/unknown" })]} contextTokens={11_000} pricing={sessionCost({ turns: [turn({ model: "nope/unknown" })] })} />,
|
||||||
);
|
);
|
||||||
assert.match(html, /data-testid="stats-cost">—</);
|
assert.match(html, /data-testid="stats-cost">—</);
|
||||||
// unknown model: no catalog limit exists, but the footprint is
|
// unknown model: no catalog limit exists, but the footprint is
|
||||||
@@ -77,6 +78,17 @@ describe("stats bar", () => {
|
|||||||
assert.match(html, /data-testid="stats-context">11k</);
|
assert.match(html, /data-testid="stats-context">11k</);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("unknown context is explicit; changing the selected limit preserves the measurement", () => {
|
||||||
|
assert.equal(formatContextMeasurement(null, 100_000), "? / 100k");
|
||||||
|
assert.equal(formatContextMeasurement(83_000, 100_000), "83k / 100k");
|
||||||
|
assert.equal(formatContextMeasurement(83_000, 200_000), "83k / 200k");
|
||||||
|
assert.equal(formatContextMeasurement(83_000, null), "83k");
|
||||||
|
const html = renderToStaticMarkup(<StatsBar chatId="fixture" turns={[turn()]} contextTokens={null} />);
|
||||||
|
assert.match(html, /data-testid="stats-context">\?</);
|
||||||
|
const empty = renderToStaticMarkup(<StatsBar chatId="fixture" turns={[]} />);
|
||||||
|
assert.match(empty, /data-testid="stats-context">\?</);
|
||||||
|
});
|
||||||
|
|
||||||
it("prettyModel strips vendor + date suffix, keeps dashes", () => {
|
it("prettyModel strips vendor + date suffix, keeps dashes", () => {
|
||||||
assert.equal(prettyModel("z-ai/glm-5.3-flash"), "glm-5.3-flash"); // dashes kept (owner round 5)
|
assert.equal(prettyModel("z-ai/glm-5.3-flash"), "glm-5.3-flash"); // dashes kept (owner round 5)
|
||||||
assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "qwen3.8-max");
|
assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "qwen3.8-max");
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/** Backend title isolation without launching Electron or touching a user profile. */
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { runInNewContext } from "node:vm";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const root = fileURLToPath(new URL("../", import.meta.url));
|
||||||
|
const mainFile = path.join(root, "electron/main.cjs");
|
||||||
|
const mainSource = readFileSync(mainFile, "utf8");
|
||||||
|
const mainRequire = createRequire(mainFile);
|
||||||
|
const guardSource = readFileSync(path.join(root, "electron/server-title-guard.cjs"), "utf8");
|
||||||
|
const backendEnv = { ELECTRON_RUN_AS_NODE: "1", LH_DESKTOP_SERVER: "1" };
|
||||||
|
|
||||||
|
function titleFixture(platform = "darwin", env: Record<string, string> = backendEnv) {
|
||||||
|
const nativeWrites: unknown[] = [];
|
||||||
|
let nativeReads = 0;
|
||||||
|
const process = { platform, env } as { platform: string; env: Record<string, string>; title: unknown };
|
||||||
|
Object.defineProperty(process, "title", {
|
||||||
|
configurable: true, enumerable: true,
|
||||||
|
get() { nativeReads++; return "initial native title"; },
|
||||||
|
set(value) { nativeWrites.push(value); },
|
||||||
|
});
|
||||||
|
const original = Object.getOwnPropertyDescriptor(process, "title");
|
||||||
|
runInNewContext(guardSource, { process });
|
||||||
|
return { process, nativeWrites, original, nativeReads: () => nativeReads };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("macOS backend process.title guard", () => {
|
||||||
|
it("reads the initial title once, then isolates strict-mode writes from the native setter", () => {
|
||||||
|
const fixture = titleFixture();
|
||||||
|
assert.equal(fixture.nativeReads(), 1);
|
||||||
|
assert.equal(fixture.process.title, "initial native title");
|
||||||
|
runInNewContext('"use strict"; process.title = "next-server (v16.3.2)";', { process: fixture.process });
|
||||||
|
assert.equal(fixture.process.title, "next-server (v16.3.2)");
|
||||||
|
fixture.process.title = "next-router-worker";
|
||||||
|
assert.equal(fixture.process.title, "next-router-worker");
|
||||||
|
assert.deepEqual(fixture.nativeWrites, []);
|
||||||
|
assert.equal(fixture.nativeReads(), 1);
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(fixture.process, "title")!;
|
||||||
|
assert.equal(typeof descriptor.get, "function");
|
||||||
|
assert.equal(typeof descriptor.set, "function");
|
||||||
|
assert.equal(descriptor.configurable, true);
|
||||||
|
assert.equal(descriptor.enumerable, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the approved String(value) contract, without native length truncation", () => {
|
||||||
|
const fixture = titleFixture();
|
||||||
|
for (const value of [42, true, null, undefined, Symbol("title"), "x".repeat(4096)]) {
|
||||||
|
fixture.process.title = value;
|
||||||
|
assert.equal(fixture.process.title, String(value));
|
||||||
|
}
|
||||||
|
let coercions = 0;
|
||||||
|
fixture.process.title = { toString() { coercions++; return "custom title"; } };
|
||||||
|
assert.equal(fixture.process.title, "custom title");
|
||||||
|
assert.equal(coercions, 1);
|
||||||
|
assert.throws(() => { fixture.process.title = { toString() { throw new Error("conversion failed"); } }; }, /conversion failed/);
|
||||||
|
assert.equal(fixture.process.title, "custom title");
|
||||||
|
assert.deepEqual(fixture.nativeWrites, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can be loaded again without restoring the native accessor", () => {
|
||||||
|
const fixture = titleFixture();
|
||||||
|
fixture.process.title = "remembered";
|
||||||
|
runInNewContext(guardSource, { process: fixture.process });
|
||||||
|
assert.equal(fixture.process.title, "remembered");
|
||||||
|
fixture.process.title = "next-server";
|
||||||
|
assert.deepEqual(fixture.nativeWrites, []);
|
||||||
|
assert.equal(fixture.nativeReads(), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [label, platform, env] of [
|
||||||
|
["Windows", "win32", backendEnv],
|
||||||
|
["Linux", "linux", backendEnv],
|
||||||
|
["full-Electron browser helper", "darwin", {}],
|
||||||
|
["non-backend Node child", "darwin", { ELECTRON_RUN_AS_NODE: "1" }],
|
||||||
|
["non-Node-mode process", "darwin", { LH_DESKTOP_SERVER: "1" }],
|
||||||
|
] as const) {
|
||||||
|
it(`leaves the original property untouched for ${label}`, () => {
|
||||||
|
const fixture = titleFixture(platform, env);
|
||||||
|
assert.deepEqual(Object.getOwnPropertyDescriptor(fixture.process, "title"), fixture.original);
|
||||||
|
assert.equal(fixture.nativeReads(), 0);
|
||||||
|
assert.deepEqual(fixture.nativeWrites, []);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function serverLaunch(platform: string) {
|
||||||
|
let launch!: { binary: string; args: string[]; options: { cwd: string; env: Record<string, string>; stdio: string[] } };
|
||||||
|
const child = Object.assign(new EventEmitter(), { stdout: new EventEmitter(), stderr: new EventEmitter() });
|
||||||
|
const context = {
|
||||||
|
require: (id: string) => id === "electron" ? {
|
||||||
|
app: { setName() {}, requestSingleInstanceLock: () => false, quit() {}, getPath: () => "/fixture/user-data" },
|
||||||
|
} : id === "node:child_process" ? {
|
||||||
|
spawn(binary: string, args: string[], options: typeof launch.options) {
|
||||||
|
launch = JSON.parse(JSON.stringify({ binary, args, options }));
|
||||||
|
return child;
|
||||||
|
},
|
||||||
|
} : mainRequire(id),
|
||||||
|
process: { platform, argv: [], env: { FIXTURE: "kept" }, on() {}, execPath: "/fixture/LearnHarness", resourcesPath: "/fixture/resources with spaces" },
|
||||||
|
__dirname: path.dirname(mainFile),
|
||||||
|
};
|
||||||
|
runInNewContext(mainSource, context, { filename: mainFile });
|
||||||
|
await runInNewContext("startServer(54321)", context);
|
||||||
|
return launch;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("backend-only preload wiring and packaging", () => {
|
||||||
|
for (const platform of ["darwin", "win32", "linux"]) {
|
||||||
|
it(`${platform} uses the real server launch with the intended argument order`, async () => {
|
||||||
|
const launch = await serverLaunch(platform);
|
||||||
|
const entry = path.join("/fixture/resources with spaces", "server", "standalone", "server.js");
|
||||||
|
assert.equal(launch.binary, "/fixture/LearnHarness");
|
||||||
|
assert.deepEqual(launch.args, platform === "darwin"
|
||||||
|
? ["--require", path.join("/fixture/resources with spaces", "server-title-guard.cjs"), entry]
|
||||||
|
: [entry]);
|
||||||
|
assert.equal(launch.options.cwd, path.dirname(entry));
|
||||||
|
assert.equal(launch.options.env.NODE_OPTIONS, undefined, "do not add NODE_OPTIONS to the child environment");
|
||||||
|
assert.equal(launch.options.env.ELECTRON_RUN_AS_NODE, "1");
|
||||||
|
assert.equal(launch.options.env.LH_DESKTOP_SERVER, "1");
|
||||||
|
assert.equal(launch.options.env.LH_DATA_DIR, path.join("/fixture/user-data", "data"));
|
||||||
|
assert.equal(launch.options.env.LH_AUTH_FILE, path.join("/fixture/user-data", "auth.json"));
|
||||||
|
assert.equal(launch.options.env.PORT, "54321");
|
||||||
|
assert.equal(launch.options.env.HOSTNAME, "127.0.0.1");
|
||||||
|
assert.equal(launch.options.env.FIXTURE, "kept");
|
||||||
|
assert.deepEqual(launch.options.stdio, ["ignore", "pipe", "pipe", "ipc"]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("electron-builder copies the guard outside ASAR only on macOS, retaining shared resources", async () => {
|
||||||
|
const yaml = require("js-yaml");
|
||||||
|
const config = yaml.load(readFileSync(path.join(root, "electron-builder.yml"), "utf8"));
|
||||||
|
const { getFileMatchers, copyFiles } = require("app-builder-lib/out/fileMatcher.js");
|
||||||
|
const destination = mkdtempSync(path.join(tmpdir(), "lh-title-resource-"));
|
||||||
|
try {
|
||||||
|
for (const platform of ["mac", "win", "linux"]) {
|
||||||
|
const matchers = getFileMatchers(config, "extraResources", destination, {
|
||||||
|
defaultSrc: root, customBuildOptions: config[platform], macroExpander: (value: string) => value,
|
||||||
|
globalOutDir: path.join(root, "dist"),
|
||||||
|
});
|
||||||
|
assert.ok(matchers.some((matcher: { to: string }) => matcher.to === path.join(destination, "server")), "shared standalone payload remains included");
|
||||||
|
const guards = matchers.filter((matcher: { to: string }) => matcher.to === path.join(destination, "server-title-guard.cjs"));
|
||||||
|
assert.equal(guards.length, platform === "mac" ? 1 : 0);
|
||||||
|
if (platform === "mac") {
|
||||||
|
await copyFiles(guards, null, false);
|
||||||
|
assert.equal(readFileSync(path.join(destination, "server-title-guard.cjs"), "utf8"), guardSource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
rmSync(destination, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+202
-1
@@ -25,7 +25,7 @@ import { absorbed, pendingCallTool, SessionModel } from "../components/session-c
|
|||||||
import type { ChatInput } from "../src/core/input-events.ts";
|
import type { ChatInput } from "../src/core/input-events.ts";
|
||||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||||
import { newUserMessage, newAnswerResult, newThinkingMessage, needsContinuation, endsInDismissal } from "../src/core/native-messages.ts";
|
import { newUserMessage, newAnswerResult, newThinkingMessage, needsContinuation, endsInDismissal } from "../src/core/native-messages.ts";
|
||||||
import { projectView } from "../src/core/index.ts";
|
import { newCompactionMarker, newPlanBlockMessage, projectView } from "../src/core/index.ts";
|
||||||
import { dismissedAnswer } from "../src/core/question-core.ts";
|
import { dismissedAnswer } from "../src/core/question-core.ts";
|
||||||
import { buildAnswer, type QuestionData } from "../src/core/question-core.ts";
|
import { buildAnswer, type QuestionData } from "../src/core/question-core.ts";
|
||||||
|
|
||||||
@@ -483,6 +483,207 @@ it("Wave C: markers injected mid-run (custom role, follow-up delivery) append to
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── provider failures: one final-entry rule for live and saved records ──────
|
||||||
|
|
||||||
|
describe("provider-error banner (live and saved)", () => {
|
||||||
|
function failed(id = "failed", errorMessage: string | undefined = "Google 500: fixture failure"): LhMessage {
|
||||||
|
return { ...assistantMsg(id, ""), content: [], stopReason: "error", errorMessage } as LhMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("restores an empty failed response on load and full sync without adding a transcript card", () => {
|
||||||
|
const record = [newUserMessage("topic"), failed()];
|
||||||
|
const m = model({ initialMessages: record });
|
||||||
|
assert.equal(m.getState().error?.message, "Google 500: fixture failure");
|
||||||
|
assert.equal(m.getState().status, "ready");
|
||||||
|
assert.equal(m.readyRef, true, "a restored provider failure does not block retry");
|
||||||
|
assert.deepEqual(m.getState().nativeMessages, record, "diagnostic history stays intact");
|
||||||
|
assert.equal(m.getState().messages.length, 1, "only the user bubble, no permanent error card");
|
||||||
|
const synced = model();
|
||||||
|
synced.setMessages(record);
|
||||||
|
assert.equal(synced.getState().error?.message, m.getState().error?.message);
|
||||||
|
const adopted = model({ initialMessages: record.slice(0, 1) });
|
||||||
|
adopted.adoptRecord(record);
|
||||||
|
assert.equal(adopted.getState().error?.message, m.getState().error?.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the existing fallback wording when the saved failure has no error text", () => {
|
||||||
|
const withoutText = { ...failed(), errorMessage: undefined } as LhMessage;
|
||||||
|
assert.equal(model({ initialMessages: [withoutText] }).getState().error?.message, "An error occurred.");
|
||||||
|
assert.equal(model().getState().error, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("any later entry supersedes the failure, including plan/compaction markers and aborts", () => {
|
||||||
|
const failure = failed();
|
||||||
|
const later: LhMessage[] = [
|
||||||
|
newUserMessage("retry"),
|
||||||
|
assistantMsg("success", "Hello"),
|
||||||
|
{ ...assistantMsg("aborted", ""), stopReason: "aborted", errorMessage: "Aborted" } as LhMessage,
|
||||||
|
newThinkingMessage("problem", "my attempt"),
|
||||||
|
resultMsg("call"),
|
||||||
|
newPlanBlockMessage("plan", { kind: "full", nodes: [], edges: [], current: null }),
|
||||||
|
newCompactionMarker("summary", { firstKeptMessageId: failure.id, tokensBefore: 100, model: "fixture", at: "2026-01-01T00:00:00Z" }),
|
||||||
|
];
|
||||||
|
for (const entry of later) {
|
||||||
|
const m = model({ initialMessages: [failure] });
|
||||||
|
m.applyWireEvent({ type: "message_end", message: failure });
|
||||||
|
assert.ok(m.getState().error, "the live failure is initially visible");
|
||||||
|
m.setMessages([failure, entry]);
|
||||||
|
assert.equal(m.getState().error, null, `live banner superseded by ${entry.role}`);
|
||||||
|
assert.equal(model({ initialMessages: [failure, entry] }).getState().error, null, "reopened view agrees");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a newer live message supersedes the failure before its response finishes", () => {
|
||||||
|
const m = model();
|
||||||
|
m.applyWireEvent({ type: "message_end", message: failed() });
|
||||||
|
assert.ok(m.getState().error);
|
||||||
|
m.applyWireEvent({ type: "message_start", message: assistantMsg("next", "") });
|
||||||
|
assert.equal(m.getState().error, null);
|
||||||
|
m.applyWireEvent({ type: "message_end", message: assistantMsg("next", "Hello") });
|
||||||
|
assert.equal(m.getState().error, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat an unfinished assistant's error-shaped partial as a completed failure", () => {
|
||||||
|
const m = model();
|
||||||
|
const failure = failed();
|
||||||
|
m.applyWireEvent({ type: "message_start", message: failure });
|
||||||
|
assert.equal(m.getState().error, null);
|
||||||
|
m.applyWireEvent({ type: "message_update", message: failure });
|
||||||
|
assert.equal(m.getState().error, null);
|
||||||
|
m.applyWireEvent({ type: "message_end", message: failure });
|
||||||
|
assert.equal(m.getState().error?.message, "Google 500: fixture failure");
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const input of [{ type: "text", text: "retry" }, { type: "continue" }] as const) {
|
||||||
|
it(`hides the old failure during the optimistic ${input.type} wait, then shows a new live failure`, async () => {
|
||||||
|
let acknowledge!: (response: Response) => void;
|
||||||
|
const ack = new Promise<Response>((resolve) => { acknowledge = resolve; });
|
||||||
|
const old = failed("old", "old failure");
|
||||||
|
const fresh = failed("fresh", "new failure");
|
||||||
|
let finish!: () => void;
|
||||||
|
const stream = new ReadableStream<Uint8Array>({ start(controller) {
|
||||||
|
finish = () => {
|
||||||
|
controller.enqueue(new TextEncoder().encode(sse("[DONE]")));
|
||||||
|
controller.close();
|
||||||
|
};
|
||||||
|
} });
|
||||||
|
const m = model({ initialMessages: [old], fetch: async (url) => {
|
||||||
|
if (url === "/api/chat") return ack;
|
||||||
|
if (url === "/api/chats/chat_x") return Response.json({ messages: [old] });
|
||||||
|
if (url === "/api/chats/chat_x/stream") return new Response(stream);
|
||||||
|
throw new Error(`unexpected fetch: ${url}`);
|
||||||
|
} });
|
||||||
|
assert.equal(m.getState().error?.message, "old failure");
|
||||||
|
const sending = m.send(input);
|
||||||
|
assert.equal(m.getState().status, "submitted");
|
||||||
|
assert.equal(m.getState().error, null, "hidden even before the acknowledgement");
|
||||||
|
acknowledge(Response.json({ ok: true }));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
assert.equal(m.getState().error, null, "refetching the old failed record does not resurrect it");
|
||||||
|
m.applyWireEvent({ type: "message_start", message: fresh });
|
||||||
|
assert.equal(m.getState().error, null, "no error from an unfinished partial");
|
||||||
|
m.applyWireEvent({ type: "message_end", message: fresh });
|
||||||
|
assert.equal(m.getState().status, "submitted");
|
||||||
|
assert.equal(m.getState().error?.message, "new failure", "new failure appears before stream closure");
|
||||||
|
finish();
|
||||||
|
await sending;
|
||||||
|
assert.equal(m.getState().error?.message, "new failure");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("hides the old failure immediately on fresh-mount reattach, and restores final-entry truth at idle", async () => {
|
||||||
|
let settle!: (response: Response) => void;
|
||||||
|
const response = new Promise<Response>((resolve) => { settle = resolve; });
|
||||||
|
const m = model({ initialMessages: [failed()], fetch: async () => response });
|
||||||
|
const attaching = m.resumeStream(false);
|
||||||
|
assert.equal(m.getState().status, "submitted");
|
||||||
|
assert.equal(m.getState().error, null);
|
||||||
|
settle(sseResponse({ type: "turn-settled" }, "[DONE]"));
|
||||||
|
await attaching;
|
||||||
|
assert.equal(m.getState().error?.message, "Google 500: fixture failure", "no newer entry superseded it");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers a failure that settled before the submitter could attach to its stream", async () => {
|
||||||
|
const record = [newUserMessage("topic"), failed()];
|
||||||
|
const m = model({ fetch: async (url) => {
|
||||||
|
if (url === "/api/chat") return Response.json({ ok: true });
|
||||||
|
if (url === "/api/chats/chat_x") return Response.json({ messages: record });
|
||||||
|
if (url === "/api/chats/chat_x/stream") return sseResponse({ type: "turn-settled" }, "[DONE]");
|
||||||
|
throw new Error(`unexpected fetch: ${url}`);
|
||||||
|
} });
|
||||||
|
m.setReattachSettled(() => m.setMessages(record));
|
||||||
|
await m.send({ type: "text", text: "topic" });
|
||||||
|
assert.equal(m.getState().status, "ready");
|
||||||
|
assert.equal(m.getState().error?.message, "Google 500: fixture failure");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a newer live provider failure replaces an earlier attachment error immediately", async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
const seen: Array<{ status: string; error: string | undefined }> = [];
|
||||||
|
const m = model({ fetch: async () => {
|
||||||
|
if (++attempts === 1) return new Response("earlier attach failure", { status: 500 });
|
||||||
|
return sseResponse(
|
||||||
|
{ type: "message_start", message: failed("new", "new provider failure") },
|
||||||
|
{ type: "message_end", message: failed("new", "new provider failure") },
|
||||||
|
"[DONE]",
|
||||||
|
);
|
||||||
|
} });
|
||||||
|
await m.resumeStream(false);
|
||||||
|
assert.equal(m.getState().error?.message, "earlier attach failure");
|
||||||
|
m.onChange = () => { seen.push({ status: m.status, error: m.getState().error?.message }); };
|
||||||
|
await m.resumeStream(false);
|
||||||
|
assert.ok(seen.some((s) => s.status === "streaming" && s.error === "new provider failure"), "new failure wins before stream closure");
|
||||||
|
assert.equal(m.getState().error?.message, "new provider failure");
|
||||||
|
assert.equal(model({ initialMessages: m.getState().nativeMessages }).getState().error?.message, "new provider failure", "live and reopened views agree");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an aborted older consume cannot restore the old banner during a replacement reattach", async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
let firstRead!: () => void;
|
||||||
|
const reading = new Promise<void>((resolve) => { firstRead = resolve; });
|
||||||
|
let settleReplacement!: (response: Response) => void;
|
||||||
|
const replacement = new Promise<Response>((resolve) => { settleReplacement = resolve; });
|
||||||
|
const m = model({ initialMessages: [failed()], fetch: async (_url, init) => {
|
||||||
|
if (++attempts > 1) return replacement;
|
||||||
|
return new Response(new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(new TextEncoder().encode(sse({ type: "agent_start" })));
|
||||||
|
init?.signal?.addEventListener("abort", () => controller.error(new DOMException("Aborted", "AbortError")), { once: true });
|
||||||
|
},
|
||||||
|
pull() { firstRead(); },
|
||||||
|
}));
|
||||||
|
} });
|
||||||
|
const first = m.resumeStream(false);
|
||||||
|
await reading;
|
||||||
|
const second = m.resumeStream(false);
|
||||||
|
await first;
|
||||||
|
assert.equal(m.getState().status, "submitted", "old cleanup must not settle the replacement attempt");
|
||||||
|
assert.equal(m.getState().error, null, "old saved failure stays hidden during the replacement's Thinking wait");
|
||||||
|
settleReplacement(sseResponse(
|
||||||
|
{ type: "message_start", message: failed("new", "replacement failure") },
|
||||||
|
{ type: "message_end", message: failed("new", "replacement failure") },
|
||||||
|
"[DONE]",
|
||||||
|
));
|
||||||
|
await second;
|
||||||
|
assert.equal(m.getState().error?.message, "replacement failure");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps loop/request errors transient, with the existing precedence and clearError behavior", async () => {
|
||||||
|
const m = model({ initialMessages: [failed()] });
|
||||||
|
m.applyWireEvent({ type: "error", errorText: "loop failure" });
|
||||||
|
assert.equal(m.getState().error?.message, "loop failure");
|
||||||
|
assert.equal(model({ initialMessages: m.getState().nativeMessages }).getState().error?.message, "Google 500: fixture failure");
|
||||||
|
m.clearError();
|
||||||
|
assert.equal(m.getState().error?.message, "Google 500: fixture failure");
|
||||||
|
m.fetch = async () => new Response("request refused", { status: 500 });
|
||||||
|
await m.send({ type: "text", text: "retry" });
|
||||||
|
assert.equal(m.getState().error?.message, "request refused");
|
||||||
|
assert.equal(model({ initialMessages: m.getState().nativeMessages }).getState().error, null, "request errors are not fabricated as saved messages");
|
||||||
|
m.clearError();
|
||||||
|
assert.equal(m.getState().error, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── reattach (resumeStream) ────────────────────────────────────────────────
|
// ─── reattach (resumeStream) ────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("resumeStream (the reattach path)", () => {
|
describe("resumeStream (the reattach path)", () => {
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import React, { act } from "react";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { parseHTML } from "linkedom";
|
||||||
|
import { StatsBar } from "../components/stats-bar.tsx";
|
||||||
|
import { notifySettingsSaved } from "../components/settings-signal.ts";
|
||||||
|
import type { ModelCatalogResponse, TurnUsage } from "../src/core/index.ts";
|
||||||
|
|
||||||
|
(globalThis as Record<string, unknown>).React = React;
|
||||||
|
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 });
|
||||||
|
|
||||||
|
test("meter keeps its measured numerator across selected-model/catalog changes, and renders post-compaction unknown", async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
let selected = "a";
|
||||||
|
const catalog: ModelCatalogResponse = {
|
||||||
|
models: ["a", "b"].map((id, i) => ({ id, provider: "fixture", name: id, input: ["text"], reasoning: false, supportedLevels: ["off"], maxTokens: 1000, contextWindow: 100_000 * (i + 1), cost: { input: 1, output: 1, cacheRead: 1, cacheWrite: 1 } })),
|
||||||
|
providers: [], defaults: null, slots: { strong: null, cheap: null, vision: null }, rolePins: {} as ModelCatalogResponse["rolePins"],
|
||||||
|
};
|
||||||
|
globalThis.fetch = async (input) => {
|
||||||
|
if (input === "/api/models") return Response.json(catalog);
|
||||||
|
if (input === "/api/settings") return Response.json({ settings: { models: {} } });
|
||||||
|
if (input === "/api/chats/fixture") return Response.json({ settings: { model: { provider: "fixture", id: selected }, thinkingLevel: "off" } });
|
||||||
|
throw new Error(`unexpected fixture request ${String(input)}`);
|
||||||
|
};
|
||||||
|
const host = document.createElement("div"); document.body.append(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
const turn: TurnUsage = { id: "summary", messageId: "marker", at: "fixture", model: "fixture:a", steps: [{ inputTokens: 4300, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 }] };
|
||||||
|
const pricing = { cost: 0.003, unpriced: 0, turnCosts: { summary: 0.003 } };
|
||||||
|
try {
|
||||||
|
await act(async () => root.render(<StatsBar chatId="fixture" turns={[turn]} contextTokens={83_000} pricing={pricing} />));
|
||||||
|
const context = () => host.querySelector('[data-testid="stats-context"]')?.textContent;
|
||||||
|
assert.equal(context(), "83k / 100k", "summarizer's 4.3k money log is not the meter source");
|
||||||
|
selected = "b";
|
||||||
|
await act(async () => notifySettingsSaved("models"));
|
||||||
|
assert.equal(context(), "83k / 200k", "only denominator changes");
|
||||||
|
await act(async () => root.render(<StatsBar chatId="fixture" turns={[turn]} contextTokens={null} pricing={pricing} />));
|
||||||
|
assert.equal(context(), "? / 200k");
|
||||||
|
assert.equal(host.querySelector('[data-testid="stats-cost"]')?.textContent, "$0.0030", "summary spending is still visible");
|
||||||
|
await act(async () => root.render(<StatsBar chatId="fixture" turns={[turn]} contextTokens={72_000} pricing={pricing} />));
|
||||||
|
assert.equal(context(), "72k / 200k");
|
||||||
|
} finally {
|
||||||
|
await act(async () => root.unmount()); host.remove(); globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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,133 @@
|
|||||||
|
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[], retry: [] 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 (id) => { calls.retry.push(id); },
|
||||||
|
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 maybeButton(text: string, scope: ParentNode = document) { return [...scope.querySelectorAll<HTMLButtonElement>("button")].find((element) => element.textContent === text); }
|
||||||
|
function button(text: string, scope: ParentNode = document) { const found = maybeButton(text, scope); 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"]')!;
|
||||||
|
const toggle = () => dialog().querySelector<HTMLElement>('[role="switch"]')!;
|
||||||
|
const footer = () => dialog().querySelector<HTMLElement>('[data-testid="subscription-connection-footer"]')!;
|
||||||
|
async function click(element: HTMLElement) { assert(element); await act(async () => element.click()); }
|
||||||
|
async function connectionStep(id: string) { await click(button("Connect", row(id))); await click(button("Continue", dialog())); }
|
||||||
|
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"));
|
||||||
|
});
|
||||||
|
for (const [id, label] of [["github-copilot", "GitHub Copilot"], ["xai", "Grok"]] as const) test(`${id}: disclosure and connection are separate steps with no duplicated content or early authorization`, async () => {
|
||||||
|
const f = fixture(); await mount(); await click(button("Connect", row(id)));
|
||||||
|
assert.equal(dialog().querySelector("h2")?.textContent, label);
|
||||||
|
assert(dialog().querySelector('[data-testid="subscription-privacy-notice"]')); assert(!toggle());
|
||||||
|
assert.equal(!!dialog().querySelector('[data-testid="copilot-policy-notice"]'), id === "github-copilot");
|
||||||
|
if (id === "github-copilot") assert(dialog().textContent?.includes(COPILOT_POLICY_NOTICE));
|
||||||
|
assert(!maybeButton("Connect", dialog())); assert.deepEqual(f.calls.login, []); assert.deepEqual(f.calls.consent, []);
|
||||||
|
await click(button("Continue", dialog()));
|
||||||
|
assert.equal(dialog().querySelector("h2")?.textContent, label); assert.equal(toggle().getAttribute("aria-checked"), "true");
|
||||||
|
assert(!dialog().querySelector('[data-testid="subscription-privacy-notice"], [data-testid="copilot-policy-notice"]'));
|
||||||
|
assert.equal(f.calls.login.length, 0); await click(toggle()); assert.deepEqual(f.calls.consent, []);
|
||||||
|
await click(button("Connect", dialog())); assert.deepEqual(f.calls.login, [[id, false]]);
|
||||||
|
assert(dialog().textContent?.includes("PUBLIC-CODE")); assert(!dialog().querySelector('[data-testid="subscription-privacy-notice"]'));
|
||||||
|
await click(button("Open browser", dialog())); assert.deepEqual(f.calls.open, [[id, "attempt", "opaque-action"]]);
|
||||||
|
await click(button("Cancel", dialog())); assert.equal(f.calls.cancel, 1); assert(!dialog());
|
||||||
|
await click(button("Connect", row(id === "xai" ? "github-copilot" : "xai"))); const newer = dialog(); await act(async () => f.login.resolve());
|
||||||
|
assert(dialog() === newer, "old completion cannot close another provider dialog");
|
||||||
|
});
|
||||||
|
for (const phase of ["notice", "connection"] as const) test(`cancelling the native ${phase} step starts no OAuth and saves no draft preference`, async () => {
|
||||||
|
const f = fixture(); await mount(); await click(button("Connect", row("xai")));
|
||||||
|
if (phase === "connection") { await click(button("Continue", dialog())); await click(toggle()); }
|
||||||
|
await click(button("Cancel", dialog())); assert(!dialog()); assert.deepEqual(f.calls.login, []); assert.deepEqual(f.calls.consent, []); assert.equal(f.calls.cancel, 0);
|
||||||
|
await connectionStep("xai"); assert.equal(toggle().getAttribute("aria-checked"), "true");
|
||||||
|
});
|
||||||
|
test("native step transition moves keyboard focus into the new connection controls", async () => {
|
||||||
|
const original = HTMLElement.prototype.focus, focused: HTMLElement[] = [];
|
||||||
|
HTMLElement.prototype.focus = function () { focused.push(this); };
|
||||||
|
try { fixture(); await mount(); await connectionStep("xai"); assert.equal(focused.at(-1), toggle()); }
|
||||||
|
finally { HTMLElement.prototype.focus = original; }
|
||||||
|
});
|
||||||
|
test("healthy management has only controls, quiet Manage, destructive action left and Close right", async () => {
|
||||||
|
const f = fixture(true); await mount();
|
||||||
|
assert(!row("xai").textContent?.includes("Connected")); assert.match(button("Manage…", row("xai")).className, /text-muted-foreground/);
|
||||||
|
await click(button("Manage…", row("xai")));
|
||||||
|
assert.equal(dialog().querySelector("h2")?.textContent, "Grok");
|
||||||
|
assert(!dialog().querySelector("select")); assert(!dialog().textContent?.includes("Add accounts"));
|
||||||
|
assert(!dialog().querySelector('[data-testid="subscription-privacy-notice"]')); assert(!maybeButton("Reconnect", dialog())); assert(!maybeButton("Sign in again", dialog()));
|
||||||
|
assert.equal(footer().firstElementChild, button("Disconnect", dialog())); assert.match(button("Disconnect", dialog()).className, /text-red/);
|
||||||
|
assert.equal(footer().lastElementChild?.lastElementChild, button("Close", dialog()));
|
||||||
|
await click(toggle()); 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.equal(dialog().querySelector("h2")?.textContent, "Grok"); assert(dialog().textContent?.includes("does not cancel your subscription or revoke access"));
|
||||||
|
await click(button("Cancel", dialog())); assert(toggle()); assert(!maybeButton("Reconnect", dialog()));
|
||||||
|
await click(button("Disconnect", dialog())); await click(button("Disconnect", dialog())); assert.deepEqual(f.calls.disconnect, ["xai"]); assert(!dialog());
|
||||||
|
});
|
||||||
|
test("authorization trouble offers Retry and staged Sign in again without removing the saved connection", async () => {
|
||||||
|
const f = fixture(true); f.push("xai", { connected: false, allowBackgroundUse: false, error: "The connection could not be refreshed." });
|
||||||
|
await mount(); await click(button("Manage…", row("xai"))); await click(button("Retry", dialog())); assert.deepEqual(f.calls.retry, ["xai"]);
|
||||||
|
await click(button("Sign in again", dialog())); assert(dialog().querySelector('[data-testid="subscription-privacy-notice"]')); assert(!toggle());
|
||||||
|
assert.deepEqual(f.calls.login, []); assert.deepEqual(f.calls.disconnect, []); assert.equal(f.statuses.find((s) => s.providerId === "xai")!.connectionId, "local-xai");
|
||||||
|
await click(button("Continue", dialog())); assert.equal(toggle().getAttribute("aria-checked"), "false");
|
||||||
|
await click(toggle()); await click(button("Connect", dialog())); assert.deepEqual(f.calls.login, [["xai", true]]); assert.deepEqual(f.calls.consent, []);
|
||||||
|
await click(button("Cancel", dialog())); assert.equal(f.calls.cancel, 1); assert.deepEqual(f.calls.disconnect, []); assert.equal(f.statuses.find((s) => s.providerId === "xai")!.allowBackgroundUse, false);
|
||||||
|
await act(async () => f.login.resolve());
|
||||||
|
});
|
||||||
|
test("a non-authority error on a still-working connection does not expose a healthy reauthorization action", async () => {
|
||||||
|
const f = fixture(true); f.push("xai", { error: "The replacement sign-in did not complete." });
|
||||||
|
await mount(); await click(button("Manage…", row("xai"))); assert(!maybeButton("Sign in again", dialog())); assert(!maybeButton("Retry", dialog()));
|
||||||
|
});
|
||||||
|
test("storage/desktop unavailability is not presented as an authorization repair action", async () => {
|
||||||
|
const f = fixture(true); await mount(); await click(button("Manage…", row("xai")));
|
||||||
|
await act(async () => f.push("xai", { available: false, connected: false, error: "The saved connection could not be read or written safely." }));
|
||||||
|
assert(!maybeButton("Sign in again", dialog())); assert(!maybeButton("Retry", dialog())); assert(button("Close", dialog()));
|
||||||
|
});
|
||||||
|
test("required native prompts render and commit cannot be cancelled through button, Escape or backdrop", async () => {
|
||||||
|
const f = fixture(); await mount(); await connectionStep("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()));
|
||||||
|
assert(!dialog().querySelector('[data-testid="subscription-privacy-notice"]'));
|
||||||
|
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 () => {
|
||||||
|
const backdrop = document.querySelector<HTMLElement>('[data-testid="subscription-dialog-backdrop"]')!;
|
||||||
|
for (const type of ["pointerdown", "pointerup", "click"]) { const event = new window.Event(type, { bubbles: true }); Object.defineProperties(event, { pointerId: { value: 1 }, isPrimary: { value: true }, button: { value: 0 } }); backdrop.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"));
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user