Skip to content
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename

## [Unreleased]

### Fixed

- Codex ChatGPT subscription sessions no longer show a public-rate dollar
cost estimate. Hide follows the live provider identity after `/model`
switches, not the launch base URL. Coding-plan (Z.AI) hide uses the same
live-identity rule. Context usage and `/cost` still work; `/cost`
reports Codex cost as covered by ChatGPT subscription. Metered OpenAI
API endpoints keep dollar estimates.

## [0.3.11] - 2026-08-31

### Changed
Expand Down
71 changes: 54 additions & 17 deletions src/agent/renderer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import type { ReactorEmittedEvent } from "@intx/inference";
import type { LastCycleSource, TokenUsage } from "@intx/types/runtime";

import { createFaremeter, formatCost } from "../cost/faremeter.js";
import { formatSessionCostCopy } from "../cost/cost-summary.js";
import { formatCost } from "../cost/faremeter.js";
import type { PricingCache } from "../cost/pricing-fetcher.js";
import {
billingIdentityFromSource,
createSessionCostAccumulator,
type TurnBillingIdentity,
} from "../cost/session-cost.js";
import { inferenceErrorMessage } from "../inference-error-message.js";

export interface Renderer {
Expand Down Expand Up @@ -58,6 +65,35 @@ function formatOp(name: string): string {
return name;
}

function tokenUsageFromEvent(data: Record<string, unknown> | undefined): TokenUsage | null {
const usage = data?.usage;
if (usage === null || typeof usage !== "object") return null;
const fields = usage as Record<string, unknown>;
if (typeof fields.input !== "number" || typeof fields.output !== "number") return null;
return {
input: fields.input,
output: fields.output,
cacheRead: typeof fields.cacheRead === "number" ? fields.cacheRead : 0,
cacheWrite: typeof fields.cacheWrite === "number" ? fields.cacheWrite : 0,
thinking: typeof fields.thinking === "number" ? fields.thinking : 0,
};
}

function billingIdentityFromEvent(
data: Record<string, unknown> | undefined,
fallbackModelId: string,
): TurnBillingIdentity {
const source = data?.source;
if (source === null || typeof source !== "object") {
return { modelId: fallbackModelId };
}
const fields = source as Record<string, unknown>;
if (typeof fields.sourceId !== "string" || typeof fields.model !== "string") {
return { modelId: fallbackModelId };
}
return billingIdentityFromSource(fields as LastCycleSource);
}

export function createRenderer(
startedAt: number,
modelId?: string,
Expand All @@ -69,20 +105,29 @@ export function createRenderer(
const pendingArgs = new Map<string, Record<string, unknown>>();
const pendingNames = new Map<string, string>();
let pendingSubmitSummary: string | undefined;
const faremeter = createFaremeter(
modelId === undefined ? {} : { modelId, pricingCache: pricingCache ?? null },
);
const sessionCost = createSessionCostAccumulator({
pricingCache: () => pricingCache ?? null,
});

function elapsedSecs(): number {
return Math.floor((Date.now() - startedAt) / 1000);
}

function costText(): string {
const billed = sessionCost.snapshot();
return formatSessionCostCopy({
mix: billed.mix,
formattedCost: formatCost(billed.meteredCost),
sessionHiddenReason: billed.hiddenReason,
});
}

function writeStatusBar(): void {
const opText =
currentOp.length > 0
? `${AMBER}${currentOp}${currentArg ? " " + currentArg : ""}${RESET}`
: "";
const bar = `${DIM}interchange · turn ${turnCount} · ${formatCost(faremeter.getTotalCost())} · ${RESET}${opText}${DIM} · ${elapsedSecs()}s${RESET}\r`;
const bar = `${DIM}interchange · turn ${turnCount} · ${costText()} · ${RESET}${opText}${DIM} · ${elapsedSecs()}s${RESET}\r`;
process.stderr.write(bar);
}

Expand Down Expand Up @@ -156,18 +201,10 @@ export function createRenderer(
turnCount++;
currentOp = "";
currentArg = "";
break;
}

case "inference.usage": {
const usage = (e.data?.usage ?? {}) as {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
thinking: number;
};
faremeter.addUsage(usage);
const usage = tokenUsageFromEvent(e.data);
if (usage !== null) {
sessionCost.addTurn(usage, billingIdentityFromEvent(e.data, modelId ?? ""));
}
break;
}

Expand Down
132 changes: 132 additions & 0 deletions src/cost/cost-summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,35 @@ describe("buildCostSummary", () => {
expect(summary.costHiddenReason).toBe("coding-plan");
});

it("hides cost for a live coding-plan identity even when baseURL is still metered", () => {
const summary = buildCostSummary({
...baseInput,
providerName: "zai",
baseURL: "https://api.openai.com/v1",
});
expect(summary.costHiddenReason).toBe("coding-plan");
});

it("shows cost for a live metered identity even when baseURL is still a coding-plan endpoint", () => {
const summary = buildCostSummary({
...baseInput,
modelId: "glm-5.1",
providerName: "openai",
baseURL: "https://api.z.ai/api/coding/paas/v4",
});
expect(summary.costHiddenReason).toBeNull();
});

it("hides cost for a Codex ChatGPT subscription identity", () => {
const summary = buildCostSummary({
...baseInput,
modelId: "gpt-5.6-luna",
providerName: "codex/default",
baseURL: "https://api.openai.com/v1",
});
expect(summary.costHiddenReason).toBe("chatgpt-subscription");
});

it("hides cost for a provider marked free", () => {
const summary = buildCostSummary({ ...baseInput, providerFree: true });
expect(summary.costHiddenReason).toBe("provider-free");
Expand Down Expand Up @@ -102,6 +131,47 @@ describe("formatStatusBarSegments", () => {
});
});

it("omits dollar cost for a Codex ChatGPT subscription session", () => {
const summary = buildCostSummary({
...baseInput,
modelId: "gpt-5.6-luna",
providerName: "codex/default",
baseURL: "https://api.openai.com/v1",
totalCost: 1.1897,
formattedCost: "$1.1897",
});
expect(formatStatusBarSegments(summary)).toEqual({
contextLabel: "Ctx 16%",
contextPercentUsed: 16,
});
});

it("omits prompt $ on a mixed session while the live identity is Codex", () => {
const summary = buildCostSummary({
...baseInput,
modelId: "gpt-5.6-luna",
providerName: "codex/default",
formattedCost: "$0.0070",
totalCost: 0.007,
sessionBillingMix: "mixed",
sessionHiddenReason: "chatgpt-subscription",
});
expect(formatStatusBarSegments(summary).costLabel).toBeUndefined();
});

it("shows the metered-accumulated $ on a mixed session while the live identity is metered", () => {
const summary = buildCostSummary({
...baseInput,
modelId: "glm-5.1",
providerName: "openai",
formattedCost: "$0.0070",
totalCost: 0.007,
sessionBillingMix: "mixed",
sessionHiddenReason: "chatgpt-subscription",
});
expect(formatStatusBarSegments(summary).costLabel).toBe("$0.0070");
});

it("renders an unknown context window as --% rather than 0%", () => {
setModelContextWindows({ "test-model": 0 });
const summary = buildCostSummary(baseInput);
Expand Down Expand Up @@ -142,6 +212,23 @@ describe("formatCostCommandOutput", () => {
expect(formatCostCommandOutput(summary)).toContain("Cost: hidden (coding-plan endpoint)");
});

it("reports ChatGPT subscription coverage instead of a hidden dollar figure", () => {
const summary = buildCostSummary({
...baseInput,
modelId: "gpt-5.6-luna",
providerName: "codex/default",
baseURL: "https://api.openai.com/v1",
});
expect(formatCostCommandOutput(summary)).toBe(
[
"Model: gpt-5.6-luna",
"Cost: covered by ChatGPT subscription (not billed per token)",
"Tokens: 1000 in / 500 out / 200 cache-read",
"Context: 64000/400000 (16%)",
].join("\n"),
);
});

it("reports the reason cost is hidden for a provider marked free", () => {
const summary = buildCostSummary({ ...baseInput, providerFree: true });
expect(formatCostCommandOutput(summary)).toContain("Cost: hidden (provider marked free)");
Expand All @@ -157,4 +244,49 @@ describe("formatCostCommandOutput", () => {
const summary = buildCostSummary({ ...baseInput, contextIsEstimate: true });
expect(formatCostCommandOutput(summary)).toContain("(~50%)");
});

it("prints mixed /cost as the metered portion, not a whole-session subscription", () => {
const summary = buildCostSummary({
...baseInput,
modelId: "gpt-5.6-luna",
providerName: "codex/default",
formattedCost: "$0.0070",
totalCost: 0.007,
sessionBillingMix: "mixed",
sessionHiddenReason: "chatgpt-subscription",
});
const output = formatCostCommandOutput(summary);
expect(output).toContain(
"Cost: $0.0070 (metered portion only; session mixed billed and hidden usage)",
);
expect(output).not.toContain("covered by ChatGPT subscription");
});

it("prints mixed /cost as the metered portion after switching onto a public-rate model", () => {
const summary = buildCostSummary({
...baseInput,
modelId: "glm-5.1",
providerName: "openai",
formattedCost: "$0.0070",
totalCost: 0.007,
sessionBillingMix: "mixed",
sessionHiddenReason: "chatgpt-subscription",
});
expect(formatCostCommandOutput(summary)).toContain(
"Cost: $0.0070 (metered portion only; session mixed billed and hidden usage)",
);
});

it("keeps hidden-only Codex /cost on the subscription copy", () => {
const summary = buildCostSummary({
...baseInput,
modelId: "gpt-5.6-luna",
providerName: "codex/default",
sessionBillingMix: "hidden-only",
sessionHiddenReason: "chatgpt-subscription",
});
expect(formatCostCommandOutput(summary)).toContain(
"Cost: covered by ChatGPT subscription (not billed per token)",
);
});
});
51 changes: 47 additions & 4 deletions src/cost/cost-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@

import { contextWindowFor } from "../provider/context-window.js";
import { costHiddenReason, type CostHiddenReason } from "./cost-visibility.js";
import type { SessionBillingMix } from "./session-cost.js";
import type { PricingCache } from "./pricing-fetcher.js";

export interface CostSummaryInput {
modelId: string;
baseURL?: string | undefined;
providerName?: string | undefined;
providerFree?: boolean | undefined;
pricingCache: PricingCache | null;
totalCost: number;
Expand All @@ -23,10 +25,17 @@ export interface CostSummaryInput {
// approximate instead of implying provider-grade precision. The caller
// building this input owns the decision; nothing downstream re-derives it.
contextIsEstimate: boolean;
// Session mix from the per-turn accumulator. Absent or "none" falls back to
// the live identity for /cost hide copy (fresh launch, post-/clear).
sessionBillingMix?: SessionBillingMix | undefined;
// Hidden reason of the last hidden-identity turn. Used for hidden-only /cost
// copy so a later live metered identity does not rewrite history.
sessionHiddenReason?: CostHiddenReason | null | undefined;
}

export type CostSummary = CostSummaryInput & {
costHiddenReason: CostHiddenReason | null;
sessionBillingMix: SessionBillingMix;
contextWindow: number;
// Null when the model's context window is unknown (non-positive), so the
// display can distinguish "unknown" from a genuine 0% usage.
Expand All @@ -45,9 +54,11 @@ export function buildCostSummary(input: CostSummaryInput): CostSummary {
costHiddenReason: costHiddenReason({
modelId: input.modelId,
baseURL: input.baseURL,
providerName: input.providerName,
providerFree: input.providerFree,
pricingCache: input.pricingCache,
}),
sessionBillingMix: input.sessionBillingMix ?? "none",
contextWindow,
contextPercentUsed,
};
Expand Down Expand Up @@ -90,20 +101,52 @@ export function formatStatusBarSegments(summary: CostSummary): StatusBarCostSegm
};
}

const HIDDEN_REASON_TEXT: Record<CostHiddenReason, string> = {
const HIDDEN_REASON_TEXT: Record<Exclude<CostHiddenReason, "chatgpt-subscription">, string> = {
"provider-free": "provider marked free",
"coding-plan": "coding-plan endpoint",
"free-model": "free model",
"zero-priced": "zero-priced in the pricing registry",
};

const MIXED_SESSION_COST_SUFFIX = " (metered portion only; session mixed billed and hidden usage)";

export function formatSessionCostCopy(args: {
mix: SessionBillingMix;
formattedCost: string;
sessionHiddenReason?: CostHiddenReason | null | undefined;
liveHiddenReason?: CostHiddenReason | null | undefined;
}): string {
if (args.mix === "mixed") {
return `${args.formattedCost}${MIXED_SESSION_COST_SUFFIX}`;
}
if (args.mix === "metered-only") {
return args.formattedCost;
}
const hide =
args.mix === "hidden-only"
? (args.sessionHiddenReason ?? args.liveHiddenReason ?? null)
: (args.liveHiddenReason ?? null);
if (hide === null) return args.formattedCost;
if (hide === "chatgpt-subscription") {
return "covered by ChatGPT subscription (not billed per token)";
}
return `hidden (${HIDDEN_REASON_TEXT[hide]})`;
}

function formatCostLine(summary: CostSummary): string {
return `Cost: ${formatSessionCostCopy({
mix: summary.sessionBillingMix,
formattedCost: summary.formattedCost,
sessionHiddenReason: summary.sessionHiddenReason,
liveHiddenReason: summary.costHiddenReason,
})}`;
}

export function formatCostCommandOutput(summary: CostSummary): string {
const window = summary.contextWindow > 0 ? String(summary.contextWindow) : "unknown";
const lines = [
`Model: ${summary.modelId}`,
summary.costHiddenReason === null
? `Cost: ${summary.formattedCost}`
: `Cost: hidden (${HIDDEN_REASON_TEXT[summary.costHiddenReason]})`,
formatCostLine(summary),
`Tokens: ${String(summary.inputTokens)} in / ${String(summary.outputTokens)} out / ${String(summary.cacheReadTokens)} cache-read`,
`Context: ${String(summary.contextTokens)}/${window} (${formatContextPercentLabel(summary.contextPercentUsed, summary.contextIsEstimate)})`,
];
Expand Down
Loading
Loading