copilot-metrics 0.1.4 → 0.1.5
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/CHANGELOG.md +9 -0
- package/README.md +31 -28
- package/package.json +1 -1
- package/src/cli.js +3 -3
- package/src/ingest.js +267 -2
- package/src/otel.js +40 -5
- package/src/pricing.js +12 -3
- package/src/sqlite-store.js +188 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.5 - 2026-05-31
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- VS Code Copilot token usage is now attributed to Jira labels by matching OTel `gen_ai.response.id` values to VS Code chat session `responseId` values.
|
|
8
|
+
- Existing local stores with older VS Code usage rows are repaired by backfilling missing response IDs from already imported raw OTel records.
|
|
9
|
+
- VS Code chat session files are parsed only in memory for label extraction; full chat content is not persisted in the metrics store.
|
|
10
|
+
- Versioned model IDs such as dated Copilot telemetry model names now use the canonical per-token pricing row when one is available, so estimates show what the token usage would cost even during included or `0x` periods.
|
|
11
|
+
|
|
3
12
|
## 0.1.4 - 2026-05-31
|
|
4
13
|
|
|
5
14
|
### Changed
|
package/README.md
CHANGED
|
@@ -9,8 +9,8 @@ Costs are estimates, not official billing records. GitHub billing remains the so
|
|
|
9
9
|
From npm:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
npx copilot-metrics@0.1.
|
|
13
|
-
npx copilot-metrics@0.1.
|
|
12
|
+
npx copilot-metrics@0.1.5 --help
|
|
13
|
+
npx copilot-metrics@0.1.5 init
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
From this checkout:
|
|
@@ -38,8 +38,8 @@ export COPILOT_METRICS_HOME=/path/to/copilot-metrics-data
|
|
|
38
38
|
Useful commands:
|
|
39
39
|
|
|
40
40
|
```bash
|
|
41
|
-
npx copilot-metrics@0.1.
|
|
42
|
-
npx copilot-metrics@0.1.
|
|
41
|
+
npx copilot-metrics@0.1.5 init
|
|
42
|
+
npx copilot-metrics@0.1.5 paths --json
|
|
43
43
|
```
|
|
44
44
|
|
|
45
45
|
`init` only creates the central data directory and local config. It does not modify editor or hook settings. `setup` performs integration setup for the current machine/workspace.
|
|
@@ -51,19 +51,19 @@ For Copilot CLI, `init` plus hooks are enough for local token reporting. Reports
|
|
|
51
51
|
Install VS Code Copilot Chat OpenTelemetry settings:
|
|
52
52
|
|
|
53
53
|
```bash
|
|
54
|
-
npx copilot-metrics@0.1.
|
|
54
|
+
npx copilot-metrics@0.1.5 setup vscode
|
|
55
55
|
```
|
|
56
56
|
|
|
57
57
|
Install Copilot CLI hooks for the current workspace:
|
|
58
58
|
|
|
59
59
|
```bash
|
|
60
|
-
npx copilot-metrics@0.1.
|
|
60
|
+
npx copilot-metrics@0.1.5 setup copilot-cli
|
|
61
61
|
```
|
|
62
62
|
|
|
63
63
|
Or set up both VS Code settings and workspace hooks in one command:
|
|
64
64
|
|
|
65
65
|
```bash
|
|
66
|
-
npx copilot-metrics@0.1.
|
|
66
|
+
npx copilot-metrics@0.1.5 setup
|
|
67
67
|
```
|
|
68
68
|
|
|
69
69
|
Use `setup vscode --print` or `setup copilot-cli --print` to print the settings/optional environment exports without writing files. Copilot CLI OTel exports are optional because CLI token usage is read from local session-state files.
|
|
@@ -75,14 +75,14 @@ Content capture is disabled by default. Do not enable richer prompt capture unle
|
|
|
75
75
|
Preview repo-local hook config. The default `--surface both` emits the Copilot CLI lower camel case hook format:
|
|
76
76
|
|
|
77
77
|
```bash
|
|
78
|
-
npx copilot-metrics@0.1.
|
|
78
|
+
npx copilot-metrics@0.1.5 hooks preview --scope local --surface both
|
|
79
79
|
```
|
|
80
80
|
|
|
81
81
|
Install repo-local or user-global hook config:
|
|
82
82
|
|
|
83
83
|
```bash
|
|
84
|
-
npx copilot-metrics@0.1.
|
|
85
|
-
npx copilot-metrics@0.1.
|
|
84
|
+
npx copilot-metrics@0.1.5 hooks install --scope local --surface both
|
|
85
|
+
npx copilot-metrics@0.1.5 hooks install --scope global --surface both
|
|
86
86
|
```
|
|
87
87
|
|
|
88
88
|
Local install writes `.github/hooks/copilot-metrics.json`. Global install updates `~/.copilot/settings.json` idempotently, replacing prior `copilot-metrics` hook entries while preserving other settings and hooks. Use `--surface vscode` for VS Code-only PascalCase events or `--surface copilot-cli` for CLI-native lower camel case events. The hook logger writes redacted JSONL metadata to the central data directory. It extracts Jira-style labels such as `DEMO-12345` from safe metadata and does not store full prompt text by default.
|
|
@@ -92,39 +92,40 @@ Local install writes `.github/hooks/copilot-metrics.json`. Global install update
|
|
|
92
92
|
Initialize the local SQLite store and import JSONL files manually:
|
|
93
93
|
|
|
94
94
|
```bash
|
|
95
|
-
npx copilot-metrics@0.1.
|
|
96
|
-
npx copilot-metrics@0.1.
|
|
97
|
-
npx copilot-metrics@0.1.
|
|
98
|
-
npx copilot-metrics@0.1.
|
|
99
|
-
npx copilot-metrics@0.1.
|
|
95
|
+
npx copilot-metrics@0.1.5 store init
|
|
96
|
+
npx copilot-metrics@0.1.5 import --source vscode --file ~/.local/share/copilot-metrics/telemetry/vscode-copilot-otel.jsonl
|
|
97
|
+
npx copilot-metrics@0.1.5 import --source copilot-cli --file ~/.local/share/copilot-metrics/telemetry/copilot-cli-otel.jsonl
|
|
98
|
+
npx copilot-metrics@0.1.5 import --source copilot-session --file ~/.copilot/session-state/<session-id>/events.jsonl
|
|
99
|
+
npx copilot-metrics@0.1.5 import --source vscode-chat --file ~/.config/Code\ -\ Insiders/User/workspaceStorage/<workspace-id>/chatSessions/<session-id>.jsonl
|
|
100
|
+
npx copilot-metrics@0.1.5 import --source hooks --file ~/.local/share/copilot-metrics/hooks/copilot-hooks.jsonl
|
|
100
101
|
```
|
|
101
102
|
|
|
102
|
-
Imports persist raw records, normalized LLM usage records, hook events, label evidence, and import warnings. Re-importing the same JSONL rows is idempotent. For Copilot session-state files, only shutdown usage rows are persisted; prompt-bearing session events are used in memory for label extraction and context and are not stored as raw records.
|
|
103
|
+
Imports persist raw records, normalized LLM usage records, hook events, label evidence, and import warnings. Re-importing the same JSONL rows is idempotent. For Copilot session-state files, only shutdown usage rows are persisted; prompt-bearing session events are used in memory for label extraction and context and are not stored as raw records. VS Code chat session files are also parsed only in memory, then reduced to label evidence linked to VS Code OTel usage by exact response ID.
|
|
103
104
|
|
|
104
105
|
## Reports
|
|
105
106
|
|
|
106
107
|
Run local reports from the SQLite store:
|
|
107
108
|
|
|
108
109
|
```bash
|
|
109
|
-
npx copilot-metrics@0.1.
|
|
110
|
-
npx copilot-metrics@0.1.
|
|
111
|
-
npx copilot-metrics@0.1.
|
|
112
|
-
npx copilot-metrics@0.1.
|
|
113
|
-
npx copilot-metrics@0.1.
|
|
114
|
-
npx copilot-metrics@0.1.
|
|
110
|
+
npx copilot-metrics@0.1.5 report labels
|
|
111
|
+
npx copilot-metrics@0.1.5 report label DEMO-12345
|
|
112
|
+
npx copilot-metrics@0.1.5 report label DEMO-12345 --detail
|
|
113
|
+
npx copilot-metrics@0.1.5 report models
|
|
114
|
+
npx copilot-metrics@0.1.5 report repos
|
|
115
|
+
npx copilot-metrics@0.1.5 report unattributed
|
|
115
116
|
```
|
|
116
117
|
|
|
117
118
|
Every report supports `--json`:
|
|
118
119
|
|
|
119
120
|
```bash
|
|
120
|
-
npx copilot-metrics@0.1.
|
|
121
|
+
npx copilot-metrics@0.1.5 report labels --json
|
|
121
122
|
```
|
|
122
123
|
|
|
123
|
-
Report commands automatically import newly appended configured VS Code OTel, optional Copilot CLI OTel, Copilot CLI session-state, and hook JSONL files before querying. Repeated reports skip already imported session-state files and already imported JSONL lines.
|
|
124
|
+
Report commands automatically import newly appended configured VS Code OTel, VS Code chat session metadata, optional Copilot CLI OTel, Copilot CLI session-state, and hook JSONL files before querying. Repeated reports skip already imported session-state files and already imported JSONL lines.
|
|
124
125
|
|
|
125
126
|
`report labels` shows accumulated totals per label. `report label <id>` shows the selected label summary plus a per-model breakdown by default. Label reports include input, output, cache read, cache creation, and reasoning token totals. Labels seen only in hooks remain visible as `evidence-only` with zero usage records, so attribution hints do not imply token-bearing usage.
|
|
126
127
|
|
|
127
|
-
`AI Credits est.` is a local estimate derived from the pricing table. The project treats 1 AI Credit as $0.01 for estimates; GitHub billing remains the source of truth.
|
|
128
|
+
`AI Credits est.` is a local what-would-this-cost estimate derived from the token pricing table, not a claim that the interaction was billed today. Some included or request-based models can appear as `0x` in Copilot while still having published per-token prices. The project treats 1 AI Credit as $0.01 for estimates; GitHub billing remains the source of truth.
|
|
128
129
|
|
|
129
130
|
## Attribution Model
|
|
130
131
|
|
|
@@ -132,6 +133,8 @@ The default extractor finds Jira-style labels such as `DEMO-12345` from safe met
|
|
|
132
133
|
|
|
133
134
|
Attribution is stored as evidence with source, field, session, repo, branch, cwd, confidence, and related usage or hook record IDs. This makes the data useful for later analysis, such as deciding whether a label was the main task or a sidetrack.
|
|
134
135
|
|
|
136
|
+
For VS Code Copilot Chat, token records from OTel are linked to chat labels by exact response ID. The OTel `gen_ai.response.id` value must match the VS Code chat session `responseId`; timestamp-only attribution is not used.
|
|
137
|
+
|
|
135
138
|
Full prompt content is not stored by default. Prompt-like fields are only used to extract labels and the stored source value is reduced to the matched label.
|
|
136
139
|
|
|
137
140
|
## Custom Label Extractors
|
|
@@ -187,7 +190,7 @@ The manual prompt performs one harmless tool call so Copilot CLI hook execution
|
|
|
187
190
|
## Current Limits
|
|
188
191
|
|
|
189
192
|
- Costs are estimates, not official billing records.
|
|
190
|
-
- Official GitHub usage report reconciliation is not included in `0.1.
|
|
191
|
-
- Local OTLP collector mode is not included in `0.1.
|
|
192
|
-
- Richer prompt/content capture and redaction controls are not included in `0.1.
|
|
193
|
+
- Official GitHub usage report reconciliation is not included in `0.1.5`.
|
|
194
|
+
- Local OTLP collector mode is not included in `0.1.5`.
|
|
195
|
+
- Richer prompt/content capture and redaction controls are not included in `0.1.5`.
|
|
193
196
|
- Dashboard views are deferred until the CLI/query model proves useful.
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -79,7 +79,7 @@ Usage:
|
|
|
79
79
|
copilot-metrics hooks install [--scope local|global] [--surface both|vscode|copilot-cli] [--json]
|
|
80
80
|
copilot-metrics hook-log --event <name>
|
|
81
81
|
copilot-metrics store init [--json]
|
|
82
|
-
copilot-metrics import --source vscode|copilot-cli|copilot-session|hooks --file <path> [--json]
|
|
82
|
+
copilot-metrics import --source vscode|vscode-chat|copilot-cli|copilot-session|hooks --file <path> [--json]
|
|
83
83
|
copilot-metrics report labels [--json]
|
|
84
84
|
copilot-metrics report label <id> [--detail] [--json]
|
|
85
85
|
copilot-metrics report models [--json]
|
|
@@ -305,8 +305,8 @@ async function main(args, io) {
|
|
|
305
305
|
: source === 'hooks'
|
|
306
306
|
? paths.hookEventsJsonl
|
|
307
307
|
: null);
|
|
308
|
-
if (!['vscode', 'copilot-cli', 'copilot-session', 'hooks'].includes(source)) {
|
|
309
|
-
throw new Error('import requires --source vscode|copilot-cli|copilot-session|hooks');
|
|
308
|
+
if (!['vscode', 'vscode-chat', 'copilot-cli', 'copilot-session', 'hooks'].includes(source)) {
|
|
309
|
+
throw new Error('import requires --source vscode|vscode-chat|copilot-cli|copilot-session|hooks');
|
|
310
310
|
}
|
|
311
311
|
if (!file) throw new Error('import requires --file <path>');
|
|
312
312
|
ensureDataDirs(paths);
|
package/src/ingest.js
CHANGED
|
@@ -2,13 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
const crypto = require('node:crypto');
|
|
4
4
|
const fs = require('node:fs');
|
|
5
|
+
const os = require('node:os');
|
|
5
6
|
const path = require('node:path');
|
|
6
7
|
const { readJsonl } = require('./jsonl');
|
|
7
8
|
const { normalizePayload, normalizeHookEvent, normalizeCopilotSessionEvents } = require('./otel');
|
|
8
9
|
const { estimateCost, PRICING_VERSION } = require('./pricing');
|
|
9
|
-
const {
|
|
10
|
+
const {
|
|
11
|
+
attachVscodeChatLabelEvidence,
|
|
12
|
+
existingRawFingerprints,
|
|
13
|
+
importedLineHighWater,
|
|
14
|
+
insertImport,
|
|
15
|
+
queryRows,
|
|
16
|
+
updateUsageCostEstimates,
|
|
17
|
+
updateVscodeUsageResponseIds,
|
|
18
|
+
vscodeRawRecordsNeedingResponseBackfill,
|
|
19
|
+
} = require('./sqlite-store');
|
|
10
20
|
const { attachUsageLabelEvidence, attachHookLabelEvidence } = require('./labels');
|
|
11
|
-
const { loadConfiguredExtractors } = require('./label-extractors');
|
|
21
|
+
const { loadConfiguredExtractors, runLabelExtractors } = require('./label-extractors');
|
|
12
22
|
|
|
13
23
|
function enrichCosts(records) {
|
|
14
24
|
return records.map((record) => {
|
|
@@ -42,9 +52,221 @@ function isCopilotSessionUsageRecord(record) {
|
|
|
42
52
|
return record.value && record.value.type === 'session.shutdown';
|
|
43
53
|
}
|
|
44
54
|
|
|
55
|
+
function pushText(values, value) {
|
|
56
|
+
if (typeof value === 'string' && value.trim()) values.push(value);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function pushPromptCandidates(values, value) {
|
|
60
|
+
if (!value || typeof value !== 'object') return;
|
|
61
|
+
if (Array.isArray(value)) {
|
|
62
|
+
for (const item of value) pushPromptCandidates(values, item);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
pushText(values, value.text);
|
|
66
|
+
pushText(values, value.value);
|
|
67
|
+
pushText(values, value.message);
|
|
68
|
+
pushText(values, value.prompt);
|
|
69
|
+
pushText(values, value.promptText);
|
|
70
|
+
pushText(values, value.renderedUserMessage);
|
|
71
|
+
pushText(values, value.userMessage);
|
|
72
|
+
if (value.renderedUserMessage && typeof value.renderedUserMessage === 'object') {
|
|
73
|
+
pushPromptCandidates(values, value.renderedUserMessage);
|
|
74
|
+
}
|
|
75
|
+
if (value.message && typeof value.message === 'object') pushPromptCandidates(values, value.message);
|
|
76
|
+
if (value.result && typeof value.result === 'object') pushPromptCandidates(values, value.result);
|
|
77
|
+
if (value.metadata && typeof value.metadata === 'object') pushPromptCandidates(values, value.metadata);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function responseId(value) {
|
|
81
|
+
if (!value || typeof value !== 'object') return null;
|
|
82
|
+
return value.responseId
|
|
83
|
+
|| value.metadata?.responseId
|
|
84
|
+
|| value.result?.responseId
|
|
85
|
+
|| value.result?.metadata?.responseId
|
|
86
|
+
|| value.modelMessageId
|
|
87
|
+
|| value.metadata?.modelMessageId
|
|
88
|
+
|| null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function chatSessionId(value) {
|
|
92
|
+
if (!value || typeof value !== 'object') return null;
|
|
93
|
+
return value.sessionId
|
|
94
|
+
|| value.sessionID
|
|
95
|
+
|| value.metadata?.sessionId
|
|
96
|
+
|| value.result?.sessionId
|
|
97
|
+
|| value.result?.metadata?.sessionId
|
|
98
|
+
|| null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function chatRequestIndex(record) {
|
|
102
|
+
const key = Array.isArray(record.k) ? record.k : Array.isArray(record.key) ? record.key : [];
|
|
103
|
+
if (key[0] !== 'requests') return null;
|
|
104
|
+
const index = Number(key[1]);
|
|
105
|
+
return Number.isInteger(index) ? index : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function normalizeVscodeChatSession(records, extractors = []) {
|
|
109
|
+
const requests = new Map();
|
|
110
|
+
let defaultSessionId = null;
|
|
111
|
+
|
|
112
|
+
function entry(index) {
|
|
113
|
+
const key = String(index);
|
|
114
|
+
if (!requests.has(key)) requests.set(key, { texts: [] });
|
|
115
|
+
return requests.get(key);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function mergeRequest(index, request, sessionId) {
|
|
119
|
+
if (!request || typeof request !== 'object') return;
|
|
120
|
+
const current = entry(index);
|
|
121
|
+
current.sessionId = chatSessionId(request) || sessionId || current.sessionId;
|
|
122
|
+
current.responseId = responseId(request) || current.responseId;
|
|
123
|
+
pushPromptCandidates(current.texts, request);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const record of records) {
|
|
127
|
+
const value = record.value;
|
|
128
|
+
if (!value || typeof value !== 'object') continue;
|
|
129
|
+
const root = value.v && typeof value.v === 'object' ? value.v : value;
|
|
130
|
+
defaultSessionId = root.sessionId || root.sessionID || defaultSessionId;
|
|
131
|
+
|
|
132
|
+
if (Array.isArray(root.requests)) {
|
|
133
|
+
root.requests.forEach((request, index) => mergeRequest(index, request, defaultSessionId));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const key = Array.isArray(value.k) ? value.k : Array.isArray(value.key) ? value.key : [];
|
|
137
|
+
if (key.length === 1 && key[0] === 'requests' && Array.isArray(value.v)) {
|
|
138
|
+
const startIndex = requests.size;
|
|
139
|
+
value.v.forEach((request, offset) => mergeRequest(startIndex + offset, request, defaultSessionId));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const index = chatRequestIndex(value);
|
|
143
|
+
if (index !== null) {
|
|
144
|
+
const current = entry(index);
|
|
145
|
+
const patch = value.v;
|
|
146
|
+
if (patch && typeof patch === 'object') {
|
|
147
|
+
current.sessionId = chatSessionId(patch) || defaultSessionId || current.sessionId;
|
|
148
|
+
current.responseId = responseId(patch) || current.responseId;
|
|
149
|
+
pushPromptCandidates(current.texts, patch);
|
|
150
|
+
} else {
|
|
151
|
+
pushText(current.texts, patch);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return Array.from(requests.values())
|
|
157
|
+
.filter((request) => request.responseId)
|
|
158
|
+
.map((request) => {
|
|
159
|
+
const labelEvidence = runLabelExtractors('usage', { prompt: request.texts }, extractors)
|
|
160
|
+
.map((evidence) => ({
|
|
161
|
+
...evidence,
|
|
162
|
+
source_type: 'usage',
|
|
163
|
+
source_field: 'vscode_chat_response',
|
|
164
|
+
source_value: request.responseId,
|
|
165
|
+
confidence: Math.max(Number(evidence.confidence || 0), 0.95),
|
|
166
|
+
}));
|
|
167
|
+
return {
|
|
168
|
+
responseId: request.responseId,
|
|
169
|
+
sessionId: request.sessionId || defaultSessionId || null,
|
|
170
|
+
label_evidence: labelEvidence,
|
|
171
|
+
};
|
|
172
|
+
})
|
|
173
|
+
.filter((request) => request.label_evidence.length > 0);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function ingestVscodeChatSessionFile(options) {
|
|
177
|
+
const { dbPath, file } = options;
|
|
178
|
+
const sourceFile = path.resolve(file);
|
|
179
|
+
const parsed = readJsonl(sourceFile);
|
|
180
|
+
const mappings = normalizeVscodeChatSession(parsed.records, options.extractors || []);
|
|
181
|
+
const attached = await attachVscodeChatLabelEvidence(dbPath, mappings);
|
|
182
|
+
return {
|
|
183
|
+
source: 'vscode-chat',
|
|
184
|
+
file,
|
|
185
|
+
dbPath,
|
|
186
|
+
raw_records: 0,
|
|
187
|
+
new_raw_records: 0,
|
|
188
|
+
skipped_existing_records: 0,
|
|
189
|
+
usage_records: attached.matched_usage_records,
|
|
190
|
+
hook_events: 0,
|
|
191
|
+
label_evidence: attached.label_evidence,
|
|
192
|
+
warnings: parsed.warnings,
|
|
193
|
+
estimate_label: `estimate:${PRICING_VERSION}`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function backfillVscodeUsageResponseIds(dbPath, sourceFile) {
|
|
198
|
+
const rows = await vscodeRawRecordsNeedingResponseBackfill(dbPath, sourceFile);
|
|
199
|
+
const updates = [];
|
|
200
|
+
for (const row of rows) {
|
|
201
|
+
let payload;
|
|
202
|
+
try {
|
|
203
|
+
payload = JSON.parse(row.payload_json);
|
|
204
|
+
} catch {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
for (const usage of normalizePayload(payload, 'vscode', row.line)) {
|
|
208
|
+
if (!usage.span_id) continue;
|
|
209
|
+
updates.push({
|
|
210
|
+
raw_line: usage.raw_line,
|
|
211
|
+
span_id: usage.span_id,
|
|
212
|
+
session_id: usage.session_id,
|
|
213
|
+
timestamp: usage.timestamp,
|
|
214
|
+
requested_model: usage.requested_model,
|
|
215
|
+
resolved_model: usage.resolved_model,
|
|
216
|
+
input_tokens: usage.input_tokens,
|
|
217
|
+
output_tokens: usage.output_tokens,
|
|
218
|
+
cache_read_tokens: usage.cache_read_tokens,
|
|
219
|
+
cache_creation_tokens: usage.cache_creation_tokens,
|
|
220
|
+
reasoning_tokens: usage.reasoning_tokens,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return updateVscodeUsageResponseIds(dbPath, updates);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function parseWarningsJson(value) {
|
|
228
|
+
try {
|
|
229
|
+
const parsed = JSON.parse(value || '[]');
|
|
230
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
231
|
+
} catch {
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function repairUsageCostEstimates(dbPath) {
|
|
237
|
+
const rows = await queryRows(dbPath, `
|
|
238
|
+
SELECT id, requested_model, resolved_model, input_tokens, output_tokens,
|
|
239
|
+
cache_read_tokens, cache_creation_tokens, reasoning_tokens, warnings_json
|
|
240
|
+
FROM usage_records
|
|
241
|
+
WHERE estimated_ai_credits IS NULL
|
|
242
|
+
OR estimated_ai_credits = 0
|
|
243
|
+
OR warnings_json LIKE '%unknown_model:%'
|
|
244
|
+
OR warnings_json LIKE '%missing_model%'
|
|
245
|
+
`);
|
|
246
|
+
const updates = [];
|
|
247
|
+
for (const row of rows) {
|
|
248
|
+
const estimate = estimateCost(row);
|
|
249
|
+
if (estimate.warning) continue;
|
|
250
|
+
const warnings = parseWarningsJson(row.warnings_json)
|
|
251
|
+
.filter((warning) => !String(warning).startsWith('unknown_model:') && warning !== 'missing_model');
|
|
252
|
+
updates.push({
|
|
253
|
+
id: row.id,
|
|
254
|
+
estimated_usd: estimate.estimated_usd,
|
|
255
|
+
estimated_ai_credits: estimate.estimated_ai_credits,
|
|
256
|
+
warnings,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return updateUsageCostEstimates(dbPath, updates);
|
|
260
|
+
}
|
|
261
|
+
|
|
45
262
|
async function ingestFile(options) {
|
|
46
263
|
const { dbPath, file, source } = options;
|
|
264
|
+
if (source === 'vscode-chat') return ingestVscodeChatSessionFile(options);
|
|
265
|
+
|
|
47
266
|
const sourceFile = path.resolve(file);
|
|
267
|
+
const backfilledUsageRecords = source === 'vscode'
|
|
268
|
+
? await backfillVscodeUsageResponseIds(dbPath, sourceFile)
|
|
269
|
+
: 0;
|
|
48
270
|
const highWaterLine = await importedLineHighWater(dbPath, source, sourceFile);
|
|
49
271
|
if (source === 'copilot-session' && highWaterLine > 0) {
|
|
50
272
|
return {
|
|
@@ -57,6 +279,7 @@ async function ingestFile(options) {
|
|
|
57
279
|
usage_records: 0,
|
|
58
280
|
hook_events: 0,
|
|
59
281
|
label_evidence: 0,
|
|
282
|
+
backfilled_usage_records: backfilledUsageRecords,
|
|
60
283
|
warnings: [],
|
|
61
284
|
estimate_label: `estimate:${PRICING_VERSION}`,
|
|
62
285
|
};
|
|
@@ -108,6 +331,7 @@ async function ingestFile(options) {
|
|
|
108
331
|
}
|
|
109
332
|
|
|
110
333
|
await insertImport(dbPath, source, sourceFile, newRecords, enrichedUsage, enrichedHooks, warnings);
|
|
334
|
+
const repairedCostRecords = await repairUsageCostEstimates(dbPath);
|
|
111
335
|
|
|
112
336
|
return {
|
|
113
337
|
source,
|
|
@@ -118,6 +342,8 @@ async function ingestFile(options) {
|
|
|
118
342
|
skipped_existing_records: highWaterLine,
|
|
119
343
|
usage_records: enrichedUsage.length,
|
|
120
344
|
hook_events: enrichedHooks.length,
|
|
345
|
+
backfilled_usage_records: backfilledUsageRecords,
|
|
346
|
+
repaired_cost_records: repairedCostRecords,
|
|
121
347
|
label_evidence: enrichedUsage.reduce((sum, usage) => sum + (usage.label_evidence || []).length, 0)
|
|
122
348
|
+ enrichedHooks.reduce((sum, event) => sum + (event.label_evidence || []).length, 0),
|
|
123
349
|
warnings,
|
|
@@ -130,6 +356,7 @@ function configuredSourceFiles(paths, config = {}) {
|
|
|
130
356
|
const telemetryConfig = config.telemetry || {};
|
|
131
357
|
const files = [
|
|
132
358
|
{ source: 'vscode', file: sourceConfig.vscode?.telemetry || telemetryConfig.vscode || paths.vscodeOtelJsonl },
|
|
359
|
+
...discoverVscodeChatSessionFiles(sourceConfig.vscode?.chatSessions),
|
|
133
360
|
{ source: 'hooks', file: sourceConfig.vscode?.hooks || paths.hookEventsJsonl },
|
|
134
361
|
{ source: 'copilot-cli', file: sourceConfig.copilotCli?.telemetry || telemetryConfig.copilotCli || paths.copilotCliOtelJsonl },
|
|
135
362
|
{ source: 'hooks', file: sourceConfig.copilotCli?.hooks || paths.hookEventsJsonl },
|
|
@@ -147,6 +374,40 @@ function configuredSourceFiles(paths, config = {}) {
|
|
|
147
374
|
});
|
|
148
375
|
}
|
|
149
376
|
|
|
377
|
+
function listJsonlFiles(dir) {
|
|
378
|
+
if (!dir || !fs.existsSync(dir)) return [];
|
|
379
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
380
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'))
|
|
381
|
+
.map((entry) => path.join(dir, entry.name));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function discoverWorkspaceChatSessions(workspaceStorageDir) {
|
|
385
|
+
if (!workspaceStorageDir || !fs.existsSync(workspaceStorageDir)) return [];
|
|
386
|
+
return fs.readdirSync(workspaceStorageDir, { withFileTypes: true })
|
|
387
|
+
.filter((entry) => entry.isDirectory())
|
|
388
|
+
.flatMap((entry) => listJsonlFiles(path.join(workspaceStorageDir, entry.name, 'chatSessions')));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function discoverVscodeChatSessionFiles(configured) {
|
|
392
|
+
const configuredEntries = Array.isArray(configured) ? configured : configured ? [configured] : [];
|
|
393
|
+
const files = configuredEntries.length > 0
|
|
394
|
+
? configuredEntries.flatMap((entry) => {
|
|
395
|
+
const resolved = path.resolve(entry);
|
|
396
|
+
if (!fs.existsSync(resolved)) return [];
|
|
397
|
+
const stat = fs.statSync(resolved);
|
|
398
|
+
if (stat.isFile()) return [resolved];
|
|
399
|
+
return listJsonlFiles(resolved).concat(discoverWorkspaceChatSessions(resolved));
|
|
400
|
+
})
|
|
401
|
+
: [
|
|
402
|
+
path.join(os.homedir(), '.config', 'Code', 'User', 'workspaceStorage'),
|
|
403
|
+
path.join(os.homedir(), '.config', 'Code - Insiders', 'User', 'workspaceStorage'),
|
|
404
|
+
].flatMap(discoverWorkspaceChatSessions);
|
|
405
|
+
|
|
406
|
+
return files
|
|
407
|
+
.sort()
|
|
408
|
+
.map((file) => ({ source: 'vscode-chat', file }));
|
|
409
|
+
}
|
|
410
|
+
|
|
150
411
|
function discoverCopilotSessionFiles(sessionStateDir) {
|
|
151
412
|
if (!sessionStateDir || !fs.existsSync(sessionStateDir)) return [];
|
|
152
413
|
return fs.readdirSync(sessionStateDir, { withFileTypes: true })
|
|
@@ -184,5 +445,9 @@ module.exports = {
|
|
|
184
445
|
autoImportConfiguredSources,
|
|
185
446
|
configuredSourceFiles,
|
|
186
447
|
discoverCopilotSessionFiles,
|
|
448
|
+
discoverVscodeChatSessionFiles,
|
|
449
|
+
backfillVscodeUsageResponseIds,
|
|
187
450
|
ingestFile,
|
|
451
|
+
normalizeVscodeChatSession,
|
|
452
|
+
repairUsageCostEstimates,
|
|
188
453
|
};
|
package/src/otel.js
CHANGED
|
@@ -2,9 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
function attrsToObject(attrs) {
|
|
4
4
|
if (!attrs) return {};
|
|
5
|
+
if (attrs && typeof attrs === 'object' && Array.isArray(attrs._rawAttributes)) {
|
|
6
|
+
return Object.fromEntries(attrs._rawAttributes);
|
|
7
|
+
}
|
|
5
8
|
if (!Array.isArray(attrs)) return attrs;
|
|
6
9
|
const out = {};
|
|
7
10
|
for (const attr of attrs) {
|
|
11
|
+
if (Array.isArray(attr) && attr.length >= 2) {
|
|
12
|
+
out[attr[0]] = attr[1];
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
8
15
|
const value = attr.value;
|
|
9
16
|
if (value && typeof value === 'object') {
|
|
10
17
|
out[attr.key] = value.stringValue ?? value.intValue ?? value.doubleValue ?? value.boolValue ?? value.arrayValue;
|
|
@@ -61,8 +68,28 @@ function flattenSpans(payload) {
|
|
|
61
68
|
return spans;
|
|
62
69
|
}
|
|
63
70
|
|
|
71
|
+
function timestampValue(value) {
|
|
72
|
+
if (!value) return null;
|
|
73
|
+
if (Array.isArray(value) && value.length >= 2) {
|
|
74
|
+
const millis = (Number(value[0]) * 1000) + (Number(value[1]) / 1e6);
|
|
75
|
+
return Number.isFinite(millis) ? new Date(millis).toISOString() : null;
|
|
76
|
+
}
|
|
77
|
+
if (typeof value === 'string' && /^\d+$/.test(value)) {
|
|
78
|
+
const numeric = Number(value);
|
|
79
|
+
if (!Number.isFinite(numeric)) return null;
|
|
80
|
+
const millis = numeric > 1e15 ? numeric / 1e6 : numeric;
|
|
81
|
+
return new Date(millis).toISOString();
|
|
82
|
+
}
|
|
83
|
+
if (typeof value === 'number') {
|
|
84
|
+
const millis = value > 1e15 ? value / 1e6 : value;
|
|
85
|
+
return new Date(millis).toISOString();
|
|
86
|
+
}
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
|
|
64
90
|
function classifySpan(span) {
|
|
65
91
|
const attrs = attrsToObject(span.attributes);
|
|
92
|
+
const eventName = String(pick(attrs, ['event.name']) || '').toLowerCase();
|
|
66
93
|
const operation = String(pick(attrs, ['gen_ai.operation.name', 'llm.operation']) || '').toLowerCase();
|
|
67
94
|
const name = String(span.name || '').toLowerCase();
|
|
68
95
|
const hasTokens = number(attrs, [
|
|
@@ -72,7 +99,14 @@ function classifySpan(span) {
|
|
|
72
99
|
'llm.usage.completion_tokens',
|
|
73
100
|
]) > 0;
|
|
74
101
|
|
|
75
|
-
if (
|
|
102
|
+
if (
|
|
103
|
+
eventName.includes('agent')
|
|
104
|
+
|| eventName.includes('tool')
|
|
105
|
+
|| operation.includes('agent')
|
|
106
|
+
|| operation.includes('tool')
|
|
107
|
+
|| name.includes('agent')
|
|
108
|
+
|| name.includes('tool')
|
|
109
|
+
) {
|
|
76
110
|
return 'non_billable';
|
|
77
111
|
}
|
|
78
112
|
if (hasTokens || operation.includes('chat') || operation.includes('completion') || operation.includes('generate')) {
|
|
@@ -83,19 +117,19 @@ function classifySpan(span) {
|
|
|
83
117
|
|
|
84
118
|
function normalizeSpan(span, source, rawLine) {
|
|
85
119
|
const attrs = attrsToObject(span.attributes);
|
|
86
|
-
const resourceAttrs = attrsToObject(span.resourceAttributes);
|
|
120
|
+
const resourceAttrs = attrsToObject(span.resourceAttributes || span.resource);
|
|
87
121
|
const type = classifySpan(span);
|
|
88
122
|
if (type !== 'llm') return null;
|
|
89
123
|
|
|
90
124
|
return {
|
|
91
125
|
raw_line: rawLine,
|
|
92
|
-
span_id: span.spanId || span.span_id || null,
|
|
126
|
+
span_id: span.spanId || span.span_id || pick(attrs, ['gen_ai.response.id']) || null,
|
|
93
127
|
trace_id: span.traceId || span.trace_id || null,
|
|
94
128
|
parent_span_id: span.parentSpanId || span.parent_span_id || null,
|
|
95
|
-
timestamp: span.startTimeUnixNano || span.start_time ||
|
|
129
|
+
timestamp: timestampValue(span.startTimeUnixNano || span.start_time || span.hrTime || attrs.timestamp),
|
|
96
130
|
surface: source,
|
|
97
131
|
conversation_id: pick(attrs, ['gen_ai.conversation.id', 'conversation.id', 'copilot.conversation.id']),
|
|
98
|
-
session_id: pick(attrs, ['session.id', 'copilot.session.id']),
|
|
132
|
+
session_id: pick(attrs, ['session.id', 'copilot.session.id']) || pick(resourceAttrs, ['session.id', 'copilot.session.id']),
|
|
99
133
|
requested_model: pick(attrs, ['gen_ai.request.model', 'llm.request.model', 'llm.model_name']),
|
|
100
134
|
resolved_model: pick(attrs, ['gen_ai.response.model', 'llm.response.model', 'model']),
|
|
101
135
|
repo: pick(attrs, ['vcs.repository.name', 'git.repository', 'repo']) || pick(resourceAttrs, ['vcs.repository.name', 'service.name']),
|
|
@@ -194,6 +228,7 @@ function normalizeHookEvent(payload, source, rawLine) {
|
|
|
194
228
|
return {
|
|
195
229
|
raw_line: rawLine,
|
|
196
230
|
event: payload.event || null,
|
|
231
|
+
timestamp: payload.captured_at || payload.timestamp || null,
|
|
197
232
|
session_id: payload.session_id || payload.sessionId || null,
|
|
198
233
|
cwd: payload.cwd || null,
|
|
199
234
|
repo: payload.repo || payload.repository || null,
|
package/src/pricing.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const PRICING_VERSION = 'github-copilot-2026-06-01';
|
|
4
4
|
|
|
5
|
-
// USD per 1M tokens. Source: GitHub Copilot models and pricing docs, checked 2026-05-
|
|
5
|
+
// USD per 1M tokens. Source: GitHub Copilot models and pricing docs, checked 2026-05-31.
|
|
6
6
|
const MODEL_PRICES = {
|
|
7
7
|
'gpt-4.1': { input: 2.00, cacheRead: 0.50, cacheWrite: 0, output: 8.00 },
|
|
8
8
|
'gpt-5 mini': { input: 0.25, cacheRead: 0.025, cacheWrite: 0, output: 2.00 },
|
|
@@ -32,11 +32,19 @@ const MODEL_PRICES = {
|
|
|
32
32
|
};
|
|
33
33
|
|
|
34
34
|
function normalizeModelName(model) {
|
|
35
|
-
return String(model || '').trim().toLowerCase();
|
|
35
|
+
return String(model || '').trim().toLowerCase().replace(/^copilot\//, '');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function modelPriceKey(model) {
|
|
39
|
+
const normalized = normalizeModelName(model);
|
|
40
|
+
if (MODEL_PRICES[normalized]) return normalized;
|
|
41
|
+
const withoutDate = normalized.replace(/-\d{4}-\d{2}-\d{2}$/, '');
|
|
42
|
+
if (MODEL_PRICES[withoutDate]) return withoutDate;
|
|
43
|
+
return normalized;
|
|
36
44
|
}
|
|
37
45
|
|
|
38
46
|
function estimateCost(record) {
|
|
39
|
-
const model =
|
|
47
|
+
const model = modelPriceKey(record.resolved_model || record.requested_model);
|
|
40
48
|
const price = MODEL_PRICES[model];
|
|
41
49
|
if (!model) {
|
|
42
50
|
return { estimated_usd: null, estimated_ai_credits: null, warning: 'missing_model' };
|
|
@@ -63,4 +71,5 @@ module.exports = {
|
|
|
63
71
|
PRICING_VERSION,
|
|
64
72
|
MODEL_PRICES,
|
|
65
73
|
estimateCost,
|
|
74
|
+
modelPriceKey,
|
|
66
75
|
};
|
package/src/sqlite-store.js
CHANGED
|
@@ -141,6 +141,7 @@ function lastInsertId(db) {
|
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
function insertLabelEvidence(db, importedAt, evidenceRows) {
|
|
144
|
+
if (!evidenceRows.length) return;
|
|
144
145
|
runPrepared(
|
|
145
146
|
db,
|
|
146
147
|
`INSERT INTO label_evidence (
|
|
@@ -283,6 +284,7 @@ async function insertImport(dbPath, source, sourceFile, rawRecords, usageRecords
|
|
|
283
284
|
repo: event.repo,
|
|
284
285
|
branch: event.branch,
|
|
285
286
|
cwd: event.cwd,
|
|
287
|
+
timestamp: event.timestamp,
|
|
286
288
|
});
|
|
287
289
|
}
|
|
288
290
|
}
|
|
@@ -307,6 +309,188 @@ async function insertImport(dbPath, source, sourceFile, rawRecords, usageRecords
|
|
|
307
309
|
persistDatabase(dbPath, db);
|
|
308
310
|
}
|
|
309
311
|
|
|
312
|
+
async function attachVscodeChatLabelEvidence(dbPath, mappings) {
|
|
313
|
+
await initStore(dbPath);
|
|
314
|
+
if (!mappings.length) {
|
|
315
|
+
return { matched_usage_records: 0, label_evidence: 0 };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const db = await openDatabase(dbPath);
|
|
319
|
+
const importedAt = new Date().toISOString();
|
|
320
|
+
let matchedUsageRecords = 0;
|
|
321
|
+
let labelEvidence = 0;
|
|
322
|
+
|
|
323
|
+
const usageStatement = db.prepare(`
|
|
324
|
+
SELECT id, session_id, repo, branch, cwd, timestamp
|
|
325
|
+
FROM usage_records
|
|
326
|
+
WHERE source = 'vscode' AND span_id = ?
|
|
327
|
+
`);
|
|
328
|
+
const existingStatement = db.prepare(`
|
|
329
|
+
SELECT 1
|
|
330
|
+
FROM label_evidence
|
|
331
|
+
WHERE label = ?
|
|
332
|
+
AND source_type = 'usage'
|
|
333
|
+
AND source_field = 'vscode_chat_response'
|
|
334
|
+
AND source_value = ?
|
|
335
|
+
AND usage_record_id = ?
|
|
336
|
+
LIMIT 1
|
|
337
|
+
`);
|
|
338
|
+
const insertStatement = db.prepare(`
|
|
339
|
+
INSERT INTO label_evidence (
|
|
340
|
+
imported_at, label, source_type, source_field, source_value, confidence,
|
|
341
|
+
usage_record_id, hook_event_id, session_id, repo, branch, cwd, timestamp
|
|
342
|
+
) VALUES (?, ?, 'usage', 'vscode_chat_response', ?, ?, ?, NULL, ?, ?, ?, ?, ?)
|
|
343
|
+
`);
|
|
344
|
+
|
|
345
|
+
db.run('BEGIN');
|
|
346
|
+
try {
|
|
347
|
+
for (const mapping of mappings) {
|
|
348
|
+
usageStatement.bind([mapping.responseId]);
|
|
349
|
+
const usageRows = [];
|
|
350
|
+
while (usageStatement.step()) usageRows.push(usageStatement.getAsObject());
|
|
351
|
+
usageStatement.reset();
|
|
352
|
+
matchedUsageRecords += usageRows.length;
|
|
353
|
+
|
|
354
|
+
for (const usage of usageRows) {
|
|
355
|
+
for (const evidence of mapping.label_evidence || []) {
|
|
356
|
+
existingStatement.bind([evidence.label, mapping.responseId, usage.id]);
|
|
357
|
+
const exists = existingStatement.step();
|
|
358
|
+
existingStatement.reset();
|
|
359
|
+
if (exists) continue;
|
|
360
|
+
|
|
361
|
+
insertStatement.run([
|
|
362
|
+
importedAt,
|
|
363
|
+
evidence.label,
|
|
364
|
+
mapping.responseId,
|
|
365
|
+
evidence.confidence || 0.95,
|
|
366
|
+
usage.id,
|
|
367
|
+
mapping.sessionId || usage.session_id || null,
|
|
368
|
+
usage.repo || null,
|
|
369
|
+
usage.branch || null,
|
|
370
|
+
usage.cwd || null,
|
|
371
|
+
usage.timestamp || null,
|
|
372
|
+
]);
|
|
373
|
+
labelEvidence += 1;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
db.run('COMMIT');
|
|
378
|
+
} catch (error) {
|
|
379
|
+
db.run('ROLLBACK');
|
|
380
|
+
throw error;
|
|
381
|
+
} finally {
|
|
382
|
+
usageStatement.free();
|
|
383
|
+
existingStatement.free();
|
|
384
|
+
insertStatement.free();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
persistDatabase(dbPath, db);
|
|
388
|
+
return { matched_usage_records: matchedUsageRecords, label_evidence: labelEvidence };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function vscodeRawRecordsNeedingResponseBackfill(dbPath, sourceFile) {
|
|
392
|
+
await initStore(dbPath);
|
|
393
|
+
return queryRows(dbPath, `
|
|
394
|
+
SELECT rr.line, rr.payload_json
|
|
395
|
+
FROM raw_records rr
|
|
396
|
+
WHERE rr.source = 'vscode'
|
|
397
|
+
AND rr.source_file = ?
|
|
398
|
+
AND EXISTS (
|
|
399
|
+
SELECT 1
|
|
400
|
+
FROM usage_records ur
|
|
401
|
+
WHERE ur.source = 'vscode'
|
|
402
|
+
AND ur.raw_line = rr.line
|
|
403
|
+
AND ur.span_id IS NULL
|
|
404
|
+
)
|
|
405
|
+
`, [sourceFile]);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function updateVscodeUsageResponseIds(dbPath, updates) {
|
|
409
|
+
await initStore(dbPath);
|
|
410
|
+
if (!updates.length) return 0;
|
|
411
|
+
|
|
412
|
+
const db = await openDatabase(dbPath);
|
|
413
|
+
const statement = db.prepare(`
|
|
414
|
+
UPDATE usage_records
|
|
415
|
+
SET span_id = ?,
|
|
416
|
+
session_id = COALESCE(session_id, ?),
|
|
417
|
+
timestamp = COALESCE(timestamp, ?)
|
|
418
|
+
WHERE source = 'vscode'
|
|
419
|
+
AND raw_line = ?
|
|
420
|
+
AND span_id IS NULL
|
|
421
|
+
AND input_tokens = ?
|
|
422
|
+
AND output_tokens = ?
|
|
423
|
+
AND cache_read_tokens = ?
|
|
424
|
+
AND cache_creation_tokens = ?
|
|
425
|
+
AND reasoning_tokens = ?
|
|
426
|
+
AND COALESCE(resolved_model, requested_model, '') = COALESCE(?, '')
|
|
427
|
+
`);
|
|
428
|
+
let updated = 0;
|
|
429
|
+
db.run('BEGIN');
|
|
430
|
+
try {
|
|
431
|
+
for (const update of updates) {
|
|
432
|
+
statement.run([
|
|
433
|
+
update.span_id,
|
|
434
|
+
update.session_id || null,
|
|
435
|
+
update.timestamp || null,
|
|
436
|
+
update.raw_line,
|
|
437
|
+
update.input_tokens,
|
|
438
|
+
update.output_tokens,
|
|
439
|
+
update.cache_read_tokens,
|
|
440
|
+
update.cache_creation_tokens,
|
|
441
|
+
update.reasoning_tokens,
|
|
442
|
+
update.resolved_model || update.requested_model || '',
|
|
443
|
+
]);
|
|
444
|
+
updated += typeof db.getRowsModified === 'function' ? db.getRowsModified() : 0;
|
|
445
|
+
}
|
|
446
|
+
db.run('COMMIT');
|
|
447
|
+
} catch (error) {
|
|
448
|
+
db.run('ROLLBACK');
|
|
449
|
+
throw error;
|
|
450
|
+
} finally {
|
|
451
|
+
statement.free();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
persistDatabase(dbPath, db);
|
|
455
|
+
return updated;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function updateUsageCostEstimates(dbPath, updates) {
|
|
459
|
+
await initStore(dbPath);
|
|
460
|
+
if (!updates.length) return 0;
|
|
461
|
+
|
|
462
|
+
const db = await openDatabase(dbPath);
|
|
463
|
+
const statement = db.prepare(`
|
|
464
|
+
UPDATE usage_records
|
|
465
|
+
SET estimated_usd = ?,
|
|
466
|
+
estimated_ai_credits = ?,
|
|
467
|
+
warnings_json = ?
|
|
468
|
+
WHERE id = ?
|
|
469
|
+
`);
|
|
470
|
+
let updated = 0;
|
|
471
|
+
db.run('BEGIN');
|
|
472
|
+
try {
|
|
473
|
+
for (const update of updates) {
|
|
474
|
+
statement.run([
|
|
475
|
+
update.estimated_usd,
|
|
476
|
+
update.estimated_ai_credits,
|
|
477
|
+
JSON.stringify(update.warnings || []),
|
|
478
|
+
update.id,
|
|
479
|
+
]);
|
|
480
|
+
updated += typeof db.getRowsModified === 'function' ? db.getRowsModified() : 0;
|
|
481
|
+
}
|
|
482
|
+
db.run('COMMIT');
|
|
483
|
+
} catch (error) {
|
|
484
|
+
db.run('ROLLBACK');
|
|
485
|
+
throw error;
|
|
486
|
+
} finally {
|
|
487
|
+
statement.free();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
persistDatabase(dbPath, db);
|
|
491
|
+
return updated;
|
|
492
|
+
}
|
|
493
|
+
|
|
310
494
|
async function queryOne(dbPath, sql) {
|
|
311
495
|
const db = await openDatabase(dbPath);
|
|
312
496
|
const result = db.exec(sql);
|
|
@@ -329,10 +513,14 @@ async function queryRows(dbPath, sql, params = []) {
|
|
|
329
513
|
}
|
|
330
514
|
|
|
331
515
|
module.exports = {
|
|
516
|
+
attachVscodeChatLabelEvidence,
|
|
332
517
|
existingRawFingerprints,
|
|
333
518
|
importedLineHighWater,
|
|
334
519
|
initStore,
|
|
335
520
|
insertImport,
|
|
336
521
|
queryOne,
|
|
337
522
|
queryRows,
|
|
523
|
+
updateUsageCostEstimates,
|
|
524
|
+
updateVscodeUsageResponseIds,
|
|
525
|
+
vscodeRawRecordsNeedingResponseBackfill,
|
|
338
526
|
};
|