fix(stats): resolved stats openai-codex cost fallback to OpenAI rates

- Updated Codex model pricing data to include non-zero input/output/cacheRead rates.
- Added generate-models fallback to copy billable OpenAI costs into bundled openai-codex models.
- Added catalog-cost helpers to fallback openai-codex pricing to openai and compute token totals.
- Added regression tests for openai-codex cost parity and db backfill, and documented the fix in changelog.
This commit is contained in:
can1357
2026-04-28 18:11:11 +02:00
parent 82097602c3
commit c751b9c4d5
7 changed files with 337 additions and 47 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed OpenAI Codex GPT model pricing by inheriting matching OpenAI catalog rates for zero-priced discovered Codex entries.
## [14.5.3] - 2026-04-27
### Added
+32
View File
@@ -179,6 +179,37 @@ function applyPremiumMultiplierOverrides(models: readonly Model[]): Model[] {
};
});
}
function hasBillableCost(cost: Model["cost"]): boolean {
return cost.input !== 0 || cost.output !== 0 || cost.cacheRead !== 0 || cost.cacheWrite !== 0;
}
function applyCodexPricingFallback(models: readonly Model[]): Model[] {
const openAIModels = new Map(
models
.filter(model => model.provider === "openai" && hasBillableCost(model.cost))
.map(model => [model.id, model.cost]),
);
return models.map(model => {
if (model.provider !== "openai-codex" || model.api !== "openai-codex-responses") {
return model;
}
if (hasBillableCost(model.cost)) {
return model;
}
const openAICost = openAIModels.get(model.id);
if (!openAICost) {
return model;
}
return {
...model,
cost: { ...openAICost },
};
});
}
const ANTIGRAVITY_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
async function getOAuthCredentialsFromStorage(provider: OAuthProvider): Promise<OAuthCredentials | null> {
@@ -334,6 +365,7 @@ async function generateModels() {
allModels = applyGlobalModelsDevFallback(allModels, modelsDevModels);
allModels = applyPremiumMultiplierOverrides(allModels);
allModels = applyCodexPricingFallback(allModels);
applyGeneratedModelPolicies(allModels);
linkOpenAIPromotionTargets(allModels);
+40 -40
View File
@@ -33899,9 +33899,9 @@
"image"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"input": 1.25,
"output": 10,
"cacheRead": 0.125,
"cacheWrite": 0
},
"contextWindow": 400000,
@@ -33927,9 +33927,9 @@
"image"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"input": 1.25,
"output": 10,
"cacheRead": 0.125,
"cacheWrite": 0
},
"contextWindow": 272000,
@@ -33983,9 +33983,9 @@
"image"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"input": 1.25,
"output": 10,
"cacheRead": 0.13,
"cacheWrite": 0
},
"contextWindow": 400000,
@@ -34011,9 +34011,9 @@
"image"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"input": 1.25,
"output": 10,
"cacheRead": 0.125,
"cacheWrite": 0
},
"contextWindow": 272000,
@@ -34039,9 +34039,9 @@
"image"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"input": 1.25,
"output": 10,
"cacheRead": 0.125,
"cacheWrite": 0
},
"contextWindow": 272000,
@@ -34067,9 +34067,9 @@
"image"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"input": 0.25,
"output": 2,
"cacheRead": 0.025,
"cacheWrite": 0
},
"contextWindow": 272000,
@@ -34095,9 +34095,9 @@
"image"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"input": 1.75,
"output": 14,
"cacheRead": 0.175,
"cacheWrite": 0
},
"contextWindow": 272000,
@@ -34123,9 +34123,9 @@
"image"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"input": 1.75,
"output": 14,
"cacheRead": 0.175,
"cacheWrite": 0
},
"contextWindow": 272000,
@@ -34151,9 +34151,9 @@
"image"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"input": 1.75,
"output": 14,
"cacheRead": 0.175,
"cacheWrite": 0
},
"contextWindow": 272000,
@@ -34206,9 +34206,9 @@
"image"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"input": 2.5,
"output": 15,
"cacheRead": 0.25,
"cacheWrite": 0
},
"contextWindow": 272000,
@@ -34234,9 +34234,9 @@
"image"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"input": 0.75,
"output": 4.5,
"cacheRead": 0.075,
"cacheWrite": 0
},
"contextWindow": 272000,
@@ -34262,9 +34262,9 @@
"image"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"input": 0.2,
"output": 1.25,
"cacheRead": 0.02,
"cacheWrite": 0
},
"contextWindow": 272000,
@@ -34290,9 +34290,9 @@
"image"
],
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"input": 5,
"output": 30,
"cacheRead": 0.5,
"cacheWrite": 0
},
"contextWindow": 272000,
@@ -52775,4 +52775,4 @@
}
}
}
}
}
+19
View File
@@ -70,4 +70,23 @@ describe("calculateCost", () => {
expect(usage.cost.cacheWrite).toBeCloseTo(0.08, 8);
expect(usage.cost.total).toBeCloseTo(2.18, 8);
});
it("prices OpenAI Codex GPT models from the matching OpenAI catalog entry", () => {
const openAIModel = getBundledModel("openai", "gpt-5.4");
const codexModel = getBundledModel("openai-codex", "gpt-5.4");
const usage: Usage = {
input: 1000,
output: 500,
cacheRead: 200,
cacheWrite: 0,
totalTokens: 1700,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
expect(codexModel.cost).toEqual(openAIModel.cost);
calculateCost(codexModel, usage);
expect(usage.cost.total).toBeCloseTo(0.01005, 8);
});
});
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed GPT cost reporting by deriving missing OpenAI Codex costs from the model catalog and backfilling existing zero-cost rows.
## [13.6.0] - 2026-03-03
### Fixed
+108 -7
View File
@@ -1,5 +1,6 @@
import { Database } from "bun:sqlite";
import * as fs from "node:fs/promises";
import { type GeneratedProvider, getBundledModel, type Usage } from "@oh-my-pi/pi-ai";
import { getConfigRootDir, getStatsDbPath } from "@oh-my-pi/pi-utils";
import type {
AggregatedStats,
@@ -12,7 +13,19 @@ import type {
TimeSeriesPoint,
} from "./types";
const DB_PATH = getStatsDbPath();
type ModelCost = { input: number; output: number; cacheRead: number; cacheWrite: number };
type UsageCost = Usage["cost"];
type CostTokens = Pick<Usage, "input" | "output" | "cacheRead" | "cacheWrite">;
interface CostBackfillRow {
id: number;
provider: string;
model: string;
input_tokens: number;
output_tokens: number;
cache_read_tokens: number;
cache_write_tokens: number;
}
let db: Database | null = null;
@@ -25,7 +38,7 @@ export async function initDb(): Promise<Database> {
// Ensure directory exists
await fs.mkdir(getConfigRootDir(), { recursive: true });
db = new Database(DB_PATH);
db = new Database(getStatsDbPath());
db.exec("PRAGMA journal_mode = WAL");
// Create tables
@@ -74,9 +87,96 @@ export async function initDb(): Promise<Database> {
db.exec("ALTER TABLE messages ADD COLUMN premium_requests REAL NOT NULL DEFAULT 0");
}
db.exec("UPDATE messages SET premium_requests = 0 WHERE premium_requests IS NULL");
backfillMissingCatalogCosts(db);
return db;
}
function hasBillableCost(cost: ModelCost): boolean {
return cost.input !== 0 || cost.output !== 0 || cost.cacheRead !== 0 || cost.cacheWrite !== 0;
}
function getBundledModelCost(provider: string, modelId: string): ModelCost | null {
const model = getBundledModel(provider as GeneratedProvider, modelId);
return model?.cost ?? null;
}
function getCatalogCost(provider: string, modelId: string): ModelCost | null {
const primaryCost = getBundledModelCost(provider, modelId);
if (primaryCost && hasBillableCost(primaryCost)) {
return primaryCost;
}
if (provider === "openai-codex") {
const openAICost = getBundledModelCost("openai", modelId);
if (openAICost && hasBillableCost(openAICost)) {
return openAICost;
}
}
return null;
}
function calculateCatalogCost(provider: string, modelId: string, tokens: CostTokens): UsageCost | null {
const cost = getCatalogCost(provider, modelId);
if (!cost) return null;
const input = (cost.input / 1_000_000) * tokens.input;
const output = (cost.output / 1_000_000) * tokens.output;
const cacheRead = (cost.cacheRead / 1_000_000) * tokens.cacheRead;
const cacheWrite = (cost.cacheWrite / 1_000_000) * tokens.cacheWrite;
return {
input,
output,
cacheRead,
cacheWrite,
total: input + output + cacheRead + cacheWrite,
};
}
function resolveStoredCost(stats: MessageStats): UsageCost {
if (stats.usage.cost.total !== 0) {
return stats.usage.cost;
}
return calculateCatalogCost(stats.provider, stats.model, stats.usage) ?? stats.usage.cost;
}
function backfillMissingCatalogCosts(database: Database): void {
const rows = database
.prepare(`
SELECT id, provider, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens
FROM messages
WHERE cost_total = 0 AND total_tokens > 0
`)
.all() as CostBackfillRow[];
if (rows.length === 0) return;
const update = database.prepare(`
UPDATE messages
SET cost_input = ?, cost_output = ?, cost_cache_read = ?, cost_cache_write = ?, cost_total = ?
WHERE id = ?
`);
const applyBackfill = database.transaction(() => {
for (const row of rows) {
const cost = calculateCatalogCost(row.provider, row.model, {
input: row.input_tokens,
output: row.output_tokens,
cacheRead: row.cache_read_tokens,
cacheWrite: row.cache_write_tokens,
});
if (!cost || cost.total === 0) continue;
update.run(cost.input, cost.output, cost.cacheRead, cost.cacheWrite, cost.total, row.id);
}
});
applyBackfill();
}
/**
* Get the stored offset for a session file.
*/
@@ -120,6 +220,7 @@ export function insertMessageStats(stats: MessageStats[]): number {
let inserted = 0;
const insert = db.transaction(() => {
for (const s of stats) {
const cost = resolveStoredCost(s);
const result = stmt.run(
s.sessionFile,
s.entryId,
@@ -138,11 +239,11 @@ export function insertMessageStats(stats: MessageStats[]): number {
s.usage.cacheWrite,
s.usage.totalTokens,
s.usage.premiumRequests ?? 0,
s.usage.cost.input,
s.usage.cost.output,
s.usage.cost.cacheRead,
s.usage.cost.cacheWrite,
s.usage.cost.total,
cost.input,
cost.output,
cost.cacheRead,
cost.cacheWrite,
cost.total,
);
if (result.changes > 0) inserted++;
}
+130
View File
@@ -0,0 +1,130 @@
import { Database } from "bun:sqlite";
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as os from "node:os";
import * as path from "node:path";
import { getBundledModel } from "@oh-my-pi/pi-ai";
import { getAgentDir, getStatsDbPath, setAgentDir, TempDir } from "@oh-my-pi/pi-utils";
import { closeDb, getRecentRequests, initDb, insertMessageStats } from "../src/db";
import type { MessageStats } from "../src/types";
const originalConfigDir = process.env.PI_CONFIG_DIR;
const originalAgentDir = getAgentDir();
let tempDir: TempDir | null = null;
beforeEach(() => {
tempDir = TempDir.createSync("@pi-stats-db-");
const configDir = path.relative(os.homedir(), tempDir.join("config"));
process.env.PI_CONFIG_DIR = configDir;
setAgentDir(path.join(os.homedir(), configDir, "agent"));
});
afterEach(() => {
closeDb();
if (originalConfigDir === undefined) {
delete process.env.PI_CONFIG_DIR;
} else {
process.env.PI_CONFIG_DIR = originalConfigDir;
}
setAgentDir(originalAgentDir);
tempDir?.removeSync();
tempDir = null;
});
function createCodexGptStats(entryId: string): MessageStats {
return {
sessionFile: "/tmp/session.jsonl",
entryId,
folder: "/tmp/project",
model: "gpt-5.4",
provider: "openai-codex",
api: "openai-codex-responses",
timestamp: Date.now(),
duration: 1000,
ttft: 100,
stopReason: "stop",
errorMessage: null,
usage: {
input: 1000,
output: 500,
cacheRead: 200,
cacheWrite: 0,
totalTokens: 1700,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
};
}
function expectedCodexGptCost() {
const cost = getBundledModel("openai-codex", "gpt-5.4").cost;
const input = (cost.input / 1_000_000) * 1000;
const output = (cost.output / 1_000_000) * 500;
const cacheRead = (cost.cacheRead / 1_000_000) * 200;
return {
input,
output,
cacheRead,
total: input + output + cacheRead,
};
}
describe("stats GPT cost correction", () => {
it("stores catalog-derived cost when OpenAI Codex session usage has zero cost", async () => {
await initDb();
insertMessageStats([createCodexGptStats("inserted")]);
const expected = expectedCodexGptCost();
const request = getRecentRequests(1)[0];
expect(expected.total).toBeGreaterThan(0);
expect(request?.usage.cost.input).toBeCloseTo(expected.input, 8);
expect(request?.usage.cost.output).toBeCloseTo(expected.output, 8);
expect(request?.usage.cost.cacheRead).toBeCloseTo(expected.cacheRead, 8);
expect(request?.usage.cost.total).toBeCloseTo(expected.total, 8);
});
it("backfills existing zero-cost OpenAI Codex GPT rows on database init", async () => {
await initDb();
closeDb();
const database = new Database(getStatsDbPath());
database
.prepare(`
INSERT INTO messages (
session_file, entry_id, folder, model, provider, api, timestamp,
duration, ttft, stop_reason, error_message,
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, total_tokens, premium_requests,
cost_input, cost_output, cost_cache_read, cost_cache_write, cost_total
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
.run(
"/tmp/session.jsonl",
"backfilled",
"/tmp/project",
"gpt-5.4",
"openai-codex",
"openai-codex-responses",
Date.now(),
1000,
100,
"stop",
null,
1000,
500,
200,
0,
1700,
0,
0,
0,
0,
0,
0,
);
database.close();
await initDb();
const request = getRecentRequests(1)[0];
expect(request?.usage.cost.total).toBeCloseTo(expectedCodexGptCost().total, 8);
});
});