Skip to content

Commit 4b343cb

Browse files
committed
Hide Codex costs from live provider identity not launch URL
/model updates providerName without rewriting baseURL, so a URL-only hide was stale after API to Codex switches. Live Codex identity hides; a present non-Codex identity shows; URL match remains the no-name fallback.
1 parent 561839c commit 4b343cb

6 files changed

Lines changed: 110 additions & 33 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ 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. Context usage and `/cost` still work;
21+
`/cost` reports the cost as covered by ChatGPT subscription. Metered
22+
OpenAI API endpoints keep dollar estimates.
23+
1624
## [0.3.11] - 2026-08-31
1725

1826
### Changed
@@ -37,10 +45,6 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
3745

3846
### Fixed
3947

40-
- Codex ChatGPT subscription sessions no longer show a public-rate dollar
41-
cost estimate. Context usage and `/cost` still work; `/cost` reports the
42-
hide reason as ChatGPT subscription. Metered OpenAI API endpoints keep
43-
dollar estimates.
4448
- Failed sessions with an `error` string in `run.json` are valid resume
4549
candidates, not corrupt files. A truly unreadable session id prints one
4650
recovery line; parse diagnostics go to the structured log, not the

src/cost/cost-summary.test.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { afterEach, describe, expect, it } from "bun:test";
22

