diff --git a/CHANGELOG.md b/CHANGELOG.md index 61f7daa9..2b3d14b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/agent/renderer.ts b/src/agent/renderer.ts index 4e0e49a3..7f2c2dca 100644 --- a/src/agent/renderer.ts +++ b/src/agent/renderer.ts @@ -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 { @@ -58,6 +65,35 @@ function formatOp(name: string): string { return name; } +function tokenUsageFromEvent(data: Record | undefined): TokenUsage | null { + const usage = data?.usage; + if (usage === null || typeof usage !== "object") return null; + const fields = usage as Record; + 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 | undefined, + fallbackModelId: string, +): TurnBillingIdentity { + const source = data?.source; + if (source === null || typeof source !== "object") { + return { modelId: fallbackModelId }; + } + const fields = source as Record; + if (typeof fields.sourceId !== "string" || typeof fields.model !== "string") { + return { modelId: fallbackModelId }; + } + return billingIdentityFromSource(fields as LastCycleSource); +} + export function createRenderer( startedAt: number, modelId?: string, @@ -69,20 +105,29 @@ export function createRenderer( const pendingArgs = new Map>(); const pendingNames = new Map(); 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); } @@ -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; } diff --git a/src/cost/cost-summary.test.ts b/src/cost/cost-summary.test.ts index aec9d962..f27411e1 100644 --- a/src/cost/cost-summary.test.ts +++ b/src/cost/cost-summary.test.ts @@ -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"); @@ -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); @@ -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)"); @@ -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)", + ); + }); }); diff --git a/src/cost/cost-summary.ts b/src/cost/cost-summary.ts index ae8d54bd..6d4f4078 100644 --- a/src/cost/cost-summary.ts +++ b/src/cost/cost-summary.ts @@ -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; @@ -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. @@ -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, }; @@ -90,20 +101,52 @@ export function formatStatusBarSegments(summary: CostSummary): StatusBarCostSegm }; } -const HIDDEN_REASON_TEXT: Record = { +const HIDDEN_REASON_TEXT: Record, 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)})`, ]; diff --git a/src/cost/cost-visibility.test.ts b/src/cost/cost-visibility.test.ts index 7d6f4e58..a35a58c2 100644 --- a/src/cost/cost-visibility.test.ts +++ b/src/cost/cost-visibility.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "bun:test"; -import { costHiddenReason, isCodingPlanBaseURL, isFreeModelId } from "./cost-visibility.js"; +import { CODEX_BASE_URL } from "../auth/codex/constants.js"; +import { + costHiddenReason, + isChatGPTSubscriptionBaseURL, + isCodingPlanBaseURL, + isCodingPlanProviderName, + isFreeModelId, +} from "./cost-visibility.js"; import type { PricingCache } from "./pricing-fetcher.js"; const pricingCache: PricingCache = { @@ -12,6 +19,11 @@ const pricingCache: PricingCache = { cacheReadPricePerToken: 0, }, "free-model": { inputPricePerToken: 0, outputPricePerToken: 0, cacheReadPricePerToken: 0 }, + "gpt-5.6-luna": { + inputPricePerToken: 0.000001, + outputPricePerToken: 0.000008, + cacheReadPricePerToken: 0, + }, }, }; @@ -55,6 +67,61 @@ describe("isCodingPlanBaseURL", () => { }); }); +describe("isCodingPlanProviderName", () => { + it("matches the first-class Z.AI Coding Plan catalog id", () => { + expect(isCodingPlanProviderName("zai")).toBe(true); + expect(isCodingPlanProviderName("openai")).toBe(false); + expect(isCodingPlanProviderName("codex/default")).toBe(false); + }); + + it("matches first-class connect instance names", () => { + expect(isCodingPlanProviderName("zai/default")).toBe(true); + expect(isCodingPlanProviderName("zai/work")).toBe(true); + expect(isCodingPlanProviderName("openai/default")).toBe(false); + }); +}); + +describe("isChatGPTSubscriptionBaseURL", () => { + it("detects the Codex ChatGPT subscription inference base URL", () => { + expect(isChatGPTSubscriptionBaseURL(CODEX_BASE_URL)).toBe(true); + expect(isChatGPTSubscriptionBaseURL(`${CODEX_BASE_URL}/`)).toBe(true); + expect(isChatGPTSubscriptionBaseURL(`${CODEX_BASE_URL}/codex/responses`)).toBe(true); + }); + + it("does not match the metered OpenAI platform API", () => { + expect(isChatGPTSubscriptionBaseURL("https://api.openai.com/v1")).toBe(false); + }); + + it("does not match chatgpt.com outside the backend-api path", () => { + expect(isChatGPTSubscriptionBaseURL("https://chatgpt.com/")).toBe(false); + expect(isChatGPTSubscriptionBaseURL("https://chatgpt.com/backend")).toBe(false); + expect(isChatGPTSubscriptionBaseURL("https://chatgpt.com/backend-api-v2")).toBe(false); + }); + + it("matches the backend-api path case-insensitively", () => { + expect(isChatGPTSubscriptionBaseURL("https://chatgpt.com/BACKEND-API")).toBe(true); + expect(isChatGPTSubscriptionBaseURL("https://chatgpt.com/Backend-Api/codex/responses")).toBe( + true, + ); + }); + + it("matches query and hash via pathname, not as part of the path prefix", () => { + expect(isChatGPTSubscriptionBaseURL("https://chatgpt.com/backend-api?foo=1")).toBe(true); + expect(isChatGPTSubscriptionBaseURL("https://chatgpt.com/backend-api#section")).toBe(true); + }); + + it("does not match http against the https Codex origin", () => { + expect(isChatGPTSubscriptionBaseURL("http://chatgpt.com/backend-api")).toBe(false); + }); + + it("rejects undefined, unanchored substrings, and lookalike hosts", () => { + expect(isChatGPTSubscriptionBaseURL(undefined)).toBe(false); + expect(isChatGPTSubscriptionBaseURL("not a url chatgpt.com/backend-api")).toBe(false); + expect(isChatGPTSubscriptionBaseURL("notchatgpt.com/backend-api")).toBe(false); + expect(isChatGPTSubscriptionBaseURL("chatgpt.com/backend-api")).toBe(true); + }); +}); + describe("costHiddenReason", () => { it("hides for a manual provider override", () => { expect(costHiddenReason({ modelId: "glm-5.1", providerFree: true, pricingCache })).toBe( @@ -72,6 +139,82 @@ describe("costHiddenReason", () => { ).toBe("coding-plan"); }); + it("hides on live coding-plan provider identity even when baseURL is still the metered API", () => { + expect( + costHiddenReason({ + modelId: "glm-5.1", + providerName: "zai", + baseURL: "https://api.openai.com/v1", + pricingCache, + }), + ).toBe("coding-plan"); + }); + + it("hides on a zai instance name even when baseURL is still the metered API", () => { + expect( + costHiddenReason({ + modelId: "glm-5.1", + providerName: "zai/default", + baseURL: "https://api.openai.com/v1", + pricingCache, + }), + ).toBe("coding-plan"); + }); + + it("hides on a zai instance name with a coding-plan URL", () => { + expect( + costHiddenReason({ + modelId: "glm-5.1", + providerName: "zai/default", + baseURL: "https://api.z.ai/api/coding/paas/v4", + pricingCache, + }), + ).toBe("coding-plan"); + }); + + it("shows cost on live non-coding-plan identity even when baseURL is still a coding-plan endpoint", () => { + expect( + costHiddenReason({ + modelId: "glm-5.1", + providerName: "openai", + baseURL: "https://api.z.ai/api/coding/paas/v4", + pricingCache, + }), + ).toBeNull(); + }); + + it("hides for a Codex ChatGPT subscription base URL even when the model has public rates", () => { + expect( + costHiddenReason({ + modelId: "gpt-5.6-luna", + baseURL: CODEX_BASE_URL, + pricingCache, + }), + ).toBe("chatgpt-subscription"); + }); + + it("hides on live Codex provider identity even when baseURL is still the metered API", () => { + expect( + costHiddenReason({ + modelId: "gpt-5.6-luna", + providerName: "codex/default", + baseURL: "https://api.openai.com/v1", + pricingCache, + }), + ).toBe("chatgpt-subscription"); + }); + + it("shows cost on live non-Codex identity even when baseURL is still the ChatGPT backend", () => { + expect( + costHiddenReason({ + modelId: "gpt-5.6-luna", + providerName: "openai", + baseURL: CODEX_BASE_URL, + pricingCache, + }), + ).toBeNull(); + }); + it("hides for a free-named model", () => { expect(costHiddenReason({ modelId: "qwen3:free", pricingCache })).toBe("free-model"); }); @@ -90,6 +233,28 @@ describe("costHiddenReason", () => { ).toBeNull(); }); + it("shows cost for Luna on the metered OpenAI platform API", () => { + expect( + costHiddenReason({ + modelId: "gpt-5.6-luna", + providerName: "openai", + baseURL: "https://api.openai.com/v1", + pricingCache, + }), + ).toBeNull(); + }); + + it("shows cost for a metered OpenAI instance name", () => { + expect( + costHiddenReason({ + modelId: "gpt-5.6-luna", + providerName: "openai/default", + baseURL: "https://api.openai.com/v1", + pricingCache, + }), + ).toBeNull(); + }); + it("shows cost for an unknown model with no signals", () => { expect(costHiddenReason({ modelId: "mystery-model", pricingCache: null })).toBeNull(); }); diff --git a/src/cost/cost-visibility.ts b/src/cost/cost-visibility.ts index 314fdef0..4a87f763 100644 --- a/src/cost/cost-visibility.ts +++ b/src/cost/cost-visibility.ts @@ -1,3 +1,5 @@ +import { CODEX_BASE_URL } from "../auth/codex/constants.js"; +import { isCodexProviderName } from "../config/codex-providers.js"; import { lookupModelPricing, type PricingCache } from "./pricing-fetcher.js"; // Free-model naming conventions: OpenRouter appends ":free", some gateways use @@ -24,6 +26,38 @@ export function isCodingPlanBaseURL(baseURL: string | undefined): boolean { } } +// First-class Z.AI Coding Plan catalog id, plus connect instance names +// (`zai/default`, `zai/work`). Live /model identity uses these names, not the +// launch baseURL, so a switch onto or off zai updates $ now. +export function isCodingPlanProviderName(name: string): boolean { + return name === "zai" || name.startsWith("zai/"); +} + +// Codex OAuth bills against the user's ChatGPT subscription via +// chatgpt.com/backend-api. Public per-token rates for the same model ids do +// not apply there, so dollar estimates must be suppressed. Matched against +// the canonical Codex base (origin + path prefix) so api.openai.com stays +// metered and a bare chatgpt.com host does not hide costs. +const CODEX_BASE = new URL(CODEX_BASE_URL); +const CODEX_ORIGIN = CODEX_BASE.origin; +const CODEX_PATH = CODEX_BASE.pathname.replace(/\/$/, "").toLowerCase(); +// Host-anchored: a scheme or start of string must precede chatgpt.com so +// notchatgpt.com/backend-api never matches. Query/hash after the path still +// count. Unparseable noise that merely contains the substring does not. +const CHATGPT_SUBSCRIPTION_FALLBACK = /(?:^|\/\/)chatgpt\.com\/backend-api(?:\/|$|\?|#)/i; + +export function isChatGPTSubscriptionBaseURL(baseURL: string | undefined): boolean { + if (baseURL === undefined) return false; + try { + const url = new URL(baseURL); + if (url.origin !== CODEX_ORIGIN) return false; + const path = url.pathname.replace(/\/$/, "").toLowerCase() || "/"; + return path === CODEX_PATH || path.startsWith(`${CODEX_PATH}/`); + } catch { + return CHATGPT_SUBSCRIPTION_FALLBACK.test(baseURL); + } +} + export function isFreeModelByPricing(cache: PricingCache | null, modelId: string): boolean { const pricing = lookupModelPricing(cache, modelId); if (pricing === null) return false; @@ -32,20 +66,44 @@ export function isFreeModelByPricing(cache: PricingCache | null, modelId: string export interface CostVisibilityInput { baseURL?: string | undefined; + // Live /model identity. When set, it wins over a stale launch baseURL for + // ChatGPT-subscription and coding-plan hides: Codex names hide even on + // api.openai.com, zai names hide even on a metered URL; a present + // non-matching name shows even when launch URL would hide. Undefined + // falls back to URL. + providerName?: string | undefined; modelId: string; providerFree?: boolean | undefined; pricingCache: PricingCache | null; } -export type CostHiddenReason = "provider-free" | "coding-plan" | "free-model" | "zero-priced"; +export type CostHiddenReason = + "provider-free" | "coding-plan" | "chatgpt-subscription" | "free-model" | "zero-priced"; + +function isCodingPlanSession(input: CostVisibilityInput): boolean { + if (input.providerName !== undefined) { + return isCodingPlanProviderName(input.providerName); + } + return isCodingPlanBaseURL(input.baseURL); +} + +function isChatGPTSubscriptionSession(input: CostVisibilityInput): boolean { + if (input.providerName !== undefined) { + return isCodexProviderName(input.providerName); + } + return isChatGPTSubscriptionBaseURL(input.baseURL); +} // Non-null when the dollar cost should be suppressed: a manual provider -// override, a coding-plan endpoint, a free-named model, or a model the pricing -// registry reports as zero-cost. The reason is carried to the display so /cost -// can say which condition hid the figure. +// override, a coding-plan session (live zai identity, else /coding URL), a +// ChatGPT/Codex subscription (live provider identity, else Codex URL), a +// free-named model, or a model the pricing registry reports as zero-cost. +// The reason is carried to the display so /cost can say which condition hid +// the figure. export function costHiddenReason(input: CostVisibilityInput): CostHiddenReason | null { if (input.providerFree === true) return "provider-free"; - if (isCodingPlanBaseURL(input.baseURL)) return "coding-plan"; + if (isCodingPlanSession(input)) return "coding-plan"; + if (isChatGPTSubscriptionSession(input)) return "chatgpt-subscription"; if (isFreeModelId(input.modelId)) return "free-model"; return isFreeModelByPricing(input.pricingCache, input.modelId) ? "zero-priced" : null; } diff --git a/src/cost/session-cost.test.ts b/src/cost/session-cost.test.ts new file mode 100644 index 00000000..02e11e17 --- /dev/null +++ b/src/cost/session-cost.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "bun:test"; +import type { TokenUsage } from "@intx/types/runtime"; + +import { createFaremeter, formatCost } from "./faremeter.js"; +import type { PricingCache } from "./pricing-fetcher.js"; +import { billingIdentityFromSource, createSessionCostAccumulator } from "./session-cost.js"; + +const pricingCache: PricingCache = { + timestamp: 0, + models: { + "glm-5.1": { + inputPricePerToken: 0.000002, + outputPricePerToken: 0.00001, + cacheReadPricePerToken: 0, + }, + "gpt-5.6-luna": { + inputPricePerToken: 0.000001, + outputPricePerToken: 0.000008, + cacheReadPricePerToken: 0, + }, + }, +}; + +const usage = (input: number, output: number): TokenUsage => ({ + input, + output, + cacheRead: 0, + cacheWrite: 0, + thinking: 0, +}); + +const CODEX_USAGE = usage(100_000, 20_000); +const METERED_USAGE = usage(1_000, 500); + +function recastAtLiveModel(modelId: string, turns: TokenUsage[]): number { + const faremeter = createFaremeter({ modelId, pricingCache }); + const combined: TokenUsage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + thinking: 0, + }; + for (const turn of turns) { + combined.input += turn.input; + combined.output += turn.output; + combined.cacheRead += turn.cacheRead; + combined.cacheWrite += turn.cacheWrite; + combined.thinking += turn.thinking; + } + faremeter.addUsage(combined); + return faremeter.getTotalCost(); +} + +describe("createSessionCostAccumulator", () => { + it("prices Codex then metered as the metered turns only, not a live-model recast of the sink", () => { + const acc = createSessionCostAccumulator({ pricingCache: () => pricingCache }); + acc.addTurn(CODEX_USAGE, { modelId: "gpt-5.6-luna", providerName: "codex/default" }); + acc.addTurn(METERED_USAGE, { modelId: "glm-5.1", providerName: "openai" }); + + const meteredOnly = createFaremeter({ modelId: "glm-5.1", pricingCache }); + meteredOnly.addUsage(METERED_USAGE); + const snapshot = acc.snapshot(); + + expect(snapshot.mix).toBe("mixed"); + expect(snapshot.meteredCost).toBe(meteredOnly.getTotalCost()); + expect(snapshot.meteredCost).toBeLessThan( + recastAtLiveModel("glm-5.1", [CODEX_USAGE, METERED_USAGE]), + ); + expect(formatCost(snapshot.meteredCost)).toBe(formatCost(meteredOnly.getTotalCost())); + }); + + it("prices metered then Codex as mixed with only the metered turns billed", () => { + const acc = createSessionCostAccumulator({ pricingCache: () => pricingCache }); + acc.addTurn(METERED_USAGE, { modelId: "glm-5.1", providerName: "openai" }); + acc.addTurn(CODEX_USAGE, { modelId: "gpt-5.6-luna", providerName: "codex/default" }); + + const meteredOnly = createFaremeter({ modelId: "glm-5.1", pricingCache }); + meteredOnly.addUsage(METERED_USAGE); + const snapshot = acc.snapshot(); + + expect(snapshot.mix).toBe("mixed"); + expect(snapshot.hiddenReason).toBe("chatgpt-subscription"); + expect(snapshot.meteredCost).toBe(meteredOnly.getTotalCost()); + expect(snapshot.meteredCost).toBeGreaterThan(0); + }); + + it("keeps a Codex-only session hidden with the subscription reason", () => { + const acc = createSessionCostAccumulator({ pricingCache: () => pricingCache }); + acc.addTurn(CODEX_USAGE, { modelId: "gpt-5.6-luna", providerName: "codex/default" }); + + const snapshot = acc.snapshot(); + expect(snapshot.mix).toBe("hidden-only"); + expect(snapshot.hiddenReason).toBe("chatgpt-subscription"); + expect(snapshot.meteredCost).toBe(0); + }); + + it("maps Codex catalog identity from sourceId, not the adapter kind", () => { + expect( + billingIdentityFromSource({ + sourceId: "codex/default", + provider: "codex-responses", + model: "gpt-5.6-luna", + }), + ).toEqual({ modelId: "gpt-5.6-luna", providerName: "codex/default" }); + + const acc = createSessionCostAccumulator({ pricingCache: () => pricingCache }); + acc.addTurn( + CODEX_USAGE, + billingIdentityFromSource({ + sourceId: "codex/default", + provider: "codex-responses", + model: "gpt-5.6-luna", + }), + ); + expect(acc.snapshot()).toEqual({ + mix: "hidden-only", + meteredCost: 0, + hiddenReason: "chatgpt-subscription", + }); + }); + + it("resets mix and metered cost for a new session", () => { + const acc = createSessionCostAccumulator({ pricingCache: () => pricingCache }); + acc.addTurn(METERED_USAGE, { modelId: "glm-5.1", providerName: "openai" }); + acc.addTurn(CODEX_USAGE, { modelId: "gpt-5.6-luna", providerName: "codex/default" }); + acc.reset(); + + expect(acc.snapshot()).toEqual({ mix: "none", meteredCost: 0, hiddenReason: null }); + }); +}); diff --git a/src/cost/session-cost.ts b/src/cost/session-cost.ts new file mode 100644 index 00000000..5d0bd34e --- /dev/null +++ b/src/cost/session-cost.ts @@ -0,0 +1,79 @@ +import type { LastCycleSource, TokenUsage } from "@intx/types/runtime"; + +import { costHiddenReason, type CostHiddenReason } from "./cost-visibility.js"; +import { createFaremeter } from "./faremeter.js"; +import type { PricingCache } from "./pricing-fetcher.js"; + +export type SessionBillingMix = "none" | "hidden-only" | "metered-only" | "mixed"; + +export interface TurnBillingIdentity { + modelId: string; + providerName?: string | undefined; + baseURL?: string | undefined; + providerFree?: boolean | undefined; +} + +export interface SessionCostSnapshot { + mix: SessionBillingMix; + meteredCost: number; + hiddenReason: CostHiddenReason | null; +} + +export function sessionBillingMix(hasHidden: boolean, hasMetered: boolean): SessionBillingMix { + if (hasHidden && hasMetered) return "mixed"; + if (hasHidden) return "hidden-only"; + if (hasMetered) return "metered-only"; + return "none"; +} + +// Catalog identity lives on sourceId (`codex/default`, `zai`). `provider` is the +// adapter kind (`codex-responses`) and would miss subscription / coding-plan hides. +export function billingIdentityFromSource(source: LastCycleSource): TurnBillingIdentity { + return { modelId: source.model, providerName: source.sourceId }; +} + +export function createSessionCostAccumulator(args: { pricingCache: () => PricingCache | null }): { + addTurn(usage: TokenUsage, identity: TurnBillingIdentity): void; + reset(): void; + snapshot(): SessionCostSnapshot; +} { + let hasHidden = false; + let hasMetered = false; + let meteredCost = 0; + let hiddenReason: CostHiddenReason | null = null; + + return { + addTurn(usage, identity): void { + const pricingCache = args.pricingCache(); + const reason = costHiddenReason({ + modelId: identity.modelId, + baseURL: identity.baseURL, + providerName: identity.providerName, + providerFree: identity.providerFree, + pricingCache, + }); + if (reason !== null) { + hasHidden = true; + hiddenReason = reason; + return; + } + hasMetered = true; + const faremeter = createFaremeter({ modelId: identity.modelId, pricingCache }); + faremeter.addUsage(usage); + meteredCost += faremeter.getTotalCost(); + }, + reset(): void { + hasHidden = false; + hasMetered = false; + meteredCost = 0; + hiddenReason = null; + }, + snapshot(): SessionCostSnapshot { + return { + mix: sessionBillingMix(hasHidden, hasMetered), + meteredCost, + hiddenReason, + }; + }, + }; +} diff --git a/src/renderer.test.ts b/src/renderer.test.ts index 6d093586..72305ec7 100644 --- a/src/renderer.test.ts +++ b/src/renderer.test.ts @@ -1,6 +1,10 @@ import { describe, test, expect } from "bun:test"; -import { createRenderer } from "./agent/renderer.js"; import type { ReactorEmittedEvent } from "@intx/inference"; +import type { LastCycleSource, TokenUsage } from "@intx/types/runtime"; + +import { createRenderer } from "./agent/renderer.js"; +import { createFaremeter, formatCost } from "./cost/faremeter.js"; +import type { PricingCache } from "./cost/pricing-fetcher.js"; // Capture stdout/stderr writes during a test function captureOutput(): { stdout: string[]; stderr: string[]; restore: () => void } { @@ -395,3 +399,92 @@ describe("renderer — read-only tools produce no journal block", () => { expect(cap.stdout.join("")).toBe(""); }); }); + +describe("renderer — mixed vs hidden-only session cost", () => { + const pricingCache: PricingCache = { + timestamp: 0, + models: { + "glm-5.1": { + inputPricePerToken: 0.000002, + outputPricePerToken: 0.00001, + cacheReadPricePerToken: 0, + }, + "gpt-5.6-luna": { + inputPricePerToken: 0.000001, + outputPricePerToken: 0.000008, + cacheReadPricePerToken: 0, + }, + }, + }; + + const usage = (input: number, output: number): TokenUsage => ({ + input, + output, + cacheRead: 0, + cacheWrite: 0, + thinking: 0, + }); + + const CODEX_USAGE = usage(100_000, 20_000); + const METERED_USAGE = usage(1_000, 500); + const CODEX_SOURCE: LastCycleSource = { + sourceId: "codex/default", + provider: "codex-responses", + model: "gpt-5.6-luna", + }; + const METERED_SOURCE: LastCycleSource = { + sourceId: "openai", + provider: "openai", + model: "glm-5.1", + }; + + function recastAtLiveModel(modelId: string, turns: TokenUsage[]): number { + const faremeter = createFaremeter({ modelId, pricingCache }); + const combined: TokenUsage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + thinking: 0, + }; + for (const turn of turns) { + combined.input += turn.input; + combined.output += turn.output; + combined.cacheRead += turn.cacheRead; + combined.cacheWrite += turn.cacheWrite; + combined.thinking += turn.thinking; + } + faremeter.addUsage(combined); + return faremeter.getTotalCost(); + } + + test("Codex then metered shows the metered portion only, not a live-model recast", () => { + const cap = captureOutput(); + const renderer = createRenderer(Date.now(), "glm-5.1", pricingCache); + renderer.render(event("inference.done", { usage: CODEX_USAGE, source: CODEX_SOURCE })); + renderer.render(event("inference.done", { usage: METERED_USAGE, source: METERED_SOURCE })); + cap.restore(); + + const meteredOnly = createFaremeter({ modelId: "glm-5.1", pricingCache }); + meteredOnly.addUsage(METERED_USAGE); + const bar = cap.stderr[cap.stderr.length - 1] ?? ""; + const recast = recastAtLiveModel("glm-5.1", [CODEX_USAGE, METERED_USAGE]); + + expect(bar).toContain(formatCost(meteredOnly.getTotalCost())); + expect(bar).toContain("metered portion only; session mixed billed and hidden usage"); + expect(bar).not.toContain("covered by ChatGPT subscription"); + expect(bar).not.toContain(formatCost(recast)); + expect(meteredOnly.getTotalCost()).toBeLessThan(recast); + }); + + test("hidden-only Codex still uses subscription copy", () => { + const cap = captureOutput(); + const renderer = createRenderer(Date.now(), "gpt-5.6-luna", pricingCache); + renderer.render(event("inference.done", { usage: CODEX_USAGE, source: CODEX_SOURCE })); + cap.restore(); + + const bar = cap.stderr[cap.stderr.length - 1] ?? ""; + expect(bar).toContain("covered by ChatGPT subscription (not billed per token)"); + expect(bar).not.toMatch(/\$\d/); + }); +}); diff --git a/src/tui/runner-host.test.ts b/src/tui/runner-host.test.ts index 9b23bd31..51562fa6 100644 --- a/src/tui/runner-host.test.ts +++ b/src/tui/runner-host.test.ts @@ -6,7 +6,12 @@ import type { KeyEvent } from "@opentui/core"; import type { CostSummary } from "../cost/cost-summary.js"; import type { SubAgentSession } from "../subagent/session-store.js"; import { createHarness } from "./harness.js"; -import { acceptOverlaySelection, closeInsetOverlay, runOverlayAction } from "./shell.js"; +import { + acceptOverlaySelection, + closeInsetOverlay, + moveOverlaySelection, + runOverlayAction, +} from "./shell.js"; import { mountRunnerHost, observeSessionFromSubAgents, @@ -33,6 +38,7 @@ function fakeCostSummary(): CostSummary { contextTokens: 1000, contextIsEstimate: false, costHiddenReason: null, + sessionBillingMix: "none", contextWindow: 10000, contextPercentUsed: 10, }; @@ -443,6 +449,104 @@ describe("bottom border cost run", () => { } }); + test("selecting a Codex model hides prompt $ without waiting for inference", async () => { + const harness = await createHarness({ width: 80, height: 24 }); + let provider = "xai"; + const host = await mountRunnerHost({ + title: "test", + eventEmitter: new EventEmitter(), + send: () => {}, + interrupt: () => {}, + providers: { + xai: { models: ["grok-4"] }, + "codex/abk-labs": { models: ["gpt-5.5"] }, + }, + onModelSelect: (id) => { + const sep = id.indexOf(":"); + if (sep <= 0) return; + provider = id.slice(0, sep); + }, + commands: [], + onCommand: () => {}, + chrome: () => ({ agents: [] }), + subscribeChrome: () => () => {}, + subAgentSessions: () => [], + createRenderer: async () => harness.renderer, + readCostSummary: () => ({ + ...fakeCostSummary(), + costHiddenReason: provider.startsWith("codex/") ? "chatgpt-subscription" : null, + }), + showPromptCost: () => true, + }); + try { + expect(ruleOf(host.shell.promptBottomRule)).toContain("$0.42"); + + expect(host.openSurface("models")).toBe(true); + const items = host.shell.overlayItems; + const codexIndex = items.findIndex((label) => label.includes("codex/abk-labs")); + expect(codexIndex).toBeGreaterThanOrEqual(0); + moveOverlaySelection(host.shell, codexIndex); + acceptOverlaySelection(host.shell); + + expect(provider).toBe("codex/abk-labs"); + expect(ruleOf(host.shell.promptBottomRule)).not.toContain("$0.42"); + expect(host.shell.costContext?.costLabel ?? null).toBeNull(); + expect(ruleOf(host.shell.promptBottomRule)).toContain("10%"); + } finally { + host.dispose(); + harness.destroy(); + } + }); + + test("selecting a metered model from Codex shows prompt $ without waiting for inference", async () => { + const harness = await createHarness({ width: 80, height: 24 }); + let provider = "codex/abk-labs"; + const host = await mountRunnerHost({ + title: "test", + eventEmitter: new EventEmitter(), + send: () => {}, + interrupt: () => {}, + providers: { + "codex/abk-labs": { models: ["gpt-5.5"] }, + xai: { models: ["grok-4"] }, + }, + onModelSelect: (id) => { + const sep = id.indexOf(":"); + if (sep <= 0) return; + provider = id.slice(0, sep); + }, + commands: [], + onCommand: () => {}, + chrome: () => ({ agents: [] }), + subscribeChrome: () => () => {}, + subAgentSessions: () => [], + createRenderer: async () => harness.renderer, + readCostSummary: () => ({ + ...fakeCostSummary(), + costHiddenReason: provider.startsWith("codex/") ? "chatgpt-subscription" : null, + }), + showPromptCost: () => true, + }); + try { + expect(ruleOf(host.shell.promptBottomRule)).not.toContain("$0.42"); + + expect(host.openSurface("models")).toBe(true); + const items = host.shell.overlayItems; + const meteredIndex = items.findIndex((label) => label.includes("[xai]")); + expect(meteredIndex).toBeGreaterThanOrEqual(0); + moveOverlaySelection(host.shell, meteredIndex); + acceptOverlaySelection(host.shell); + + expect(provider).toBe("xai"); + expect(ruleOf(host.shell.promptBottomRule)).toContain("$0.42"); + expect(host.shell.costContext?.costLabel ?? null).toBe("$0.42"); + expect(ruleOf(host.shell.promptBottomRule)).toContain("10%"); + } finally { + host.dispose(); + harness.destroy(); + } + }); + test("session.clear paints the context meter unknown immediately", async () => { const harness = await createHarness({ width: 80, height: 24 }); const emitter = new EventEmitter(); diff --git a/src/tui/runner-host.ts b/src/tui/runner-host.ts index 2e7a6eca..f97cc620 100644 --- a/src/tui/runner-host.ts +++ b/src/tui/runner-host.ts @@ -104,9 +104,9 @@ export interface RunnerHostDeps { */ readonly modelLabel?: () => PromptActionBarModelLabelInput; /** - * Live cost/context source for the bottom border's meter. Read on mount and - * again after every completed inference turn, so the meter tracks usage - * without a timer of its own. + * Live cost/context source for the bottom border's meter. Read on mount, after + * every completed inference turn, and after a live model pick so hide/show + * follows the new identity without waiting for the next inference. */ readonly readCostSummary?: () => CostSummary | undefined; /** @@ -248,6 +248,9 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise const onModelSelect = (id: string): void => { deps.onModelSelect(id); if (readModelLabel) setPromptModelLabel(host.shell, readModelLabel()); + // Identity is already applied (deps.onModelSelect). Re-read so Codex + // hides $ (and a metered provider shows it) without waiting for inference. + pushCostContext(); }; const cwd = deps.cwd ?? process.cwd(); const host = await mountProductHost({ diff --git a/src/tui/runner.ts b/src/tui/runner.ts index bd2efa1c..82ac5ae5 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -127,7 +127,8 @@ import pkg from "../../package.json" with { type: "json" }; import { seedPricingMetadataFromCache } from "../cost/pricing-metadata.js"; import { defaultPricingCachePath } from "../cost/pricing-fetcher.js"; import { getActivePricingCache } from "../cost/cost-visibility.js"; -import { createFaremeter, formatCost } from "../cost/faremeter.js"; +import { formatCost } from "../cost/faremeter.js"; +import { billingIdentityFromSource, createSessionCostAccumulator } from "../cost/session-cost.js"; import { buildCostSummary, maskContextMeterWhenNoTurns, @@ -162,6 +163,7 @@ import { OPERATOR_ORIGINATED_FLAG } from "../agent/message-provenance.js"; import { createSessionOperationQueue } from "./session-operation-queue.js"; import { setAgentSourceUnlessClosed } from "./agent-source-sync.js"; import { createChatDirector, hydrateTasksFromTurns } from "../agent/director.js"; +import { onTurnBoundary } from "../agent/reactor-events.js"; import { loadAgentProfiles } from "../agent/profiles.js"; import { resolveAgentPluginProfiles } from "../plugins/agent-plugins.js"; import { createPermissionGate } from "../permission/gate.js"; @@ -1610,6 +1612,10 @@ export async function runTUI(initialConfig: Config): Promise { }, }); + const sessionCost = createSessionCostAccumulator({ + pricingCache: getActivePricingCache, + }); + // MCP servers connected so far, keyed by name so a reconnect after a failure // replaces rather than duplicates the entry. let connectedMcpServers: ConnectedMcpServer[] = resumeSeed.mcpServers; @@ -1672,6 +1678,9 @@ export async function runTUI(initialConfig: Config): Promise { const streamSink = (event: Parameters[0]): void => { runSink.sink(event); cycleRecorder.handleEvent(event); + if (onTurnBoundary(event)) { + sessionCost.addTurn(event.data.usage, billingIdentityFromSource(event.data.source)); + } }; // Tool count before any MCP server connects; a reload is only worthwhile if @@ -1955,6 +1964,7 @@ export async function runTUI(initialConfig: Config): Promise { await initSessionDir(config.cwd, sessionId); permissionGate.reset(); runSink.reset(); + sessionCost.reset(); currentAgent = await buildAgent(); cycleRecorder.reset(); streamPromise = consumeStream(currentAgent.stream(), streamSink); @@ -2073,9 +2083,8 @@ export async function runTUI(initialConfig: Config): Promise { const usage = runSink.getTokenUsage(); const lastTurnUsage = runSink.getLastTurnUsage(); const pricingCache = getActivePricingCache(); - const faremeter = createFaremeter({ modelId: config.model, pricingCache }); - faremeter.addUsage(usage); - const totalCost = faremeter.getTotalCost(); + const billed = sessionCost.snapshot(); + const totalCost = billed.meteredCost; // A provider that omits or zeroes usage would otherwise pin the meter at // 0% forever; fall back to the director's local estimate (turns plus // system-prompt/tool-schema overhead). The governor already decided @@ -2086,6 +2095,7 @@ export async function runTUI(initialConfig: Config): Promise { const summary = buildCostSummary({ modelId: config.model, baseURL: config.baseURL, + providerName: config.providerName, pricingCache, totalCost, formattedCost: formatCost(totalCost), @@ -2096,6 +2106,8 @@ export async function runTUI(initialConfig: Config): Promise { ? contextEstimate.tokens : contextTokensFromUsage(lastTurnUsage), contextIsEstimate: isEstimate, + sessionBillingMix: billed.mix, + sessionHiddenReason: billed.hiddenReason, }); return maskContextMeterWhenNoTurns(summary, runSink.getTurnCount()); },