Skip to content

Commit 6b68b94

Browse files
Merge pull request #737 from corbitsdev/cl-7095-do-not-show-token-priced-costs-for-codex-chatgpt
Hide dollar cost estimates for Codex ChatGPT subscription sessions
2 parents 475dda2 + f7577b8 commit 6b68b94

12 files changed

Lines changed: 902 additions & 36 deletions

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1313

1414
## [Unreleased]
1515

16+
### Fixed
17+
18+
- Codex ChatGPT subscription sessions no longer show a public-rate dollar
19+
cost estimate. Hide follows the live provider identity after `/model`
20+
switches, not the launch base URL. Coding-plan (Z.AI) hide uses the same
21+
live-identity rule. Context usage and `/cost` still work; `/cost`
22+
reports Codex cost as covered by ChatGPT subscription. Metered OpenAI
23+
API endpoints keep dollar estimates.
24+
1625
## [0.3.11] - 2026-08-31
1726

1827
### Changed

src/agent/renderer.ts

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import type { ReactorEmittedEvent } from "@intx/inference";
2+
import type { LastCycleSource, TokenUsage } from "@intx/types/runtime";
23

3-
import { createFaremeter, formatCost } from "../cost/faremeter.js";
4+
import { formatSessionCostCopy } from "../cost/cost-summary.js";
5+
import { formatCost } from "../cost/faremeter.js";
46
import type { PricingCache } from "../cost/pricing-fetcher.js";
7+
import {
8+
billingIdentityFromSource,
9+
createSessionCostAccumulator,
10+
type TurnBillingIdentity,
11+
} from "../cost/session-cost.js";
512
import { inferenceErrorMessage } from "../inference-error-message.js";
613

