Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/eval/gaia/__tests__/pricing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,30 @@ describe("calculateCost", () => {
calculateCost("claude-sonnet-4-6", { input: 0, output: 0, total: 0 }),
).toBe(0);
});

it("prices provider-reported cache reads and writes separately", () => {
const cost = calculateCost("claude-sonnet-4-6", {
input: 100_000,
output: 20_000,
cacheRead: 300_000,
cacheWrite: 10_000,
total: 430_000,
});

expect(cost).toBeCloseTo(0.7275, 6);
});

it("uses the official long-context tier for GPT-5.5", () => {
const cost = calculateCost("gpt-5.5", {
input: 100_000,
output: 10_000,
cacheRead: 200_000,
cacheWrite: 0,
total: 310_000,
});

expect(cost).toBeCloseTo(1.65, 6);
});
});

// ─── formatCost ──────────────────────────────────────────────────────
Expand Down
127 changes: 13 additions & 114 deletions src/eval/gaia/pricing.ts
Original file line number Diff line number Diff line change
@@ -1,127 +1,26 @@
/**
* 用于 GAIA 成本核算的模型价格表。
*
* 价格单位为每百万 token 的美元数,取自各服务商在 PR 创建时的
* 官方定价页。它们一定会漂移 —— 当某服务商重新调整价格档位时,
* 需更新此表,或在汇总中将成本字段置为 `null`。
*
* 未知 / 未定价的模型会让 `calculateCost` 返回 `null`,以便
* 下游调用方能区分"无法定价"与"$0"。
*/

import { calculateModelUsageCost } from "../../shared/model-pricing";
import type { TokenUsage } from "./types";

export interface ModelPrice {
/** 每百万输入 token 的美元价(未命中缓存)。 */
inputUsdPerMTok: number;
/** 每百万输出 token 的美元价。 */
outputUsdPerMTok: number;
/** 每百万缓存读取 token 的美元价。Anthropic / OpenAI 提示缓存。 */
cacheReadUsdPerMTok?: number;
/** 每百万缓存写入 token 的美元价。 */
cacheWriteUsdPerMTok?: number;
}

/**
* 剥除服务商特定的日期 / 区域后缀,使诸如
* `claude-sonnet-4-6-20251022` 的配置映射到规范的 `claude-sonnet-4-6`
* 条目,无需为每个带日期的快照维护一行。
*/
export const normalizeModelId = (id: string): string =>
id
// `-YYYYMMDD` 后缀
.replace(/-(2\d{3}\d{4})$/i, "")
// Anthropic `-latest` 别名
.replace(/-latest$/i, "");