3-
import { CODEX_BASE_URL } from "../auth/codex/constants.js";
43
import { setModelContextWindows } from "../provider/context-window.js";
54
import {
65
buildCostSummary,
@@ -55,11 +54,12 @@ describe("buildCostSummary", () => {
5554
expect(summary.costHiddenReason).toBe("coding-plan");
5655
});
5756

58-
it("hides cost for a Codex ChatGPT subscription base URL", () => {
57+
it("hides cost for a Codex ChatGPT subscription identity", () => {
5958
const summary = buildCostSummary({
6059
...baseInput,
6160
modelId: "gpt-5.6-luna",
62-
baseURL: CODEX_BASE_URL,
61+
providerName: "codex/default",
62+
baseURL: "https://api.openai.com/v1",
6363
});
6464
expect(summary.costHiddenReason).toBe("chatgpt-subscription");
6565
});
@@ -116,13 +116,15 @@ describe("formatStatusBarSegments", () => {
116116
const summary = buildCostSummary({
117117
...baseInput,
118118
modelId: "gpt-5.6-luna",
119-
baseURL: CODEX_BASE_URL,
119+
providerName: "codex/default",
120+
baseURL: "https://api.openai.com/v1",
120121
totalCost: 1.1897,
121122
formattedCost: "$1.1897",
122123
});
123-
const segments = formatStatusBarSegments(summary);
124-
expect(segments.costLabel).toBeUndefined();
125-
expect(segments.contextLabel).toMatch(/^Ctx /);
124+
expect(formatStatusBarSegments(summary)).toEqual({
125+
contextLabel: "Ctx 16%",
126+
contextPercentUsed: 16,
127+
});
126128
});
127129

128130
it("renders an unknown context window as --% rather than 0%", () => {
@@ -165,13 +167,21 @@ describe("formatCostCommandOutput", () => {
165167
expect(formatCostCommandOutput(summary)).toContain("Cost: hidden (coding-plan endpoint)");
166168
});
167169

168-
it("reports the reason cost is hidden for a ChatGPT subscription endpoint", () => {
170+
it("reports ChatGPT subscription coverage instead of a hidden dollar figure", () => {
169171
const summary = buildCostSummary({
170172
...baseInput,
171173
modelId: "gpt-5.6-luna",
172-
baseURL: CODEX_BASE_URL,
174+
providerName: "codex/default",
175+
baseURL: "https://api.openai.com/v1",
173176
});
174-
expect(formatCostCommandOutput(summary)).toContain("Cost: hidden (ChatGPT subscription)");
177+
expect(formatCostCommandOutput(summary)).toBe(
178+
[
179+
"Model: gpt-5.6-luna",
180+
"Cost: covered by ChatGPT subscription (not billed per token)",
181+
"Tokens: 1000 in / 500 out / 200 cache-read",
182+
"Context: 64000/400000 (16%)",
183+
].join("\n"),
184+
);
175185
});
176186

177187
it("reports the reason cost is hidden for a provider marked free", () => {

src/cost/cost-summary.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { PricingCache } from "./pricing-fetcher.js";
99
export interface CostSummaryInput {
1010
modelId: string;
1111
baseURL?: string | undefined;
12+
providerName?: string | undefined;
1213
providerFree?: boolean | undefined;
1314
pricingCache: PricingCache | null;
1415
totalCost: number;
@@ -45,6 +46,7 @@ export function buildCostSummary(input: CostSummaryInput): CostSummary {
4546
costHiddenReason: costHiddenReason({
4647
modelId: input.modelId,
4748
baseURL: input.baseURL,
49+
providerName: input.providerName,
4850
providerFree: input.providerFree,
4951
pricingCache: input.pricingCache,
5052
}),
@@ -90,21 +92,24 @@ export function formatStatusBarSegments(summary: CostSummary): StatusBarCostSegm
9092
};
9193
}
9294

93-
const HIDDEN_REASON_TEXT: Record<CostHiddenReason, string> = {
95+
const HIDDEN_REASON_TEXT: Record<Exclude<CostHiddenReason, "chatgpt-subscription">, string> = {
9496
"provider-free": "provider marked free",
9597
"coding-plan": "coding-plan endpoint",
96-
"chatgpt-subscription": "ChatGPT subscription",
9798
"free-model": "free model",
9899
"zero-priced": "zero-priced in the pricing registry",
99100
};
100101

101102
export function formatCostCommandOutput(summary: CostSummary): string {
102103
const window = summary.contextWindow > 0 ? String(summary.contextWindow) : "unknown";
103-
const lines = [
104-
`Model: ${summary.modelId}`,
104+
const costLine =
105105
summary.costHiddenReason === null
106106
? `Cost: ${summary.formattedCost}`
107-
: `Cost: hidden (${HIDDEN_REASON_TEXT[summary.costHiddenReason]})`,
107+
: summary.costHiddenReason === "chatgpt-subscription"
108+
? "Cost: covered by ChatGPT subscription (not billed per token)"
109+
: `Cost: hidden (${HIDDEN_REASON_TEXT[summary.costHiddenReason]})`;
110+
const lines = [
111+
`Model: ${summary.modelId}`,
112+
costLine,
108113
`Tokens: ${String(summary.inputTokens)} in / ${String(summary.outputTokens)} out / ${String(summary.cacheReadTokens)} cache-read`,
109114
`Context: ${String(summary.contextTokens)}/${window} (${formatContextPercentLabel(summary.contextPercentUsed, summary.contextIsEstimate)})`,
110115
];

src/cost/cost-visibility.test.ts

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,30 @@ describe("isChatGPTSubscriptionBaseURL", () => {
8080
it("does not match chatgpt.com outside the backend-api path", () => {
8181
expect(isChatGPTSubscriptionBaseURL("https://chatgpt.com/")).toBe(false);
8282
expect(isChatGPTSubscriptionBaseURL("https://chatgpt.com/backend")).toBe(false);
83+
expect(isChatGPTSubscriptionBaseURL("https://chatgpt.com/backend-api-v2")).toBe(false);
8384
});
8485

85-
it("handles undefined and malformed URLs without over-matching", () => {
86+
it("matches the backend-api path case-insensitively", () => {
87+
expect(isChatGPTSubscriptionBaseURL("https://chatgpt.com/BACKEND-API")).toBe(true);
88+
expect(isChatGPTSubscriptionBaseURL("https://chatgpt.com/Backend-Api/codex/responses")).toBe(
89+
true,
90+
);
91+
});
92+
93+
it("matches query and hash via pathname, not as part of the path prefix", () => {
94+
expect(isChatGPTSubscriptionBaseURL("https://chatgpt.com/backend-api?foo=1")).toBe(true);
95+
expect(isChatGPTSubscriptionBaseURL("https://chatgpt.com/backend-api#section")).toBe(true);
96+
});
97+
98+
it("does not match http against the https Codex origin", () => {
99+
expect(isChatGPTSubscriptionBaseURL("http://chatgpt.com/backend-api")).toBe(false);
100+
});
101+
102+
it("rejects undefined, unanchored substrings, and lookalike hosts", () => {
86103
expect(isChatGPTSubscriptionBaseURL(undefined)).toBe(false);
87-
expect(isChatGPTSubscriptionBaseURL("not a url chatgpt.com/backend-api")).toBe(true);
88-
expect(isChatGPTSubscriptionBaseURL("not a url chatgpt.com/")).toBe(false);
104+
expect(isChatGPTSubscriptionBaseURL("not a url chatgpt.com/backend-api")).toBe(false);
105+
expect(isChatGPTSubscriptionBaseURL("notchatgpt.com/backend-api")).toBe(false);
106+
expect(isChatGPTSubscriptionBaseURL("chatgpt.com/backend-api")).toBe(true);
89107
});
90108
});
91109

@@ -116,6 +134,28 @@ describe("costHiddenReason", () => {
116134
).toBe("chatgpt-subscription");
117135
});
118136

137+
it("hides on live Codex provider identity even when baseURL is still the metered API", () => {
138+
expect(
139+
costHiddenReason({
140+
modelId: "gpt-5.6-luna",
141+
providerName: "codex/default",
142+
baseURL: "https://api.openai.com/v1",
143+
pricingCache,
144+
}),
145+
).toBe("chatgpt-subscription");
146+
});
147+
148+
it("shows cost on live non-Codex identity even when baseURL is still the ChatGPT backend", () => {
149+
expect(
150+
costHiddenReason({
151+
modelId: "gpt-5.6-luna",
152+
providerName: "openai",
153+
baseURL: CODEX_BASE_URL,
154+
pricingCache,
155+
}),
156+
).toBeNull();
157+
});
158+
119159
it("hides for a free-named model", () => {
120160
expect(costHiddenReason({ modelId: "qwen3:free", pricingCache })).toBe("free-model");
121161
});
@@ -138,6 +178,7 @@ describe("costHiddenReason", () => {
138178
expect(
139179
costHiddenReason({
140180
modelId: "gpt-5.6-luna",
181+
providerName: "openai",
141182
baseURL: "https://api.openai.com/v1",
142183
pricingCache,
143184
}),

src/cost/cost-visibility.ts

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { CODEX_BASE_URL } from "../auth/codex/constants.js";
2+
import { isCodexProviderName } from "../config/codex-providers.js";
23
import { lookupModelPricing, type PricingCache } from "./pricing-fetcher.js";
34

45
// Free-model naming conventions: OpenRouter appends ":free", some gateways use
@@ -30,17 +31,21 @@ export function isCodingPlanBaseURL(baseURL: string | undefined): boolean {
3031
// not apply there, so dollar estimates must be suppressed. Matched against
3132
// the canonical Codex base (origin + path prefix) so api.openai.com stays
3233
// metered and a bare chatgpt.com host does not hide costs.
33-
const CHATGPT_SUBSCRIPTION_FALLBACK = /chatgpt\.com\/backend-api(\/|$)/i;
34+
const CODEX_BASE = new URL(CODEX_BASE_URL);
35+
const CODEX_ORIGIN = CODEX_BASE.origin;
36+
const CODEX_PATH = CODEX_BASE.pathname.replace(/\/$/, "").toLowerCase();
37+
// Host-anchored: a scheme or start of string must precede chatgpt.com so
38+
// notchatgpt.com/backend-api never matches. Query/hash after the path still
39+
// count. Unparseable noise that merely contains the substring does not.
40+
const CHATGPT_SUBSCRIPTION_FALLBACK = /(?:^|\/\/)chatgpt\.com\/backend-api(?:\/|$|\?|#)/i;
3441

3542
export function isChatGPTSubscriptionBaseURL(baseURL: string | undefined): boolean {
3643
if (baseURL === undefined) return false;
3744
try {
3845
const url = new URL(baseURL);
39-
const codex = new URL(CODEX_BASE_URL);
40-
if (url.origin !== codex.origin) return false;
41-
const basePath = codex.pathname.replace(/\/$/, "");
42-
const path = url.pathname.replace(/\/$/, "") || "/";
43-
return path === basePath || path.startsWith(`${basePath}/`);
46+
if (url.origin !== CODEX_ORIGIN) return false;
47+
const path = url.pathname.replace(/\/$/, "").toLowerCase() || "/";
48+
return path === CODEX_PATH || path.startsWith(`${CODEX_PATH}/`);
4449
} catch {
4550
return CHATGPT_SUBSCRIPTION_FALLBACK.test(baseURL);
4651
}
@@ -54,6 +59,10 @@ export function isFreeModelByPricing(cache: PricingCache | null, modelId: string
5459

5560
export interface CostVisibilityInput {
5661
baseURL?: string | undefined;
62+
// Live /model identity. When set, it wins over a stale launch baseURL for
63+
// the ChatGPT-subscription hide: Codex names hide even on api.openai.com,
64+
// non-Codex names show even on CODEX_BASE_URL. Undefined falls back to URL.
65+
providerName?: string | undefined;
5766
modelId: string;
5867
providerFree?: boolean | undefined;
5968
pricingCache: PricingCache | null;
@@ -62,15 +71,22 @@ export interface CostVisibilityInput {
6271
export type CostHiddenReason =
6372
"provider-free" | "coding-plan" | "chatgpt-subscription" | "free-model" | "zero-priced";
6473

74+
function isChatGPTSubscriptionSession(input: CostVisibilityInput): boolean {
75+
if (input.providerName !== undefined) {
76+
return isCodexProviderName(input.providerName);
77+
}
78+
return isChatGPTSubscriptionBaseURL(input.baseURL);
79+
}
80+
6581
// Non-null when the dollar cost should be suppressed: a manual provider
66-
// override, a coding-plan endpoint, a ChatGPT/Codex subscription endpoint, a
67-
// free-named model, or a model the pricing registry reports as zero-cost. The
68-
// reason is carried to the display so /cost can say which condition hid the
69-
// figure.
82+
// override, a coding-plan endpoint, a ChatGPT/Codex subscription (live
83+
// provider identity, else Codex URL), a free-named model, or a model the
84+
// pricing registry reports as zero-cost. The reason is carried to the
85+
// display so /cost can say which condition hid the figure.
7086
export function costHiddenReason(input: CostVisibilityInput): CostHiddenReason | null {
7187
if (input.providerFree === true) return "provider-free";
7288
if (isCodingPlanBaseURL(input.baseURL)) return "coding-plan";
73-
if (isChatGPTSubscriptionBaseURL(input.baseURL)) return "chatgpt-subscription";
89+
if (isChatGPTSubscriptionSession(input)) return "chatgpt-subscription";
7490
if (isFreeModelId(input.modelId)) return "free-model";
7591
return isFreeModelByPricing(input.pricingCache, input.modelId) ? "zero-priced" : null;
7692
}

src/tui/runner.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2086,6 +2086,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
20862086
const summary = buildCostSummary({
20872087
modelId: config.model,
20882088
baseURL: config.baseURL,
2089+
providerName: config.providerName,
20892090
pricingCache,
20902091
totalCost,
20912092
formattedCost: formatCost(totalCost),

0 commit comments

Comments
 (0)