@vibe-cafe/vibe-usage 0.10.29 → 0.10.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/cline-roots.js +46 -17
- package/src/daemon-service.js +3 -0
- package/src/index.js +1 -1
- package/src/parsers/cline-sdk.js +106 -0
- package/src/parsers/cline.js +9 -4
- package/src/summary.js +19 -1
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ npx @vibe-cafe/vibe-usage init # Re-run setup via browser login (also ho
|
|
|
38
38
|
npx @vibe-cafe/vibe-usage init --manual-key <vbu_...> # Skip browser, use pre-issued key (CI/headless)
|
|
39
39
|
npx @vibe-cafe/vibe-usage sync # Manual sync
|
|
40
40
|
npx @vibe-cafe/vibe-usage sync --extra-codex-home /path/to/.codex # Add another Codex Home for this run only
|
|
41
|
-
npx @vibe-cafe/vibe-usage summary # Print last 7 days as markdown (cost / tokens / by model / by project)
|
|
41
|
+
npx @vibe-cafe/vibe-usage summary # Print last 7 days as markdown (cost / tokens / by tool / by model / by project)
|
|
42
42
|
npx @vibe-cafe/vibe-usage summary --days N # Same, over the last N days (1-90)
|
|
43
43
|
npx @vibe-cafe/vibe-usage daemon # Continuous sync (every 30m, foreground)
|
|
44
44
|
npx @vibe-cafe/vibe-usage daemon install # Install background service (systemd/launchd/Task Scheduler)
|
|
@@ -84,7 +84,7 @@ npx @vibe-cafe/vibe-usage help --all # Full help (plain `help` shows the short
|
|
|
84
84
|
| DeepSeek Harness | `$DSH_HOME/sessions/` (default `~/.dsh`, fixture/relocation override: `VIBE_USAGE_DSH_SESSIONS`). Reads V0–V3 logs, including `session.v3.jsonl.zstd` from DSH `0.1.5-alpha.2`, with multi-frame Zstandard support (Node ≥ 22.15 built-in, `zstd` CLI fallback) and plain JSONL support. Each session uses its highest `session[.vN].jsonl[.zstd]` generation once, so frozen pre-migration logs are not double-counted. Usage comes from `assistant/message`: cache writes join uncached input, cache reads remain separate, and reasoning is split out of inclusive output. Fork history uses V0/V1 `seedLength` or V2/V3's last `session/end-seed` tagged `inherited: true`, and is skipped only when the parent copy confirms it; missing parents retain the sole local history. Unknown versions warn and protect sync state. |
|
|
85
85
|
| Hermes (CLI / Desktop) | `<home>/state.db` + `<home>/profiles/<name>/state.db` (SQLite, multi-profile). Home: `$HERMES_HOME`, otherwise `~/.hermes` on macOS/Linux or `%LOCALAPPDATA%\hermes` on Windows (falls back to an existing `~/.hermes` only when the Windows native root is absent). Cache writes join input; reasoning is separated from inclusive output. Usage is currently a cumulative session total attributed to session start: a session spanning several days does **not** yet provide an accurate daily breakdown. |
|
|
86
86
|
| Kiro | Kiro CLI native event streams `~/.kiro/sessions/cli/*.jsonl` (estimated tokens from message text: input = prompt + tool results, output = reply + tool calls, reasoning = thinking, cacheRead = re-sent context; thinking-block signatures excluded). Falls back to `~/Library/Application Support/kiro-cli/data.sqlite3` / `~/.local/share/kiro-cli/data.sqlite3` + optional `~/.kiro_sessions/*.json` archives, then IDE `q-client.log` whole-credit deltas as `kiro-credits` (floored cumulative diff — the server stores token counts as bigint); legacy IDE `dev_data/devdata.sqlite` token telemetry is opt-in with `VIBE_USAGE_KIRO_LEGACY_TOKENS=1` |
|
|
87
|
-
| Cline |
|
|
87
|
+
| Cline | Current CLI/SDK `~/.cline/data/sessions/*/*.messages.json` plus legacy `~/.cline/{,data/}state/taskHistory.json` and editor extension stores. Honors `CLINE_DIR`, `CLINE_DATA_DIR`, and `CLINE_SESSION_DATA_DIR`; copied history is deduplicated |
|
|
88
88
|
| Roo Code | `<host>/User/globalStorage/rooveterinaryinc.roo-cline/{tasks/_index.json,tasks/<id>/{history_item,ui_messages}.json}` (walks all VSCode-fork hosts) |
|
|
89
89
|
| Trae CLI | macOS: `~/Library/Caches/trae-cli/sessions/`; Windows: `%LOCALAPPDATA%/trae-cli/cache/sessions/`; Linux: `~/.cache/trae-cli/sessions/` (CLI telemetry only; Trae IDE/Trae Work chats are not supported). Token usage is summed per unique LLM call (`model.stream.eino`, plus `model.generate` failovers); nested duplicate spans that share a session `traceID` are not max-merged. `traces.jsonl` / `events.jsonl` are streamed line-by-line so a multi-hundred-MB events file cannot hit Node's string-length limit. |
|
|
90
90
|
| Antigravity | Scans App 2.0 `~/.gemini/antigravity/conversations/`, `agy` CLI `~/.gemini/antigravity-cli/conversations/`, and standalone IDE `~/.gemini/antigravity-ide/conversations/`. `.db` stores, including the same paths below explicitly added alternate Homes, are parsed offline (tokens, model, project, sessions). When Gemini blobs omit `chatStartMetadata.createdAt` or `modelDisplayName`, timestamps fall back to `steps.metadata` and model names to `responseModel`. `.pb` history in the default stores requires the corresponding App/IDE language server to be running; when several servers are open, the parser tries the others for unreadable conversations. Unavailable legacy history produces a warning and preserves prior sync state. |
|
package/package.json
CHANGED
package/src/cline-roots.js
CHANGED
|
@@ -1,18 +1,10 @@
|
|
|
1
|
-
import { statSync } from 'node:fs';
|
|
1
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
2
2
|
import { delimiter, join } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
|
|
5
5
|
const EXTENSION_ID = 'saoudrizwan.claude-dev';
|
|
6
6
|
const HOSTS = ['Code', 'Cursor', 'Windsurf', 'VSCodium', 'Code - Insiders', 'Trae', 'Trae CN'];
|
|
7
7
|
|
|
8
|
-
function hasTaskHistory(root) {
|
|
9
|
-
try {
|
|
10
|
-
return statSync(join(root, 'state', 'taskHistory.json')).isFile();
|
|
11
|
-
} catch {
|
|
12
|
-
return false;
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
|
|
16
8
|
function hostRoots() {
|
|
17
9
|
const out = [];
|
|
18
10
|
if (process.platform === 'darwin') {
|
|
@@ -28,13 +20,50 @@ function hostRoots() {
|
|
|
28
20
|
return out;
|
|
29
21
|
}
|
|
30
22
|
|
|
31
|
-
|
|
23
|
+
/** Both legacy stores and the shared Cline 3.x CLI/extension SDK store. */
|
|
24
|
+
export function findClineStores({ onWarning = () => {} } = {}) {
|
|
25
|
+
function isPath(path, directory = false) {
|
|
26
|
+
try {
|
|
27
|
+
const stat = statSync(path);
|
|
28
|
+
return directory ? stat.isDirectory() : stat.isFile();
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') {
|
|
31
|
+
onWarning(`cline: 无法读取数据目录 ${path}: ${err.message}`);
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function unique(paths) {
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
return paths.filter(path => {
|
|
39
|
+
let key;
|
|
40
|
+
try { key = realpathSync(path); } catch { key = path; }
|
|
41
|
+
if (seen.has(key)) return false;
|
|
42
|
+
seen.add(key);
|
|
43
|
+
return true;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
32
46
|
const override = process.env.VIBE_USAGE_CLINE_DIRS?.trim();
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
47
|
+
const home = join(homedir(), '.cline');
|
|
48
|
+
const configuredHome = process.env.CLINE_DIR?.trim() || home;
|
|
49
|
+
const dataDir = process.env.CLINE_DATA_DIR?.trim() || join(configuredHome, 'data');
|
|
50
|
+
const roots = override
|
|
51
|
+
? override.split(delimiter).map(value => value.trim()).filter(Boolean)
|
|
52
|
+
: [home, configuredHome, dataDir,
|
|
53
|
+
...hostRoots().map(root => join(root, 'User', 'globalStorage', EXTENSION_ID))];
|
|
54
|
+
// Accept either a Cline home or its data directory. Keep the old standalone
|
|
55
|
+
// and editor stores so upgrading the runtime does not discard old history.
|
|
56
|
+
const dataRoots = unique(roots.flatMap(root => [root, join(root, 'data')]));
|
|
57
|
+
const legacyRoots = dataRoots.filter(root => isPath(join(root, 'state', 'taskHistory.json')));
|
|
58
|
+
const sessionDirs = override
|
|
59
|
+
? dataRoots.map(root => join(root, 'sessions'))
|
|
60
|
+
: [...dataRoots.map(root => join(root, 'sessions')),
|
|
61
|
+
process.env.CLINE_SESSION_DATA_DIR?.trim()].filter(Boolean);
|
|
62
|
+
const sdkSessionDirs = unique(sessionDirs).filter(dir => isPath(dir, true));
|
|
63
|
+
return { legacyRoots: unique(legacyRoots), sdkSessionDirs };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function findClineDataDirs() {
|
|
67
|
+
const { legacyRoots, sdkSessionDirs } = findClineStores();
|
|
68
|
+
return [...legacyRoots, ...sdkSessionDirs];
|
|
40
69
|
}
|
package/src/daemon-service.js
CHANGED
|
@@ -115,6 +115,9 @@ function escapeXml(value) {
|
|
|
115
115
|
// launchd/systemd unit that inherits nothing, so anything the parsers read for
|
|
116
116
|
// discovery has to be captured into the unit at install time.
|
|
117
117
|
const PRESERVED_SERVICE_ENV = [
|
|
118
|
+
'CLINE_DIR',
|
|
119
|
+
'CLINE_DATA_DIR',
|
|
120
|
+
'CLINE_SESSION_DATA_DIR',
|
|
118
121
|
'COLA_DATA_DIR',
|
|
119
122
|
'HERMES_HOME',
|
|
120
123
|
'MCODE_HOME',
|
package/src/index.js
CHANGED
|
@@ -252,7 +252,7 @@ const FULL_HELP = `
|
|
|
252
252
|
${BARE} init --manual-key <vbu_...> Skip browser, use a pre-issued key (CI/headless)
|
|
253
253
|
${BARE} sync Manually sync usage data
|
|
254
254
|
${BARE} sync --extra-codex-home <path> Use another Codex Home for this run
|
|
255
|
-
${BARE} summary Print last 7 days as markdown (cost/tokens/model/project)
|
|
255
|
+
${BARE} summary Print last 7 days as markdown (cost/tokens/tool/model/project)
|
|
256
256
|
${BARE} summary --days N Same, but over the last N days (1-90)
|
|
257
257
|
${BARE} daemon Continuous sync (every 30m, foreground)
|
|
258
258
|
${BARE} daemon install Install background service (systemd/launchd/Task Scheduler)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { projectFromCwd, toCount } from './fs-utils.js';
|
|
4
|
+
|
|
5
|
+
// Verified with the shipped cline 3.0.61 / @cline/core 0.0.82. SQLite is only
|
|
6
|
+
// the session index: per-call accounting lives in version-1 messages artifacts.
|
|
7
|
+
// Read canonical artifacts, not DB prompt/metadata columns or provider settings.
|
|
8
|
+
export function readClineSdk(sessionDirs, onWarning) {
|
|
9
|
+
const copies = [];
|
|
10
|
+
for (const dir of sessionDirs) {
|
|
11
|
+
let children;
|
|
12
|
+
try { children = readdirSync(dir, { withFileTypes: true }); }
|
|
13
|
+
catch (err) { onWarning(`cline: 无法读取会话目录 ${dir}: ${err.message}`); continue; }
|
|
14
|
+
for (const child of children) {
|
|
15
|
+
if (!child.isDirectory()) continue;
|
|
16
|
+
const sessionDir = join(dir, child.name);
|
|
17
|
+
let files, manifest;
|
|
18
|
+
try {
|
|
19
|
+
files = readdirSync(sessionDir, { withFileTypes: true })
|
|
20
|
+
.filter(file => file.isFile() && file.name.endsWith('.messages.json'));
|
|
21
|
+
if (!files.length) continue;
|
|
22
|
+
manifest = JSON.parse(readFileSync(join(sessionDir, `${child.name}.json`), 'utf8'));
|
|
23
|
+
if (manifest?.version !== 1 || manifest.session_id !== child.name) {
|
|
24
|
+
throw new Error('unsupported or inconsistent Cline session manifest');
|
|
25
|
+
}
|
|
26
|
+
} catch (err) {
|
|
27
|
+
onWarning(`cline: 无法读取会话目录 ${sessionDir}: ${err.message}`);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
for (const file of files) {
|
|
31
|
+
const messagesPath = join(sessionDir, file.name);
|
|
32
|
+
try {
|
|
33
|
+
const payload = JSON.parse(readFileSync(messagesPath, 'utf8'));
|
|
34
|
+
if (payload?.version !== 1 || !Array.isArray(payload.messages)
|
|
35
|
+
|| typeof payload.sessionId !== 'string'
|
|
36
|
+
|| (payload.sessionId !== child.name && payload.origin?.parentThreadId !== child.name)) {
|
|
37
|
+
throw new Error('unsupported or inconsistent Cline session artifact');
|
|
38
|
+
}
|
|
39
|
+
const project = projectFromCwd(manifest.workspace_root || manifest.cwd);
|
|
40
|
+
// Reduce immediately to accounting/timing metadata. No prompt, response,
|
|
41
|
+
// system prompt, tool arguments, credentials, or stored costs survive.
|
|
42
|
+
const messages = payload.messages.flatMap(message => {
|
|
43
|
+
if (!message || !['user', 'assistant'].includes(message.role)) return [];
|
|
44
|
+
if (message.role === 'user' && (payload.agent !== 'lead'
|
|
45
|
+
|| message.metadata?.kind || message.metadata?.userRunSpan === 0
|
|
46
|
+
|| ['system', 'status', 'error', 'tool'].includes(message.metadata?.displayRole)
|
|
47
|
+
|| (Array.isArray(message.content) && message.content.some(block =>
|
|
48
|
+
block?.type === 'tool_result' || block?.type === 'tool-result')))) return [];
|
|
49
|
+
if (typeof message.ts !== 'number' || !Number.isFinite(message.ts)
|
|
50
|
+
|| !Number.isFinite(new Date(message.ts).getTime())) return [];
|
|
51
|
+
// Legacy migration inserts a cumulative metric on an old assistant
|
|
52
|
+
// without a timestamp. Never re-date that summary to the migration.
|
|
53
|
+
const metric = message.role === 'assistant' ? message.metrics : null;
|
|
54
|
+
const input = toCount(metric?.inputTokens);
|
|
55
|
+
const cachedInputTokens = Math.min(input, toCount(metric?.cacheReadTokens));
|
|
56
|
+
return [{
|
|
57
|
+
id: typeof message.id === 'string' ? message.id : null,
|
|
58
|
+
role: message.role,
|
|
59
|
+
timestamp: message.ts,
|
|
60
|
+
model: message.modelInfo?.id || manifest.model || 'cline-unknown',
|
|
61
|
+
inputTokens: input - cachedInputTokens,
|
|
62
|
+
outputTokens: toCount(metric?.outputTokens),
|
|
63
|
+
cachedInputTokens,
|
|
64
|
+
}];
|
|
65
|
+
});
|
|
66
|
+
copies.push({ sessionId: child.name, artifactId: payload.sessionId, project, messages,
|
|
67
|
+
// Restored/forked artifacts retain message ids/timestamps; attribute
|
|
68
|
+
// shared history to the earliest original session deterministically.
|
|
69
|
+
started: Date.parse(manifest.started_at) || 0, messagesPath });
|
|
70
|
+
} catch (err) {
|
|
71
|
+
onWarning(`cline: 无法读取会话 ${messagesPath}: ${err.message}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
copies.sort((a, b) => a.started - b.started
|
|
77
|
+
|| a.sessionId.localeCompare(b.sessionId) || a.messagesPath.localeCompare(b.messagesPath));
|
|
78
|
+
const records = new Map();
|
|
79
|
+
for (const copy of copies) {
|
|
80
|
+
copy.messages.forEach((message, index) => {
|
|
81
|
+
// Anonymous messages can be deduplicated only within copies of this
|
|
82
|
+
// logical session; equal usage in unrelated sessions remains distinct.
|
|
83
|
+
const identity = message.id || `${copy.artifactId}:${index}`;
|
|
84
|
+
const key = JSON.stringify([identity, message.role, message.timestamp]);
|
|
85
|
+
const next = { ...message, sessionId: copy.sessionId, project: copy.project };
|
|
86
|
+
const old = records.get(key);
|
|
87
|
+
const count = value => value.inputTokens + value.outputTokens + value.cachedInputTokens;
|
|
88
|
+
if (!old) records.set(key, next);
|
|
89
|
+
else if (count(next) > count(old)) {
|
|
90
|
+
records.set(key, { ...next, sessionId: old.sessionId, project: old.project });
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
const entries = [], events = [];
|
|
95
|
+
for (const message of records.values()) {
|
|
96
|
+
const base = { source: 'cline', project: message.project, timestamp: new Date(message.timestamp) };
|
|
97
|
+
if (message.role === 'assistant'
|
|
98
|
+
&& message.inputTokens + message.outputTokens + message.cachedInputTokens > 0) {
|
|
99
|
+
entries.push({ ...base, model: message.model, inputTokens: message.inputTokens,
|
|
100
|
+
outputTokens: message.outputTokens, cachedInputTokens: message.cachedInputTokens,
|
|
101
|
+
reasoningOutputTokens: 0 });
|
|
102
|
+
}
|
|
103
|
+
events.push({ ...base, sessionId: message.sessionId, role: message.role });
|
|
104
|
+
}
|
|
105
|
+
return { entries, events };
|
|
106
|
+
}
|
package/src/parsers/cline.js
CHANGED
|
@@ -2,11 +2,13 @@ import { statSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
4
4
|
import { readJsonSafe, projectFromPath } from './fs-utils.js';
|
|
5
|
-
import {
|
|
5
|
+
import { findClineStores } from '../cline-roots.js';
|
|
6
|
+
import { readClineSdk } from './cline-sdk.js';
|
|
6
7
|
|
|
7
8
|
export async function parse() {
|
|
8
|
-
const
|
|
9
|
-
|
|
9
|
+
const warnings = [];
|
|
10
|
+
const onWarning = message => warnings.push(message);
|
|
11
|
+
const { legacyRoots: extDirs, sdkSessionDirs } = findClineStores({ onWarning });
|
|
10
12
|
|
|
11
13
|
const entries = [];
|
|
12
14
|
const events = [];
|
|
@@ -88,5 +90,8 @@ export async function parse() {
|
|
|
88
90
|
}
|
|
89
91
|
}
|
|
90
92
|
|
|
91
|
-
|
|
93
|
+
const sdk = readClineSdk(sdkSessionDirs, onWarning);
|
|
94
|
+
if (warnings.length) return { buckets: [], sessions: [], skipped: true, warnings };
|
|
95
|
+
return { buckets: aggregateToBuckets([...entries, ...sdk.entries]),
|
|
96
|
+
sessions: extractSessions([...events, ...sdk.events]) };
|
|
92
97
|
}
|
package/src/summary.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { loadConfig } from './config.js';
|
|
2
2
|
import { getJson } from './api.js';
|
|
3
3
|
import { failure } from './output.js';
|
|
4
|
+
import { TOOLS } from './tools.js';
|
|
5
|
+
|
|
6
|
+
// Server-side source ids are raw slugs ('claude-code'); TOOLS carries the
|
|
7
|
+
// display names. A source the server knows but this CLI doesn't parse falls
|
|
8
|
+
// back to its id rather than disappearing from the table.
|
|
9
|
+
const TOOL_NAMES = new Map(TOOLS.map(t => [t.id, t.name]));
|
|
4
10
|
|
|
5
11
|
export async function runSummary(args = []) {
|
|
6
12
|
const days = parseDays(args);
|
|
@@ -36,7 +42,7 @@ function parseDays(args) {
|
|
|
36
42
|
return v;
|
|
37
43
|
}
|
|
38
44
|
|
|
39
|
-
function render(data, days, apiUrl) {
|
|
45
|
+
export function render(data, days, apiUrl) {
|
|
40
46
|
const buckets = Array.isArray(data?.buckets) ? data.buckets : [];
|
|
41
47
|
const sessions = Array.isArray(data?.sessions) ? data.sessions : [];
|
|
42
48
|
const dashboard = `${apiUrl}/usage`;
|
|
@@ -47,6 +53,7 @@ function render(data, days, apiUrl) {
|
|
|
47
53
|
|
|
48
54
|
let totalCost = 0;
|
|
49
55
|
let totalTokens = 0;
|
|
56
|
+
const bySource = new Map();
|
|
50
57
|
const byModel = new Map();
|
|
51
58
|
const byProject = new Map();
|
|
52
59
|
|
|
@@ -55,6 +62,7 @@ function render(data, days, apiUrl) {
|
|
|
55
62
|
const tokens = Number(b.totalTokens ?? 0);
|
|
56
63
|
totalCost += cost;
|
|
57
64
|
totalTokens += tokens;
|
|
65
|
+
accumulate(bySource, b.source || 'unknown', { cost, tokens });
|
|
58
66
|
accumulate(byModel, b.model, { cost, tokens });
|
|
59
67
|
accumulate(byProject, b.project || 'unknown', { cost, tokens, sessions: 0 });
|
|
60
68
|
}
|
|
@@ -74,6 +82,16 @@ function render(data, days, apiUrl) {
|
|
|
74
82
|
lines.push(`**总览**: $${totalCost.toFixed(2)} · ${formatTokens(totalTokens)} tokens · ${sessionsCount} sessions · ${activeHours.toFixed(1)}h active`);
|
|
75
83
|
lines.push('');
|
|
76
84
|
|
|
85
|
+
lines.push('## 按工具');
|
|
86
|
+
lines.push('');
|
|
87
|
+
lines.push('| 工具 | 费用 | Tokens | 占比 |');
|
|
88
|
+
lines.push('|---|---:|---:|---:|');
|
|
89
|
+
for (const [source, { cost, tokens }] of topN(bySource, 'cost', 8)) {
|
|
90
|
+
const pct = totalCost > 0 ? ((cost / totalCost) * 100).toFixed(0) : '0';
|
|
91
|
+
lines.push(`| ${TOOL_NAMES.get(source) || source} | $${cost.toFixed(2)} | ${formatTokens(tokens)} | ${pct}% |`);
|
|
92
|
+
}
|
|
93
|
+
lines.push('');
|
|
94
|
+
|
|
77
95
|
lines.push('## 按模型');
|
|
78
96
|
lines.push('');
|
|
79
97
|
lines.push('| 模型 | 费用 | Tokens | 占比 |');
|