/**
* 静态价格表。键为规范化(剥除日期)后的模型 id。
* 当有新模型加入项目的适配器列表时,在此新增一行。
*/
export const MODEL_PRICES: Readonly<Record<string, ModelPrice>> = Object.freeze(
{
// Anthropic Claude 4.x 系列
"claude-opus-4-7": {
inputUsdPerMTok: 15,
outputUsdPerMTok: 75,
cacheReadUsdPerMTok: 1.5,
cacheWriteUsdPerMTok: 18.75,
},
"claude-sonnet-4-6": {
inputUsdPerMTok: 3,
outputUsdPerMTok: 15,
cacheReadUsdPerMTok: 0.3,
cacheWriteUsdPerMTok: 3.75,
},
"claude-sonnet-4-7": {
inputUsdPerMTok: 3,
outputUsdPerMTok: 15,
cacheReadUsdPerMTok: 0.3,
cacheWriteUsdPerMTok: 3.75,
},
"claude-haiku-4-5": {
inputUsdPerMTok: 1,
outputUsdPerMTok: 5,
cacheReadUsdPerMTok: 0.1,
cacheWriteUsdPerMTok: 1.25,
},
// OpenAI
"gpt-4o": { inputUsdPerMTok: 2.5, outputUsdPerMTok: 10 },
"gpt-4o-mini": { inputUsdPerMTok: 0.15, outputUsdPerMTok: 0.6 },
// DeepSeek
"deepseek-chat": { inputUsdPerMTok: 0.14, outputUsdPerMTok: 0.28 },
"deepseek-reasoner": { inputUsdPerMTok: 0.55, outputUsdPerMTok: 2.19 },
// MiniMax
"abab6.5s-chat": { inputUsdPerMTok: 1, outputUsdPerMTok: 1 },
"minimax-text-01": { inputUsdPerMTok: 1, outputUsdPerMTok: 1 },
// Xiaomi MiMo V2.5 系列(2026-05-27 调价公告生效)。官方报价为 CNY,
// 按 $1 ≈ ¥7 折算入表;漂移以后只需在此处调汇率:
// - V2.5-Pro:输入 ¥3 / 输出 ¥6 / 命中缓存 ¥0.025(per MTok)
// - V2.5: 输入 ¥1 / 输出 ¥2 / 命中缓存 ¥0.02 (per MTok)
// V2.1-TTS 限时免费、V2 系列官方建议迁移到 V2.5,均未录入。
// calculateCost() 只用 input/output 两个字段;cache 字段记录在此供
// 未来按 cache 命中拆账时复用。MiMo 不区分 cache write(首次写入按
// 普通 input 计费),故 cacheWriteUsdPerMTok 留空。
"mimo-v2.5-pro": {
inputUsdPerMTok: 0.43,
outputUsdPerMTok: 0.86,
cacheReadUsdPerMTok: 0.0036,
},
"mimo-v2.5": {
inputUsdPerMTok: 0.14,
outputUsdPerMTok: 0.29,
cacheReadUsdPerMTok: 0.0029,
},
},
);

/**
* 返回 `model` 对应的规范价格行,无对应条目时返回 `null`。
* 适用于希望提示"该模型未定价 —— 请更新价格表"告警的工具。
*/
export const getModelPrice = (model: string): ModelPrice | null =>
MODEL_PRICES[normalizeModelId(model)] ?? null;
export {
getModelPrice,
MODEL_PRICES,
type ModelPrice,
normalizeModelId,
} from "../../shared/model-pricing";

/**
* 计算单次 agent 运行的美元成本,使用基础的输入/输出定价
* (不区分缓存差额 —— 调用方在 `TokenUsage` 中并未拆分出这些)。
*
* 当模型不在 `MODEL_PRICES` 中时返回 `null`,使 runner 可以记录
* "未定价",而非错误地按 $0 计算。
*/
export const calculateCost = (
model: string,
usage: TokenUsage | undefined,
): number | null => {
if (!usage) return null;
const price = getModelPrice(model);
if (!price) return null;
const inputCost = (usage.input * price.inputUsdPerMTok) / 1_000_000;
const outputCost = (usage.output * price.outputUsdPerMTok) / 1_000_000;
return inputCost + outputCost;
return calculateModelUsageCost(model, {
inputTokens: usage.input,
outputTokens: usage.output,
cacheReadTokens: usage.cacheRead,
cacheWriteTokens: usage.cacheWrite,
});
};

/** 将成本数值格式化为便于人读的形式。将 `null` 视为 "—"。 */
export const formatCost = (usd: number | null | undefined): string => {
if (usd === null || usd === undefined) return "—";
if (usd === 0) return "$0.00";
Expand Down
2 changes: 2 additions & 0 deletions src/eval/gaia/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ export interface TokenUsage {
input: number;
output: number;
total: number;
cacheRead?: number;
cacheWrite?: number;
}

export interface QuestionResult {
Expand Down
4 changes: 2 additions & 2 deletions src/main/core/__tests__/agent-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,8 +277,8 @@ describe("AgentLoop", () => {

expect(end.totalUsage?.inputTokens).toBe(1_000_000);
expect(end.contextUsage).toEqual({
latestStepContextTokens: 48_000,
maxStepContextTokens: 50_000,
latestStepContextTokens: 49_000,
maxStepContextTokens: 51_000,
});
});

Expand Down
20 changes: 20 additions & 0 deletions src/main/core/agent/__tests__/agent-loop-reasoning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,24 @@ describe("mapUsage — reasoning token extraction", () => {
});
expect(u?.cacheReadTokens).toBe(64);
});