714
export interface Renderer {
@@ -58,6 +65,35 @@ function formatOp(name: string): string {
5865
return name;
5966
}
6067

68+
function tokenUsageFromEvent(data: Record<string, unknown> | undefined): TokenUsage | null {
69+
const usage = data?.usage;
70+
if (usage === null || typeof usage !== "object") return null;
71+
const fields = usage as Record<string, unknown>;
72+
if (typeof fields.input !== "number" || typeof fields.output !== "number") return null;
73+
return {
74+
input: fields.input,
75+
output: fields.output,
76+
cacheRead: typeof fields.cacheRead === "number" ? fields.cacheRead : 0,
77+
cacheWrite: typeof fields.cacheWrite === "number" ? fields.cacheWrite : 0,
78+
thinking: typeof fields.thinking === "number" ? fields.thinking : 0,
79+
};
80+
}
81+
82+
function billingIdentityFromEvent(
83+
data: Record<string, unknown> | undefined,
84+
fallbackModelId: string,
85+
): TurnBillingIdentity {
86+
const source = data?.source;
87+
if (source === null || typeof source !== "object") {
88+
return { modelId: fallbackModelId };
89+
}
90+
const fields = source as Record<string, unknown>;
91+
if (typeof fields.sourceId !== "string" || typeof fields.model !== "string") {
92+
return { modelId: fallbackModelId };
93+
}
94+
return billingIdentityFromSource(fields as LastCycleSource);
95+
}
96+
6197
export function createRenderer(
6298
startedAt: number,
6399
modelId?: string,
@@ -69,20 +105,29 @@ export function createRenderer(
69105
const pendingArgs = new Map<string, Record<string, unknown>>();
70106
const pendingNames = new Map<string, string>();
71107
let pendingSubmitSummary: string | undefined;
72-
const faremeter = createFaremeter(
73-
modelId === undefined ? {} : { modelId, pricingCache: pricingCache ?? null },
74-
);
108+
const sessionCost = createSessionCostAccumulator({
109+
pricingCache: () => pricingCache ?? null,
110+
});
75111

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

116+
function costText(): string {
117+
const billed = sessionCost.snapshot();
118+
return formatSessionCostCopy({
119+
mix: billed.mix,
120+
formattedCost: formatCost(billed.meteredCost),
121+
sessionHiddenReason: billed.hiddenReason,
122+
});
123+
}
124+
80125
function writeStatusBar(): void {
81126
const opText =
82127
currentOp.length > 0
83128
? `${AMBER}${currentOp}${currentArg ? " " + currentArg : ""}${RESET}`
84129
: "";
85-
const bar = `${DIM}interchange · turn ${turnCount} · ${formatCost(faremeter.getTotalCost())} · ${RESET}${opText}${DIM} · ${elapsedSecs()}s${RESET}\r`;
130+
const bar = `${DIM}interchange · turn ${turnCount} · ${costText()} · ${RESET}${opText}${DIM} · ${elapsedSecs()}s${RESET}\r`;
86131
process.stderr.write(bar);
87132
}
88133

@@ -156,18 +201,10 @@ export function createRenderer(
156201
turnCount++;
157202
currentOp = "";
158203
currentArg = "";
159-
break;
160-
}
161-
162-
case "inference.usage": {
163-
const usage = (e.data?.usage ?? {}) as {
164-
input: number;
165-
output: number;
166-
cacheRead: number;
167-
cacheWrite: number;
168-
thinking: number;
169-
};
170-
faremeter.addUsage(usage);
204+
const usage = tokenUsageFromEvent(e.data);
205+
if (usage !== null) {
206+
sessionCost.addTurn(usage, billingIdentityFromEvent(e.data, modelId ?? ""));
207+
}
171208
break;
172209
}
173210

src/cost/cost-summary.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,35 @@ describe("buildCostSummary", () => {
5454
expect(summary.costHiddenReason).toBe("coding-plan");
5555
});
5656

57+
it("hides cost for a live coding-plan identity even when baseURL is still metered", () => {
58+
const summary = buildCostSummary({
59+
...baseInput,
60+
providerName: "zai",
61+
baseURL: "https://api.openai.com/v1",
62+
});
63+
expect(summary.costHiddenReason).toBe("coding-plan");
64+
});
65+
66+
it("shows cost for a live metered identity even when baseURL is still a coding-plan endpoint", () => {
67+
const summary = buildCostSummary({
68+
...baseInput,
69+
modelId: "glm-5.1",
70+
providerName: "openai",
71+
baseURL: "https://api.z.ai/api/coding/paas/v4",
72+
});
73+
expect(summary.costHiddenReason).toBeNull();
74+
});
75+
76+
it("hides cost for a Codex ChatGPT subscription identity", () => {
77+
const summary = buildCostSummary({
78+
...baseInput,
79+
modelId: "gpt-5.6-luna",
80+
providerName: "codex/default",
81+
baseURL: "https://api.openai.com/v1",
82+
});
83+
expect(summary.costHiddenReason).toBe("chatgpt-subscription");
84+
});
85+
5786
it("hides cost for a provider marked free", () => {
5887
const summary = buildCostSummary({ ...baseInput, providerFree: true });
5988
expect(summary.costHiddenReason).toBe("provider-free");
@@ -102,6 +131,47 @@ describe("formatStatusBarSegments", () => {
102131
});
103132
});
104133

134+
it("omits dollar cost for a Codex ChatGPT subscription session", () => {
135+
const summary = buildCostSummary({
136+
...baseInput,
137+
modelId: "gpt-5.6-luna",
138+
providerName: "codex/default",
139+
baseURL: "https://api.openai.com/v1",
140+
totalCost: 1.1897,
141+
formattedCost: "$1.1897",
142+
});
143+
expect(formatStatusBarSegments(summary)).toEqual({
144+
contextLabel: "Ctx 16%",
145+
contextPercentUsed: 16,
146+
});
147+
});
148+
149+
it("omits prompt $ on a mixed session while the live identity is Codex", () => {
150+
const summary = buildCostSummary({
151+
...baseInput,
152+
modelId: "gpt-5.6-luna",
153+
providerName: "codex/default",
154+
formattedCost: "$0.0070",
155+
totalCost: 0.007,
156+
sessionBillingMix: "mixed",
157+
sessionHiddenReason: "chatgpt-subscription",
158+
});
159+
expect(formatStatusBarSegments(summary).costLabel).toBeUndefined();
160+
});
161+
162+
it("shows the metered-accumulated $ on a mixed session while the live identity is metered", () => {
163+
const summary = buildCostSummary({
164+
...baseInput,
165+
modelId: "glm-5.1",
166+
providerName: "openai",
167+
formattedCost: "$0.0070",
168+
totalCost: 0.007,
169+
sessionBillingMix: "mixed",
170+
sessionHiddenReason: "chatgpt-subscription",
171+
});
172+
expect(formatStatusBarSegments(summary).costLabel).toBe("$0.0070");
173+
});
174+
105175
it("renders an unknown context window as --% rather than 0%", () => {
106176
setModelContextWindows({ "test-model": 0 });
107177
const summary = buildCostSummary(baseInput);
@@ -142,6 +212,23 @@ describe("formatCostCommandOutput", () => {
142212
expect(formatCostCommandOutput(summary)).toContain("Cost: hidden (coding-plan endpoint)");
143213
});
144214

215+
it("reports ChatGPT subscription coverage instead of a hidden dollar figure", () => {
216+
const summary = buildCostSummary({
217+
...baseInput,
218+
modelId: "gpt-5.6-luna",
219+
providerName: "codex/default",
220+
baseURL: "https://api.openai.com/v1",
221+
});
222+
expect(formatCostCommandOutput(summary)).toBe(
223+
[
224+
"Model: gpt-5.6-luna",
225+
"Cost: covered by ChatGPT subscription (not billed per token)",
226+
"Tokens: 1000 in / 500 out / 200 cache-read",
227+
"Context: 64000/400000 (16%)",
228+
].join("\n"),
229+
);
230+
});
231+
145232
it("reports the reason cost is hidden for a provider marked free", () => {
146233
const summary = buildCostSummary({ ...baseInput, providerFree: true });
147234
expect(formatCostCommandOutput(summary)).toContain("Cost: hidden (provider marked free)");
@@ -157,4 +244,49 @@ describe("formatCostCommandOutput", () => {
157244
const summary = buildCostSummary({ ...baseInput, contextIsEstimate: true });
158245
expect(formatCostCommandOutput(summary)).toContain("(~50%)");
159246
});
247+
248+
it("prints mixed /cost as the metered portion, not a whole-session subscription", () => {
249+
const summary = buildCostSummary({
250+
...baseInput,
251+
modelId: "gpt-5.6-luna",
252+
providerName: "codex/default",
253+
formattedCost: "$0.0070",
254+
totalCost: 0.007,
255+
sessionBillingMix: "mixed",
256+
sessionHiddenReason: "chatgpt-subscription",
257+
});
258+
const output = formatCostCommandOutput(summary);
259+
expect(output).toContain(
260+
"Cost: $0.0070 (metered portion only; session mixed billed and hidden usage)",
261+
);
262+
expect(output).not.toContain("covered by ChatGPT subscription");
263+
});
264+
265+
it("prints mixed /cost as the metered portion after switching onto a public-rate model", () => {
266+
const summary = buildCostSummary({
267+
...baseInput,
268+
modelId: "glm-5.1",
269+
providerName: "openai",
270+
formattedCost: "$0.0070",
271+
totalCost: 0.007,
272+
sessionBillingMix: "mixed",
273+
sessionHiddenReason: "chatgpt-subscription",
274+
});
275+
expect(formatCostCommandOutput(summary)).toContain(
276+
"Cost: $0.0070 (metered portion only; session mixed billed and hidden usage)",
277+
);
278+
});
279+
280+
it("keeps hidden-only Codex /cost on the subscription copy", () => {
281+
const summary = buildCostSummary({
282+
...baseInput,
283+
modelId: "gpt-5.6-luna",
284+
providerName: "codex/default",
285+
sessionBillingMix: "hidden-only",
286+
sessionHiddenReason: "chatgpt-subscription",
287+
});
288+
expect(formatCostCommandOutput(summary)).toContain(
289+
"Cost: covered by ChatGPT subscription (not billed per token)",
290+
);
291+
});
160292
});

src/cost/cost-summary.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44

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

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

2836
export type CostSummary = CostSummaryInput & {
2937
costHiddenReason: CostHiddenReason | null;
38+
sessionBillingMix: SessionBillingMix;
3039
contextWindow: number;
3140
// Null when the model's context window is unknown (non-positive), so the
3241
// display can distinguish "unknown" from a genuine 0% usage.
@@ -45,9 +54,11 @@ export function buildCostSummary(input: CostSummaryInput): CostSummary {
4554
costHiddenReason: costHiddenReason({
4655
modelId: input.modelId,
4756
baseURL: input.baseURL,
57+
providerName: input.providerName,
4858
providerFree: input.providerFree,
4959
pricingCache: input.pricingCache,
5060
}),
61+
sessionBillingMix: input.sessionBillingMix ?? "none",
5162
contextWindow,
5263
contextPercentUsed,
5364
};
@@ -90,20 +101,52 @@ export function formatStatusBarSegments(summary: CostSummary): StatusBarCostSegm
90101
};
91102
}
92103

93-
const HIDDEN_REASON_TEXT: Record<CostHiddenReason, string> = {
104+
const HIDDEN_REASON_TEXT: Record<Exclude<CostHiddenReason, "chatgpt-subscription">, string> = {
94105
"provider-free": "provider marked free",
95106
"coding-plan": "coding-plan endpoint",
96107
"free-model": "free model",
97108
"zero-priced": "zero-priced in the pricing registry",
98109
};
99110

111+
const MIXED_SESSION_COST_SUFFIX = " (metered portion only; session mixed billed and hidden usage)";
112+
113+
export function formatSessionCostCopy(args: {
114+
mix: SessionBillingMix;
115+
formattedCost: string;
116+
sessionHiddenReason?: CostHiddenReason | null | undefined;
117+
liveHiddenReason?: CostHiddenReason | null | undefined;
118+
}): string {
119+
if (args.mix === "mixed") {
120+
return `${args.formattedCost}${MIXED_SESSION_COST_SUFFIX}`;
121+
}
122+
if (args.mix === "metered-only") {
123+
return args.formattedCost;
124+
}
125+
const hide =
126+
args.mix === "hidden-only"
127+
? (args.sessionHiddenReason ?? args.liveHiddenReason ?? null)
128+
: (args.liveHiddenReason ?? null);
129+
if (hide === null) return args.formattedCost;
130+
if (hide === "chatgpt-subscription") {
131+
return "covered by ChatGPT subscription (not billed per token)";
132+
}
133+
return `hidden (${HIDDEN_REASON_TEXT[hide]})`;
134+
}
135+
136+
function formatCostLine(summary: CostSummary): string {
137+
return `Cost: ${formatSessionCostCopy({
138+
mix: summary.sessionBillingMix,
139+
formattedCost: summary.formattedCost,
140+
sessionHiddenReason: summary.sessionHiddenReason,
141+
liveHiddenReason: summary.costHiddenReason,
142+
})}`;
143+
}
144+
100145
export function formatCostCommandOutput(summary: CostSummary): string {
101146
const window = summary.contextWindow > 0 ? String(summary.contextWindow) : "unknown";
102147
const lines = [
103148
`Model: ${summary.modelId}`,
104-
summary.costHiddenReason === null
105-
? `Cost: ${summary.formattedCost}`
106-
: `Cost: hidden (${HIDDEN_REASON_TEXT[summary.costHiddenReason]})`,
149+
formatCostLine(summary),
107150
`Tokens: ${String(summary.inputTokens)} in / ${String(summary.outputTokens)} out / ${String(summary.cacheReadTokens)} cache-read`,
108151
`Context: ${String(summary.contextTokens)}/${window} (${formatContextPercentLabel(summary.contextPercentUsed, summary.contextIsEstimate)})`,
109152
];

0 commit comments

Comments
 (0)