feat: add verified desktop ChatGPT subscription connection
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
/** Client-safe status only. Credentials and OAuth transaction data never enter this contract. */
|
||||
export interface ChatGPTStatus {
|
||||
available: boolean;
|
||||
pending: boolean;
|
||||
activeId: string | null;
|
||||
accounts: { id: string; email: string; registration: string; connected: boolean; background: boolean; planEnabled?: boolean }[];
|
||||
error: string | null;
|
||||
catalogError?: string | null;
|
||||
}
|
||||
export interface ChatGPTControlBridge {
|
||||
status(): Promise<ChatGPTStatus>;
|
||||
login(id?: string): Promise<ChatGPTStatus>;
|
||||
cancel(): Promise<ChatGPTStatus>;
|
||||
select(id: string): Promise<ChatGPTStatus>;
|
||||
logout(id: string): Promise<ChatGPTStatus>;
|
||||
consent(id: string, enabled: boolean): Promise<ChatGPTStatus>;
|
||||
onChange(callback: () => void): () => void;
|
||||
}
|
||||
@@ -18,10 +18,12 @@
|
||||
*/
|
||||
|
||||
import type { CallUsage, EmissionStats, ExtractionRunUsage } from "./contracts.ts";
|
||||
import { callCost, sumSteps } from "./prices.ts";
|
||||
import { callCost, sumSteps, isChatGPTPlan } from "./prices.ts";
|
||||
|
||||
/** One pipeline stage's cumulative cost across every run. */
|
||||
export interface StageCost {
|
||||
/** ChatGPT plan stage-runs, excluded from API dollars and unpriced counts. */
|
||||
subscriptionRuns?: number;
|
||||
/** Inferred USD summed over PRICED runs (partial when unpricedRuns > 0). */
|
||||
dollars: number;
|
||||
/** Stage-runs whose model is missing from PRICES (render "—", never zero). */
|
||||
@@ -34,6 +36,7 @@ export interface StageCost {
|
||||
|
||||
/** One run in the recent-runs list (extraction or naming). */
|
||||
export interface RecentRun {
|
||||
subscriptionRuns?: number;
|
||||
startedAt: number;
|
||||
/** ISO wall-clock. */
|
||||
at: string;
|
||||
@@ -51,9 +54,10 @@ export interface RecentRun {
|
||||
|
||||
/** The cost pill's whole payload: stage totals + recent runs. */
|
||||
export interface GraphCostSummary {
|
||||
subscriptionRuns?: number;
|
||||
/** Inferred USD across all priced stages of all runs. */
|
||||
totalDollars: number;
|
||||
/** Runs where EVERY stage was unpriced (totalDollars excludes them). */
|
||||
/** Runs with no priced API stage and at least one unknown stage. */
|
||||
unpricedRuns: number;
|
||||
/** The billable pipeline stages. (Wave 7, owner ruling 2026-09-23:
|
||||
* the embedding stage is gone with the provider embedder; the
|
||||
@@ -89,6 +93,7 @@ const emptyStage = (): StageCost => ({ dollars: 0, unpricedRuns: 0, runs: 0, tok
|
||||
/** One stage's dollars over its steps; null when the model is unpriced
|
||||
* (all-or-nothing: a stage runs one model). */
|
||||
function stageDollars(model: string, steps: readonly CallUsage[]): number | null {
|
||||
if (isChatGPTPlan(model)) return null;
|
||||
let total = 0;
|
||||
for (const step of steps) {
|
||||
const c = callCost(step, model);
|
||||
@@ -121,13 +126,15 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
|
||||
extraction.runs++;
|
||||
mergeTokens(extraction.tokens, stages.extractor.steps);
|
||||
const extDollars = stageDollars(stages.extractor.model, stages.extractor.steps);
|
||||
if (extDollars === null) extraction.unpricedRuns++;
|
||||
if (isChatGPTPlan(stages.extractor.model)) extraction.subscriptionRuns = (extraction.subscriptionRuns ?? 0) + 1;
|
||||
else if (extDollars === null) extraction.unpricedRuns++;
|
||||
else extraction.dollars += extDollars;
|
||||
|
||||
audit.runs++;
|
||||
mergeTokens(audit.tokens, stages.auditor.steps);
|
||||
const audDollars = stageDollars(stages.auditor.model, stages.auditor.steps);
|
||||
if (audDollars === null) audit.unpricedRuns++;
|
||||
if (isChatGPTPlan(stages.auditor.model)) audit.subscriptionRuns = (audit.subscriptionRuns ?? 0) + 1;
|
||||
else if (audDollars === null) audit.unpricedRuns++;
|
||||
else audit.dollars += audDollars;
|
||||
|
||||
// Run-level: null only when EVERY stage is unpriced.
|
||||
@@ -135,8 +142,10 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
|
||||
const runDollars = allStages.every((d) => d === null)
|
||||
? null
|
||||
: allStages.reduce<number>((a, d) => a + (d ?? 0), 0);
|
||||
const subscriptionRuns = [stages.extractor, stages.auditor].filter((stage) => isChatGPTPlan(stage.model)).length;
|
||||
const allSteps = [...stages.extractor.steps, ...stages.auditor.steps];
|
||||
runs.push({
|
||||
...(subscriptionRuns ? { subscriptionRuns } : {}),
|
||||
startedAt: run.startedAt,
|
||||
at: run.at,
|
||||
durationMs: run.durationMs,
|
||||
@@ -159,8 +168,10 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
|
||||
const capped = runs.slice(0, input.recentCap ?? 10);
|
||||
|
||||
// A run is "unpriced" when it billed nothing countable at all.
|
||||
const unpricedRuns = runs.filter((r) => r.dollars === null).length;
|
||||
const unpricedRuns = runs.filter((r) => r.dollars === null && (r.subscriptionRuns ?? 0) < 2).length;
|
||||
const subscriptionRuns = (extraction.subscriptionRuns ?? 0) + (audit.subscriptionRuns ?? 0);
|
||||
return {
|
||||
...(subscriptionRuns ? { subscriptionRuns } : {}),
|
||||
totalDollars: extraction.dollars + audit.dollars,
|
||||
unpricedRuns,
|
||||
stages: { extraction, audit },
|
||||
|
||||
@@ -48,6 +48,7 @@ export * from "./input-events.ts";
|
||||
export * from "./view.ts";
|
||||
export * from "./tool-parts.ts";
|
||||
export * from "./prices.ts";
|
||||
export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.ts";
|
||||
export * from "./cost-summary.ts";
|
||||
export * from "./fuzzy.ts";
|
||||
// settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19):
|
||||
|
||||
@@ -674,6 +674,57 @@ export function endsInDismissal(messages: readonly LhMessage[]): boolean {
|
||||
return last !== undefined && isDismissedResult(last);
|
||||
}
|
||||
|
||||
export type RequestCause = "foreground" | "background";
|
||||
export type ReconstructedCause = RequestCause | "ambiguous";
|
||||
export type NativeInputCause = "learner" | "automatic" | "neutral" | "unrecognizable";
|
||||
/** Native causes only: flattened user roles, IDs' prefixes and prose confer no permission. */
|
||||
export function nativeInputCause(message: AgentMessage, history: readonly AgentMessage[]): NativeInputCause {
|
||||
if (message.role === "user") return "learner";
|
||||
if (message.role === "system" || message.role === "assistant") return "neutral";
|
||||
if (message.role === "custom") {
|
||||
if (["probe-handoff", "subagent-steer", "subagent-abort"].includes(message.customType)) return "automatic";
|
||||
if (["practice-thinking", "practice-next"].includes(message.customType)) return "learner";
|
||||
return ["graph-context", "plan-block", "problem-dismissed", "compaction"].includes(message.customType) ? "neutral" : "unrecognizable";
|
||||
}
|
||||
if (message.role === "toolResult") {
|
||||
if (!(QUESTION_TOOLS as readonly string[]).includes(message.toolName) || message.isError || message.details == null || (message.details as { dismissed?: boolean }).dismissed) return "neutral";
|
||||
const details = message.details as Partial<QuestionAnswerData>;
|
||||
if (!["quiz", "ask", "explain"].includes(details.kind ?? "") || typeof details.questionText !== "string" || typeof details.dontKnow !== "boolean" || !Array.isArray(details.answers) || typeof details.answeredAt !== "string") return "unrecognizable";
|
||||
const calls = history.flatMap((m) => m.role === "assistant" ? m.content.filter((b) => b.type === "toolCall" && b.id === message.toolCallId) : []);
|
||||
return calls.length === 1 && calls[0]?.type === "toolCall" && calls[0].name === message.toolName ? "learner" : "unrecognizable";
|
||||
}
|
||||
return "unrecognizable";
|
||||
}
|
||||
/** Full persisted native record, including pre-compaction causes. Never guessed foreground. */
|
||||
export function reconstructRequestCause(messages: readonly LhMessage[]): ReconstructedCause {
|
||||
let inherited: ReconstructedCause = "ambiguous";
|
||||
let triggers: NativeInputCause[] = [];
|
||||
const seen = new Map<string, string>();
|
||||
const history: AgentMessage[] = [];
|
||||
const resolve = (): ReconstructedCause => {
|
||||
if (triggers.includes("unrecognizable")) return "ambiguous";
|
||||
const lastLearner = triggers.lastIndexOf("learner"), lastAutomatic = triggers.lastIndexOf("automatic");
|
||||
if (lastLearner < 0 && lastAutomatic < 0) return inherited;
|
||||
if (lastAutomatic < 0) return "foreground";
|
||||
if (lastLearner < lastAutomatic) return "background";
|
||||
return "ambiguous";
|
||||
};
|
||||
for (const message of messages) {
|
||||
const cause = nativeInputCause(message, history);
|
||||
const id = (message as { id?: string }).id;
|
||||
if (id) {
|
||||
const value = JSON.stringify(message);
|
||||
if (seen.has(id)) { if (seen.get(id) !== value) triggers.push("unrecognizable"); continue; }
|
||||
seen.set(id, value);
|
||||
} else if (cause !== "neutral") triggers.push("unrecognizable");
|
||||
if (message.role === "assistant" && !(message.provider === "synthetic" && message.api === "faux" && message.model === "synthetic-conclude" && message.content.some((b) => b.type === "toolCall" && b.name === "conclude" && b.arguments?.outcome === "dismissed"))) {
|
||||
inherited = resolve(); triggers = [];
|
||||
} else if (cause !== "neutral") triggers.push(cause);
|
||||
history.push(message);
|
||||
}
|
||||
return resolve();
|
||||
}
|
||||
|
||||
export function needsContinuation(messages: readonly LhMessage[]): boolean {
|
||||
const last = messages[messages.length - 1];
|
||||
if (!last || (last as { role?: string }).role !== "toolResult") return false;
|
||||
|
||||
+7
-2
@@ -1661,6 +1661,8 @@ export function sumSteps(steps: readonly CallUsage[]): CallUsage {
|
||||
/** One model call's inferred cost in USD — null when the model is unknown
|
||||
* or the usage fields are absent (never guess a price). Cached input bills
|
||||
* at the cache-read rate; cache writes bill separately where priced. */
|
||||
export function isChatGPTPlan(model: string | undefined): boolean { return model?.startsWith("chatgpt:") === true; }
|
||||
|
||||
export function callCost(
|
||||
usage: { inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number },
|
||||
model: string,
|
||||
@@ -1681,6 +1683,7 @@ export function callCost(
|
||||
|
||||
/** One turn's inferred cost — the sum over its steps; null when unpriceable. */
|
||||
export function turnCost(turn: TurnUsage): number | null {
|
||||
if (isChatGPTPlan(turn.model)) return null;
|
||||
let total = 0;
|
||||
for (const step of turn.steps) {
|
||||
const c = callCost(step, turn.model);
|
||||
@@ -1693,15 +1696,17 @@ export function turnCost(turn: TurnUsage): number | null {
|
||||
/** Fold a chat record's turn log: total inferred session cost. Unknown-model
|
||||
* or partial-usage turns don't poison the total — they count as unpriced
|
||||
* and are reported alongside, so the UI can show "n turns unpriced". */
|
||||
export function sessionCost(record: Pick<ChatRecord, "turns">): { cost: number; unpriced: number } {
|
||||
export function sessionCost(record: Pick<ChatRecord, "turns">): { cost: number; unpriced: number; subscription?: number } {
|
||||
let cost = 0;
|
||||
let unpriced = 0;
|
||||
let subscription = 0;
|
||||
for (const turn of record.turns ?? []) {
|
||||
if (isChatGPTPlan(turn.model)) { subscription++; continue; }
|
||||
const c = turnCost(turn);
|
||||
if (c === null) unpriced++;
|
||||
else cost += c;
|
||||
}
|
||||
return { cost, unpriced };
|
||||
return { cost, unpriced, ...(subscription ? { subscription } : {}) };
|
||||
}
|
||||
|
||||
/** The session's context-window footprint: the LAST model call's
|
||||
|
||||
Reference in New Issue
Block a user