feat: add app-owned Copilot and Grok subscription connections

This commit is contained in:
amosblomqvist
2026-10-25 00:27:52 +03:00
parent e8c81faad9
commit d6e317c6fe
49 changed files with 1709 additions and 248 deletions
+68
View File
@@ -0,0 +1,68 @@
import type { Api, Model } from "@earendil-works/pi-ai";
/** Native SDK IDs stay distinct from the app's persisted connection routes. */
export type NativeSubscriptionProviderId = "github-copilot" | "xai";
export type NativeSubscriptionLogicalId = "github-copilot-subscription" | "xai-subscription";
export const NATIVE_SUBSCRIPTIONS = [
{ providerId: "github-copilot", logicalId: "github-copilot-subscription", label: "GitHub Copilot" },
{ providerId: "xai", logicalId: "xai-subscription", label: "Grok" },
] as const;
export interface SubscriptionPrompt {
id: string;
type: "text" | "secret" | "select" | "manual_code";
message: string;
placeholder?: string;
options?: readonly { id: string; label: string; description?: string }[];
}
/** Only public device user codes, never the private device_code or tokens. */
export interface SubscriptionInteraction {
attemptId: string;
prompt?: SubscriptionPrompt;
browser?: { id: string; url: string };
userCode?: string;
progress?: string;
}
export interface NativeSubscriptionStatus {
providerId: NativeSubscriptionProviderId;
available: boolean;
connected: boolean;
pending: boolean;
committing?: boolean;
allowBackgroundUse: boolean;
connectionId: string | null;
epoch: number;
authorityRevision: number;
revision: number;
error?: string;
interaction?: SubscriptionInteraction;
}
/** Private inherited IPC also carries safe, SDK-supported catalog metadata. */
export interface NativeSubscriptionSnapshot {
statuses: NativeSubscriptionStatus[];
models: Partial<Record<NativeSubscriptionProviderId, Model<Api>[]>>;
}
export interface NativeSubscriptionScope {
providerId: NativeSubscriptionProviderId;
connectionId: string;
epoch: number;
authorityRevision: number;
selectedModelId: string;
}
export interface NativeSubscriptionControlBridge {
status(): Promise<NativeSubscriptionStatus[]>;
login(providerId: NativeSubscriptionProviderId, enabled?: boolean): Promise<void>;
cancel(providerId: NativeSubscriptionProviderId): Promise<void>;
respond(providerId: NativeSubscriptionProviderId, attemptId: string, promptId: string, value: string): Promise<void>;
open(providerId: NativeSubscriptionProviderId, attemptId: string, actionId: string): Promise<void>;
retry(providerId: NativeSubscriptionProviderId): Promise<void>;
disconnect(providerId: NativeSubscriptionProviderId): Promise<void>;
consent(providerId: NativeSubscriptionProviderId, enabled: boolean): Promise<void>;
onChange(listener: () => void): () => void;
}
+17 -8
View File
@@ -18,12 +18,13 @@
*/
import type { CallUsage, EmissionStats, ExtractionRunUsage } from "./contracts.ts";
import { sumSteps, isChatGPTPlan, type CallPricer } from "./prices.ts";
import { sumSteps, isSubscriptionModel, subscriptionLabels, type CallPricer } from "./prices.ts";
/** One pipeline stage's cumulative cost across every run. */
export interface StageCost {
/** ChatGPT plan stage-runs, excluded from API dollars and unpriced counts. */
/** Subscription stage-runs, excluded from API dollars and unpriced counts. */
subscriptionRuns?: number;
subscriptionProviders?: string[];
/** Inferred USD summed over PRICED runs (partial when unpricedRuns > 0). */
dollars: number;
/** Stage-runs whose model is missing from Pi's catalog (render "—", never zero). */
@@ -37,6 +38,7 @@ export interface StageCost {
/** One run in the recent-runs list (extraction or naming). */
export interface RecentRun {
subscriptionRuns?: number;
subscriptionProviders?: string[];
startedAt: number;
/** ISO wall-clock. */
at: string;
@@ -55,6 +57,7 @@ export interface RecentRun {
/** The cost pill's whole payload: stage totals + recent runs. */
export interface GraphCostSummary {
subscriptionRuns?: number;
subscriptionProviders?: string[];
/** Inferred USD across all priced stages of all runs. */
totalDollars: number;
/** Runs with no priced API stage and at least one unknown stage. */
@@ -93,7 +96,7 @@ const emptyStage = (): StageCost => ({ dollars: 0, unpricedRuns: 0, runs: 0, tok
/** One stage's dollars over its steps; null when the model is unpriced
* (all-or-nothing: a stage runs one model). */
function stageDollars(model: string, steps: readonly CallUsage[], price: CallPricer): number | null {
if (isChatGPTPlan(model) || steps.length === 0) return null;
if (isSubscriptionModel(model) || steps.length === 0) return null;
let total = 0;
for (const step of steps) {
const c = price(step, model);
@@ -129,14 +132,20 @@ export function graphCostSummary(input: CostSummaryInput, price: CallPricer): Gr
extraction.runs++;
mergeTokens(extraction.tokens, stages.extractor.steps);
const extDollars = stageDollars(stages.extractor.model, stages.extractor.steps, price);
if (isChatGPTPlan(stages.extractor.model)) extraction.subscriptionRuns = (extraction.subscriptionRuns ?? 0) + 1;
if (isSubscriptionModel(stages.extractor.model)) {
extraction.subscriptionRuns = (extraction.subscriptionRuns ?? 0) + 1;
extraction.subscriptionProviders = [...new Set([...(extraction.subscriptionProviders ?? []), ...subscriptionLabels([stages.extractor.model])])];
}
else if (extDollars === null) extraction.unpricedRuns++;
else extraction.dollars += extDollars;
audit.runs++;
mergeTokens(audit.tokens, stages.auditor.steps);
const audDollars = stageDollars(stages.auditor.model, stages.auditor.steps, price);
if (isChatGPTPlan(stages.auditor.model)) audit.subscriptionRuns = (audit.subscriptionRuns ?? 0) + 1;
if (isSubscriptionModel(stages.auditor.model)) {
audit.subscriptionRuns = (audit.subscriptionRuns ?? 0) + 1;
audit.subscriptionProviders = [...new Set([...(audit.subscriptionProviders ?? []), ...subscriptionLabels([stages.auditor.model])])];
}
else if (audDollars === null) audit.unpricedRuns++;
else audit.dollars += audDollars;
@@ -145,10 +154,10 @@ export function graphCostSummary(input: CostSummaryInput, price: CallPricer): Gr
const runDollars = allStages.every((d) => d === null)
? null
: allStages.reduce<number>((a, d) => a + (d ?? 0), 0);
const subscriptionRuns = [stages.extractor, stages.auditor].filter((stage) => isChatGPTPlan(stage.model)).length;
const subscriptionRuns = [stages.extractor, stages.auditor].filter((stage) => isSubscriptionModel(stage.model)).length;
const allSteps = [...stages.extractor.steps, ...stages.auditor.steps];
runs.push({
...(subscriptionRuns ? { subscriptionRuns } : {}),
...(subscriptionRuns ? { subscriptionRuns, subscriptionProviders: subscriptionLabels([stages.extractor.model, stages.auditor.model]) } : {}),
startedAt: run.startedAt,
at: run.at,
durationMs: run.durationMs,
@@ -174,7 +183,7 @@ export function graphCostSummary(input: CostSummaryInput, price: CallPricer): Gr
const unpricedRuns = runs.filter((r) => r.dollars === null && (r.subscriptionRuns ?? 0) < 2).length;
const subscriptionRuns = (extraction.subscriptionRuns ?? 0) + (audit.subscriptionRuns ?? 0);
return {
...(subscriptionRuns ? { subscriptionRuns } : {}),
...(subscriptionRuns ? { subscriptionRuns, subscriptionProviders: [...new Set([...(extraction.subscriptionProviders ?? []), ...(audit.subscriptionProviders ?? [])])] } : {}),
totalDollars: extraction.dollars + audit.dollars,
unpricedRuns,
stages: { extraction, audit },
+2
View File
@@ -50,6 +50,8 @@ export * from "./tool-parts.ts";
export * from "./prices.ts";
export * from "./context-usage.ts";
export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.ts";
export { NATIVE_SUBSCRIPTIONS } from "./contracts/subscriptions.ts";
export type { NativeSubscriptionProviderId, NativeSubscriptionLogicalId, NativeSubscriptionStatus, NativeSubscriptionSnapshot, NativeSubscriptionScope, NativeSubscriptionControlBridge, SubscriptionInteraction, SubscriptionPrompt } from "./contracts/subscriptions.ts";
export * from "./cost-summary.ts";
export * from "./fuzzy.ts";
// settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19):
+14 -4
View File
@@ -1,12 +1,14 @@
/** Client-safe accounting folds. Rates and calculation live on the server,
* using Pi's catalog and calculateCost; no copied pricing table lives here. */
import type { CallUsage, ChatRecord, JobRecord, TurnUsage } from "./contracts.ts";
import { NATIVE_SUBSCRIPTIONS } from "./contracts/subscriptions.ts";
export type CallPricer = (usage: Partial<CallUsage>, model: string) => number | null;
export interface SessionPricing {
cost: number;
unpriced: number;
subscription?: number;
subscriptionProviders?: string[];
/** Derived at read time, keyed by the persisted turn's id. Never stored. */
turnCosts: Record<string, number | null>;
}
@@ -14,6 +16,14 @@ export interface SessionPricing {
export function isChatGPTPlan(model: string | undefined): boolean {
return model?.startsWith("chatgpt:") === true;
}
export function subscriptionLabel(model: string | undefined): string | undefined {
if (isChatGPTPlan(model)) return "ChatGPT";
return NATIVE_SUBSCRIPTIONS.find((p) => model?.startsWith(`${p.logicalId}:`))?.label;
}
export function isSubscriptionModel(model: string | undefined): boolean { return subscriptionLabel(model) !== undefined; }
export function subscriptionLabels(models: readonly (string | undefined)[]): string[] {
return [...new Set(models.flatMap((model) => { const label = subscriptionLabel(model); return label ? [label] : []; }))];
}
/** Absent stays absent. Long-cache precision is unknown if any write-bearing
* step lacks the split; never silently turn that missing detail into zero. */
@@ -32,7 +42,7 @@ export function sumSteps(steps: readonly CallUsage[]): CallUsage {
}
export function turnCost(turn: Pick<TurnUsage, "model" | "steps">, price: CallPricer): number | null {
if (isChatGPTPlan(turn.model) || turn.steps.length === 0) return null;
if (isSubscriptionModel(turn.model) || turn.steps.length === 0) return null;
let total = 0;
for (const step of turn.steps) {
const cost = price(step, turn.model);
@@ -50,16 +60,16 @@ export function sessionCost(record: Pick<ChatRecord, "turns">, price: CallPricer
for (const turn of record.turns ?? []) {
const dollars = turnCost(turn, price);
turnCosts[turn.id] = dollars;
if (isChatGPTPlan(turn.model)) subscription++;
if (isSubscriptionModel(turn.model)) subscription++;
else if (dollars === null) unpriced++;
else cost += dollars;
}
return { cost, unpriced, ...(subscription ? { subscription } : {}), turnCosts };
return { cost, unpriced, ...(subscription ? { subscription, subscriptionProviders: subscriptionLabels((record.turns ?? []).map((turn) => turn.model)) } : {}), turnCosts };
}
export function jobCost(job: Pick<JobRecord, "usage">, price: CallPricer): number | null {
const usage = job.usage;
if (!usage || isChatGPTPlan(usage.model)) return null;
if (!usage || isSubscriptionModel(usage.model)) return null;
if (usage.calls && usage.calls.length === usage.steps) {
return turnCost({ model: usage.model, steps: usage.calls }, price);
}