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