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
108 changes: 94 additions & 14 deletions electron/codex.js
Original file line number Diff line number Diff line change
Expand Up @@ -328,28 +328,107 @@ function extractCommandItem(ev) {
return null;
}

/**
* token_count.info from exec JSONL, app-server `msg`, or session `payload`.
* @param {object} ev
* @returns {object | null}
*/
function tokenCountInfo(ev) {
if (!ev || typeof ev !== "object") return null;
const type = String(ev.type || "");
const payload = ev.payload && typeof ev.payload === "object" ? ev.payload : null;
const msg = ev.msg && typeof ev.msg === "object" ? ev.msg : null;
if (
ev.info &&
typeof ev.info === "object" &&
(type === "token_count" ||
type === "token.count" ||
ev.info.last_token_usage ||
ev.info.total_token_usage ||
ev.info.model_context_window != null)
) {
return ev.info;
}
if (payload && (payload.type === "token_count" || payload.type === "token.count")) {
return payload.info && typeof payload.info === "object" ? payload.info : null;
}
if (msg && (msg.type === "token_count" || msg.type === "token.count")) {
return msg.info && typeof msg.info === "object" ? msg.info : null;
}
return null;
}

/**
* Codex input_tokens already includes cached_input_tokens (session logs:
* input + output === total_tokens). Prefer total_tokens when present.
* @param {object | null | undefined} usage
* @returns {number | undefined}
*/
function codexPromptTokens(usage) {
if (!usage || typeof usage !== "object") return undefined;
const total = Number(usage.total_tokens);
if (Number.isFinite(total) && total > 0) return total;
const input =
Number(
usage.input_tokens ??
usage.inputTokens ??
usage.prompt_tokens ??
usage.total_input_tokens ??
0,
) || 0;
const output =
Number(
usage.output_tokens ??
usage.outputTokens ??
usage.completion_tokens ??
usage.total_output_tokens ??
0,
) || 0;
return input + output > 0 ? input + output : undefined;
}