it("separates provider cache usage from uncached input tokens", () => {
const u = mapUsage({
inputTokens: 100,
outputTokens: 10,
inputTokenDetails: {
noCacheTokens: 20,
cacheReadTokens: 64,
cacheWriteTokens: 16,
},
});

expect(u).toMatchObject({
inputTokens: 20,
outputTokens: 10,
totalTokens: 110,
cacheReadTokens: 64,
cacheWriteTokens: 16,
});
});
});
34 changes: 24 additions & 10 deletions src/main/core/agent/agent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -728,16 +728,16 @@ export class AgentLoop {
}
case "finish-step": {
const stepUsage = mapUsage(part.usage);
const stepInputTokens = stepUsage?.inputTokens;
const stepContextTokens = stepUsage?.totalTokens;
if (
typeof stepInputTokens === "number" &&
Number.isFinite(stepInputTokens) &&
stepInputTokens >= 0
typeof stepContextTokens === "number" &&
Number.isFinite(stepContextTokens) &&
stepContextTokens >= 0
) {
latestStepContextTokens = stepInputTokens;
latestStepContextTokens = stepContextTokens;
maxStepContextTokens = Math.max(
maxStepContextTokens,
stepInputTokens,
stepContextTokens,
);
}
if (messageOpen) {
Expand Down Expand Up @@ -1039,7 +1039,9 @@ interface RawUsage {
reasoningTokens?: number | null;
/** AI SDK v7 的输入 token 细分。 */
inputTokenDetails?: {
noCacheTokens?: number | null;
cacheReadTokens?: number | null;
cacheWriteTokens?: number | null;
} | null;
/** AI SDK v6+ 的嵌套形式(优先于已废弃的扁平字段)。 */
outputTokenDetails?: {
Expand All @@ -1051,11 +1053,23 @@ interface RawUsage {
export function mapUsage(raw: unknown): TokenUsage | undefined {
if (!raw || typeof raw !== "object") return undefined;
const u = raw as RawUsage;
const input = u.inputTokens ?? null;
const cacheRead =
u.inputTokenDetails?.cacheReadTokens ?? u.cachedInputTokens ?? null;
const cacheWrite = u.inputTokenDetails?.cacheWriteTokens ?? null;
const input =
u.inputTokenDetails?.noCacheTokens ??
(u.inputTokens != null && (cacheRead != null || cacheWrite != null)
? Math.max(0, u.inputTokens - (cacheRead ?? 0) - (cacheWrite ?? 0))
: (u.inputTokens ?? null));
const output = u.outputTokens ?? null;
const total =
u.totalTokens ??
(input !== null || output !== null ? (input ?? 0) + (output ?? 0) : null);
(input !== null ||
output !== null ||
cacheRead !== null ||
cacheWrite !== null
? (input ?? 0) + (output ?? 0) + (cacheRead ?? 0) + (cacheWrite ?? 0)
: null);
// 优先使用嵌套的 `outputTokenDetails.reasoningTokens`;对较旧的 SDK
// 响应则回退到已废弃的扁平 `reasoningTokens` 字段。
const reasoning =
Expand All @@ -1064,8 +1078,8 @@ export function mapUsage(raw: unknown): TokenUsage | undefined {
inputTokens: input,
outputTokens: output,
totalTokens: total,
cacheReadTokens:
u.inputTokenDetails?.cacheReadTokens ?? u.cachedInputTokens ?? null,
cacheReadTokens: cacheRead,
cacheWriteTokens: cacheWrite,
reasoningTokens: reasoning,
};
}
Expand Down
3 changes: 3 additions & 0 deletions src/main/core/session/message-parts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,9 @@ export interface UsagePart {
inputTokens: number | null;
outputTokens: number | null;
totalTokens: number | null;
cacheReadTokens?: number | null;
cacheWriteTokens?: number | null;
costUsd?: number | null;
modelId: string | null;
provider: string | null;
latestStepContextTokens?: number | null;
Expand Down
9 changes: 9 additions & 0 deletions src/main/db/__tests__/automation-runs-migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,15 @@ describe("automation run migrations", () => {
expect(sqliteMock.state.execCalls.join("\n")).toContain(
"ALTER TABLE tasks ADD COLUMN updated_at TEXT",
);
expect(sqliteMock.state.execCalls.join("\n")).toContain(
"ALTER TABLE tasks ADD COLUMN cache_read_tokens INTEGER",
);
expect(sqliteMock.state.execCalls.join("\n")).toContain(
"ALTER TABLE tasks ADD COLUMN cache_write_tokens INTEGER",
);
expect(sqliteMock.state.execCalls.join("\n")).toContain(
"ALTER TABLE tasks ADD COLUMN cost_usd REAL",
);
});

it("adds context checkpoint columns to existing task summaries", async () => {
Expand Down
27 changes: 26 additions & 1 deletion src/main/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,15 @@ export const initDatabase = async () => {
completed_at TEXT,
session_id TEXT,
assistant_message_id TEXT,
updated_at TEXT
updated_at TEXT,
input_tokens INTEGER,
output_tokens INTEGER,
total_tokens INTEGER,
cache_read_tokens INTEGER,
cache_write_tokens INTEGER,
cost_usd REAL,
model_id TEXT,
provider TEXT
);
CREATE TABLE IF NOT EXISTS task_trace_events (
id TEXT PRIMARY KEY,
Expand Down Expand Up @@ -567,6 +575,15 @@ export const initDatabase = async () => {
if (!taskColumnNames.has("updated_at")) {
sqlite.exec("ALTER TABLE tasks ADD COLUMN updated_at TEXT");
}
if (!taskColumnNames.has("cache_read_tokens")) {
sqlite.exec("ALTER TABLE tasks ADD COLUMN cache_read_tokens INTEGER");
}
if (!taskColumnNames.has("cache_write_tokens")) {
sqlite.exec("ALTER TABLE tasks ADD COLUMN cache_write_tokens INTEGER");
}
if (!taskColumnNames.has("cost_usd")) {
sqlite.exec("ALTER TABLE tasks ADD COLUMN cost_usd REAL");
}

// 迁移:task_trace_events 表缺失时创建(旧版数据库)
sqlite.exec(`
Expand Down Expand Up @@ -890,6 +907,9 @@ interface Task {
inputTokens?: number | null;
outputTokens?: number | null;
totalTokens?: number | null;
cacheReadTokens?: number | null;
cacheWriteTokens?: number | null;
costUsd?: number | null;
modelId?: string | null;
provider?: string | null;
}
Expand Down Expand Up @@ -1115,6 +1135,11 @@ export const updateTask = (id: string, updates: Partial<Task>) => {
mapped.outputTokens = updates.outputTokens;
if (updates.totalTokens !== undefined)
mapped.totalTokens = updates.totalTokens;
if (updates.cacheReadTokens !== undefined)
mapped.cacheReadTokens = updates.cacheReadTokens;
if (updates.cacheWriteTokens !== undefined)
mapped.cacheWriteTokens = updates.cacheWriteTokens;
if (updates.costUsd !== undefined) mapped.costUsd = updates.costUsd;
if (updates.modelId !== undefined) mapped.modelId = updates.modelId;
if (updates.provider !== undefined) mapped.provider = updates.provider;
if (updates.updatedAt !== undefined) {
Expand Down
3 changes: 3 additions & 0 deletions src/main/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ export const tasks = sqliteTable("tasks", {
inputTokens: integer("input_tokens"),
outputTokens: integer("output_tokens"),
totalTokens: integer("total_tokens"),
cacheReadTokens: integer("cache_read_tokens"),
cacheWriteTokens: integer("cache_write_tokens"),
costUsd: real("cost_usd"),
modelId: text("model_id"),
provider: text("provider"),
});
Expand Down
Loading
Loading