/**
* Extract token usage from completion / token_count events.
* token_count.last_token_usage is the per-request delta; total_token_usage
* is session-cumulative — never treat the latter as a turn delta (#317).
* @param {object} ev
* @returns {{ inputTokens: number, outputTokens: number, model: string | null, costUsd: number } | null}
* @returns {{ inputTokens: number, outputTokens: number, model: string | null, costUsd?: number, contextTokens?: number, contextWindow?: number, snapshot?: boolean } | null}
*/
function extractUsage(ev) {
if (!ev || typeof ev !== "object") return null;
const type = String(ev.type || "");

const info = tokenCountInfo(ev);
if (info) {
const last =
info.last_token_usage && typeof info.last_token_usage === "object"
? info.last_token_usage
: null;
const total =
info.total_token_usage && typeof info.total_token_usage === "object"
? info.total_token_usage
: null;
const src = last || total;
if (!src && info.model_context_window == null) return null;
const inputTokens = Number(src?.input_tokens) || 0;
const outputTokens = Number(src?.output_tokens) || 0;
const window = Number(info.model_context_window);
/** @type {{ inputTokens: number, outputTokens: number, model: string | null, contextTokens?: number, contextWindow?: number, snapshot?: boolean }} */
const counted = { inputTokens, outputTokens, model: null };
const ctx = codexPromptTokens(src);
if (ctx != null) counted.contextTokens = ctx;
if (Number.isFinite(window) && window > 0) counted.contextWindow = window;
// No last_token_usage: totals are a snapshot, not a delta.
if (!last && total) counted.snapshot = true;
if (!src && counted.contextWindow == null) return null;
return counted;
}

let usage = null;
if (ev.usage && typeof ev.usage === "object") {
usage = ev.usage;
} else if (type === "token_count" || type === "token.count") {
usage = ev;
} else if (ev.msg && typeof ev.msg === "object") {
if (ev.msg.type === "token_count" || ev.msg.usage) {
usage =
ev.msg.usage ||
ev.msg.info?.total_token_usage ||
ev.msg.info ||
ev.msg;
}
} else if (ev.msg && typeof ev.msg === "object" && ev.msg.usage) {
usage = ev.msg.usage;
}

if (
Expand Down Expand Up @@ -388,8 +467,6 @@ function extractUsage(ev) {
usage.outputTokens != null ||
usage.prompt_tokens != null ||
usage.completion_tokens != null ||
type === "token_count" ||
type === "token.count" ||
type === "turn.completed" ||
type === "turn_completed";

Expand All @@ -410,11 +487,14 @@ function extractUsage(ev) {
ev.cost_usd;
const costUsd = costRaw != null ? Number(costRaw) || 0 : 0;

/** @type {{ inputTokens: number, outputTokens: number, model: string | null, costUsd?: number }} */
/** @type {{ inputTokens: number, outputTokens: number, model: string | null, costUsd?: number, contextTokens?: number }} */
const out = { inputTokens, outputTokens, model };
if (costRaw != null) {
out.costUsd = costUsd;
}
// exec --json turn.completed: input already includes cached_input_tokens.
const ctx = codexPromptTokens(usage);
if (ctx != null) out.contextTokens = ctx;
return out;
}

Expand Down
4 changes: 4 additions & 0 deletions electron/providers.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,25 +90,29 @@ const PROVIDERS = [
label: "Fable",
description: "Fast everyday coding with strong defaults",
vendor: "Anthropic",
contextTokens: 1_000_000,
},
{
id: "claude-opus-5",
label: "Opus",
description: "Best for hard multi-step work",
vendor: "Anthropic",
recommended: true,
contextTokens: 1_000_000,
},
{
id: "claude-sonnet-5",
label: "Sonnet",
description: "Balanced quality and speed",
vendor: "Anthropic",
contextTokens: 1_000_000,
},
{
id: "claude-haiku-4-5",
label: "Haiku",
description: "Cheapest and fastest replies",
vendor: "Anthropic",
contextTokens: 200_000,
},
],
// claude --help / live warning: low, medium, high, xhigh, max
Expand Down
108 changes: 83 additions & 25 deletions electron/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,46 @@ function shortError(text) {
return two.length > 300 ? two.slice(0, 300) : two;
}

/**
* Full prompt size for the context ring. Claude's input_tokens excludes
* cache_read/cache_creation; omitting those reads as ~0% then jumps (#317).
* Returns undefined when the event does not report the cache fields — an
* inaccurate number is worse than none.
* @param {object | null | undefined} usage
* @returns {number | undefined}
*/
function claudeContextTokens(usage) {
if (!usage || typeof usage !== "object") return undefined;
if (
usage.cache_read_input_tokens == null &&
usage.cache_creation_input_tokens == null
) {
return undefined;
}
const total =
(Number(usage.input_tokens) || 0) +
(Number(usage.cache_read_input_tokens) || 0) +
(Number(usage.cache_creation_input_tokens) || 0) +
(Number(usage.output_tokens) || 0);
return total > 0 ? total : undefined;
}

/**
* Carry forward measured context fields; never invent a 0.
* @param {object} next
* @param {object} prev
* @param {number | undefined} contextTokens
* @param {number | undefined} contextWindow
*/
function assignContextUsage(next, prev, contextTokens, contextWindow) {
const ctx = contextTokens != null ? Number(contextTokens) : NaN;
if (Number.isFinite(ctx) && ctx > 0) next.contextTokens = ctx;
else if (prev.contextTokens != null) next.contextTokens = prev.contextTokens;
const win = contextWindow != null ? Number(contextWindow) : NaN;
if (Number.isFinite(win) && win > 0) next.contextWindow = win;
else if (prev.contextWindow != null) next.contextWindow = prev.contextWindow;
}

/**
* A kept-alive/resumed Claude CLI can emit a result that is not the answer to
* the turn we just sent: settling a leftover background-task notification or
Expand Down Expand Up @@ -2485,7 +2525,6 @@ function createRunner(opts) {
outputTokens: 0,
costUsd: 0,
turns: 0,
contextTokens: 0,
};
const usage = ev.usage || {};
const turnIn = Number(usage.input_tokens) || 0;
Expand All @@ -2499,16 +2538,17 @@ function createRunner(opts) {
const costUsd = prev.costUsd + costDelta;
const model =
capturedModel || prev.model || null;
store.setUsage(threadId, {
const nextUsage = {
model,
inputTokens,
outputTokens,
costUsd,
turns: prev.turns + 1,
// Last turn's in+out approximates context-window fill.
contextTokens:
turnIn + turnOut > 0 ? turnIn + turnOut : prev.contextTokens || 0,
});
};
// inputTokens stay billable (no cache). contextTokens is the full
// prompt or stays unset when the event omitted cache fields (#317).
assignContextUsage(nextUsage, prev, claudeContextTokens(usage));
store.setUsage(threadId, nextUsage);
if (costDelta > 0) {
store.recordSpend(costDelta);
}
Expand Down Expand Up @@ -2936,28 +2976,46 @@ function createRunner(opts) {
outputTokens: 0,
costUsd: 0,
turns: 0,
contextTokens: 0,
};
const costDelta = Number(usageInfo.costUsd) || 0;
const inDelta = Number(usageInfo.inputTokens) || 0;
const outDelta = Number(usageInfo.outputTokens) || 0;
runUsage.tokensIn += inDelta;
runUsage.tokensOut += outDelta;
runUsage.costUsd += costDelta;
store.setUsage(threadId, {
// token_count.total_token_usage is session-cumulative. Replacing
// rather than adding is what stops the ring from double-counting (#317).
const snapshot = Boolean(usageInfo.snapshot);
if (snapshot) {
runUsage.tokensIn = inDelta;
runUsage.tokensOut = outDelta;
runUsage.costUsd += costDelta;
} else {
runUsage.tokensIn += inDelta;
runUsage.tokensOut += outDelta;
runUsage.costUsd += costDelta;
}
const nextUsage = {
model: usageInfo.model || prev.model || thread.model || null,
inputTokens: prev.inputTokens + inDelta,
outputTokens: prev.outputTokens + outDelta,
inputTokens: snapshot ? inDelta : prev.inputTokens + inDelta,
outputTokens: snapshot ? outDelta : prev.outputTokens + outDelta,
costUsd: prev.costUsd + costDelta,
turns: prev.turns + 1,
// Last turn's in+out approximates context-window fill.
contextTokens:
inDelta + outDelta > 0 ? inDelta + outDelta : prev.contextTokens || 0,
});
turns: snapshot && prev.turns > 0 ? prev.turns : prev.turns + 1,
};
assignContextUsage(
nextUsage,
prev,
usageInfo.contextTokens,
usageInfo.contextWindow,
);
store.setUsage(threadId, nextUsage);
if (costDelta > 0) {
store.recordSpend(costDelta);
}
store.recordUsage({ provider: thread.provider, model: usageInfo.model || prev.model || thread.model || null, costUsd: costDelta, inputTokens: inDelta, outputTokens: outDelta });
const billedIn = snapshot
? Math.max(0, inDelta - prev.inputTokens)
: inDelta;
const billedOut = snapshot
? Math.max(0, outDelta - prev.outputTokens)
: outDelta;
store.recordUsage({ provider: thread.provider, model: usageInfo.model || prev.model || thread.model || null, costUsd: costDelta, inputTokens: billedIn, outputTokens: billedOut });
sawTerminalUsage = true;
}

Expand Down Expand Up @@ -3338,24 +3396,24 @@ function createRunner(opts) {
outputTokens: 0,
costUsd: 0,
turns: 0,
contextTokens: 0,
};
const costDelta = Number(usageInfo.costUsd) || 0;
const inDelta = Number(usageInfo.inputTokens) || 0;
const outDelta = Number(usageInfo.outputTokens) || 0;
runUsage.tokensIn += inDelta;
runUsage.tokensOut += outDelta;
runUsage.costUsd += costDelta;
store.setUsage(threadId, {
const nextUsage = {
model: prev.model || thread.model || null,
inputTokens: prev.inputTokens + inDelta,
outputTokens: prev.outputTokens + outDelta,
costUsd: prev.costUsd + costDelta,
turns: prev.turns + 1,
// Last turn's in+out approximates context-window fill.
contextTokens:
inDelta + outDelta > 0 ? inDelta + outDelta : prev.contextTokens || 0,
});
};
// Kimi reports billable in/out at best, never a full prompt. Leave
// contextTokens unset rather than write an undefendable number (#317).
assignContextUsage(nextUsage, prev, undefined, undefined);
store.setUsage(threadId, nextUsage);
if (costDelta > 0) {
store.recordSpend(costDelta);
}
Expand Down
Loading
Loading