@juspay/neurolink 12.5.2 → 12.6.0
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 +7 -2
- package/dist/browser/neurolink.min.js +397 -397
- package/dist/cli/commands/proxy.js +9 -0
- package/dist/cli/commands/usage.js +15 -1
- package/dist/cli/proxy-clients/copilot.d.ts +3 -0
- package/dist/cli/proxy-clients/copilot.js +39 -0
- package/dist/cli/proxy-clients/registry.js +24 -1
- package/dist/constants/proxyModels.d.ts +14 -0
- package/dist/constants/proxyModels.js +14 -0
- package/dist/localUsage/copilotCliReader.d.ts +52 -0
- package/dist/localUsage/copilotCliReader.js +200 -0
- package/dist/localUsage/geminiCliReader.d.ts +73 -0
- package/dist/localUsage/geminiCliReader.js +319 -0
- package/dist/localUsage/index.js +3 -0
- package/dist/localUsage/localUsageReaderRegistry.js +42 -0
- package/dist/localUsage/qwenCodeReader.d.ts +52 -0
- package/dist/localUsage/qwenCodeReader.js +279 -0
- package/dist/providers/amazonBedrock/loopAdapter.js +14 -2
- package/dist/proxy/clientAttribution.js +39 -3
- package/dist/proxy/proxyTranslationEngine.js +8 -0
- package/dist/types/localUsage.d.ts +72 -1
- package/dist/types/proxyClient.d.ts +17 -0
- package/dist/utils/providerRetry.js +44 -1
- package/package.json +1 -1
|
@@ -2588,6 +2588,12 @@ async function startProxyRuntime(params) {
|
|
|
2588
2588
|
}
|
|
2589
2589
|
if (result.applied) {
|
|
2590
2590
|
logger.always(chalk.green(` ✓ Auto-configured ${result.displayName} settings`));
|
|
2591
|
+
if (result.note) {
|
|
2592
|
+
// A written file is not the same as a live configuration. Printing
|
|
2593
|
+
// the check without this reads as "done" for a client that is still
|
|
2594
|
+
// talking to its own upstream.
|
|
2595
|
+
logger.always(chalk.yellow(` ↳ ${result.note}`));
|
|
2596
|
+
}
|
|
2591
2597
|
logger.always(chalk.dim(` Restart ${result.displayName} to connect through proxy`));
|
|
2592
2598
|
}
|
|
2593
2599
|
}
|
|
@@ -4042,6 +4048,9 @@ export const proxySetupCommand = {
|
|
|
4042
4048
|
}
|
|
4043
4049
|
if (result.applied) {
|
|
4044
4050
|
console.info(chalk.green(` ✓ ${result.displayName} configured`));
|
|
4051
|
+
if (result.note) {
|
|
4052
|
+
console.info(chalk.yellow(` ↳ ${result.note}`));
|
|
4053
|
+
}
|
|
4045
4054
|
}
|
|
4046
4055
|
}
|
|
4047
4056
|
// Done!
|
|
@@ -75,7 +75,11 @@ export class UsageCommandFactory {
|
|
|
75
75
|
// given with no value, which is a mistake and must be rejected — a
|
|
76
76
|
// truthiness check treats the two as the same and silently scans every
|
|
77
77
|
// reader instead, reporting everything for a request that named nothing.
|
|
78
|
-
|
|
78
|
+
// "copilot-cli" is the pre-rename spelling and is still in the published
|
|
79
|
+
// LocalUsageCliId union, so it must keep resolving. Normalised here rather
|
|
80
|
+
// than registered twice: two descriptors for one reader would show the CLI
|
|
81
|
+
// twice in every report.
|
|
82
|
+
const wanted = argv.cli === "copilot-cli" ? "copilot" : argv.cli;
|
|
79
83
|
const known = getLocalUsageDescriptors().map((d) => d.id);
|
|
80
84
|
if (wanted !== undefined &&
|
|
81
85
|
!known.includes(wanted)) {
|
|
@@ -140,6 +144,16 @@ export class UsageCommandFactory {
|
|
|
140
144
|
for (const failure of report.failures) {
|
|
141
145
|
logger.always(chalk.yellow(` ${failure.cliId} failed: ${failure.message}`));
|
|
142
146
|
}
|
|
147
|
+
// A reader that read nine of ten transcripts still reports totals, and
|
|
148
|
+
// those totals are wrong by the tenth. Printed next to the numbers they
|
|
149
|
+
// undercut, because a silently short total is the failure mode this whole
|
|
150
|
+
// command exists to avoid.
|
|
151
|
+
if (report.scanErrors.length > 0) {
|
|
152
|
+
logger.always(chalk.yellow(` ${report.scanErrors.length} file(s) could not be read; totals are incomplete`));
|
|
153
|
+
for (const scanError of report.scanErrors.slice(0, 5)) {
|
|
154
|
+
logger.always(chalk.dim(` ${scanError.cliId}: ${scanError.message}`));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
143
157
|
logger.always("");
|
|
144
158
|
}
|
|
145
159
|
}
|
|
@@ -28,12 +28,15 @@ import type { CliProxyClientConfigurator } from "../../types/index.js";
|
|
|
28
28
|
*/
|
|
29
29
|
declare function getCopilotConfigDir(): string;
|
|
30
30
|
declare function getCopilotEnvPath(): string;
|
|
31
|
+
/** Whether any shell profile already sources the generated script. */
|
|
32
|
+
declare function isEnvScriptSourced(): Promise<boolean>;
|
|
31
33
|
export declare function setCopilotProxySettings(baseUrl: string, proxyKey?: string): Promise<boolean>;
|
|
32
34
|
export declare function clearCopilotProxySettings(expectedBaseUrl?: string): Promise<boolean>;
|
|
33
35
|
export declare const copilotConfigurator: CliProxyClientConfigurator;
|
|
34
36
|
export declare const __copilotTestHooks: {
|
|
35
37
|
getCopilotConfigDir: typeof getCopilotConfigDir;
|
|
36
38
|
getCopilotEnvPath: typeof getCopilotEnvPath;
|
|
39
|
+
isEnvScriptSourced: typeof isEnvScriptSourced;
|
|
37
40
|
setCopilotProxySettings: typeof setCopilotProxySettings;
|
|
38
41
|
clearCopilotProxySettings: typeof clearCopilotProxySettings;
|
|
39
42
|
};
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import { homedir } from "os";
|
|
25
25
|
import { join } from "path";
|
|
26
26
|
import { logger } from "../../utils/logger.js";
|
|
27
|
+
import { DEFAULT_PROXY_MODEL_ID } from "../../constants/proxyModels.js";
|
|
27
28
|
import { writeFileAtomic } from "./snapshot.js";
|
|
28
29
|
/**
|
|
29
30
|
* Resolved per call rather than at module load so `detect()` and `apply()`
|
|
@@ -35,6 +36,31 @@ function getCopilotConfigDir() {
|
|
|
35
36
|
function getCopilotEnvPath() {
|
|
36
37
|
return join(homedir(), ".neurolink", "copilot-env.sh");
|
|
37
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Shell profiles that could plausibly source the script.
|
|
41
|
+
*
|
|
42
|
+
* Checked to answer one question: has the user done the manual half? Writing
|
|
43
|
+
* the file is only half the job, and until the other half happens Copilot
|
|
44
|
+
* talks to GitHub, not the proxy.
|
|
45
|
+
*/
|
|
46
|
+
function getProfileCandidates() {
|
|
47
|
+
return [".zshrc", ".zprofile", ".bashrc", ".bash_profile", ".profile"].map((name) => join(homedir(), name));
|
|
48
|
+
}
|
|
49
|
+
/** Whether any shell profile already sources the generated script. */
|
|
50
|
+
async function isEnvScriptSourced() {
|
|
51
|
+
const fs = await import("fs");
|
|
52
|
+
for (const profile of getProfileCandidates()) {
|
|
53
|
+
try {
|
|
54
|
+
if (fs.readFileSync(profile, "utf8").includes("copilot-env.sh")) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Absent or unreadable profile — not evidence either way, keep looking.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
38
64
|
function buildCopilotEnvScript(baseUrl, proxyKey) {
|
|
39
65
|
return [
|
|
40
66
|
"# Generated by `neurolink proxy` — do not edit.",
|
|
@@ -46,6 +72,11 @@ function buildCopilotEnvScript(baseUrl, proxyKey) {
|
|
|
46
72
|
'export COPILOT_PROVIDER_TYPE="openai"',
|
|
47
73
|
`export COPILOT_PROVIDER_BASE_URL="${baseUrl}"`,
|
|
48
74
|
`export COPILOT_PROVIDER_API_KEY="${proxyKey}"`,
|
|
75
|
+
// Without a model id Copilot's BYOK path refuses to start — "BYOK
|
|
76
|
+
// providers require an explicit model" — so a script that stops at the
|
|
77
|
+
// base URL still needs `--model` on every invocation. Export a default and
|
|
78
|
+
// let the user override it after sourcing.
|
|
79
|
+
`export COPILOT_PROVIDER_MODEL_ID="\${COPILOT_PROVIDER_MODEL_ID:-${DEFAULT_PROXY_MODEL_ID}}"`,
|
|
49
80
|
"",
|
|
50
81
|
].join("\n");
|
|
51
82
|
}
|
|
@@ -101,6 +132,13 @@ export async function clearCopilotProxySettings(expectedBaseUrl) {
|
|
|
101
132
|
export const copilotConfigurator = {
|
|
102
133
|
id: "copilot",
|
|
103
134
|
displayName: "Copilot CLI",
|
|
135
|
+
// Copilot is the only client whose apply() can fully succeed and still have
|
|
136
|
+
// no effect: the script it writes does nothing until a shell profile sources
|
|
137
|
+
// it. Without this note the proxy prints a green check for a file nothing
|
|
138
|
+
// reads, which is indistinguishable from working.
|
|
139
|
+
postApplyNote: async () => (await isEnvScriptSourced())
|
|
140
|
+
? null
|
|
141
|
+
: "Copilot reads provider settings from the environment only. Add this line to your shell profile, then open a new shell:\n [ -f ~/.neurolink/copilot-env.sh ] && . ~/.neurolink/copilot-env.sh",
|
|
104
142
|
detect: async () => {
|
|
105
143
|
const fs = await import("fs");
|
|
106
144
|
try {
|
|
@@ -118,6 +156,7 @@ export const copilotConfigurator = {
|
|
|
118
156
|
export const __copilotTestHooks = {
|
|
119
157
|
getCopilotConfigDir,
|
|
120
158
|
getCopilotEnvPath,
|
|
159
|
+
isEnvScriptSourced,
|
|
121
160
|
setCopilotProxySettings,
|
|
122
161
|
clearCopilotProxySettings,
|
|
123
162
|
};
|
|
@@ -34,7 +34,30 @@ export async function applyAllClients(proxyBaseUrl) {
|
|
|
34
34
|
const applied = (await client.detect())
|
|
35
35
|
? await client.apply(proxyBaseUrl)
|
|
36
36
|
: false;
|
|
37
|
-
|
|
37
|
+
// Only ask for a note when something was actually written: a note on a
|
|
38
|
+
// client that was skipped would read as an instruction to act on a
|
|
39
|
+
// configuration that does not exist.
|
|
40
|
+
//
|
|
41
|
+
// Computed in its own try, outside apply()'s. A note is advisory; a
|
|
42
|
+
// throwing implementation must not be able to turn a successful write
|
|
43
|
+
// into `applied: false` and an error the caller reports as a failed
|
|
44
|
+
// configuration. Dormant today only because the one implementation
|
|
45
|
+
// swallows its own errors, which is not a property to rely on.
|
|
46
|
+
let note = null;
|
|
47
|
+
if (applied) {
|
|
48
|
+
try {
|
|
49
|
+
note = (await client.postApplyNote?.(proxyBaseUrl)) ?? null;
|
|
50
|
+
}
|
|
51
|
+
catch (noteError) {
|
|
52
|
+
logger.debug(`[proxy] ${client.id} post-apply note failed: ${noteError instanceof Error ? noteError.message : String(noteError)}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
results.push({
|
|
56
|
+
id: client.id,
|
|
57
|
+
displayName: client.displayName,
|
|
58
|
+
applied,
|
|
59
|
+
...(note === null ? {} : { note }),
|
|
60
|
+
});
|
|
38
61
|
}
|
|
39
62
|
catch (error) {
|
|
40
63
|
const wrapped = error instanceof Error ? error : new Error(String(error));
|
|
@@ -20,3 +20,17 @@
|
|
|
20
20
|
* better than the empty map it replaces.
|
|
21
21
|
*/
|
|
22
22
|
export declare const DEFAULT_PROXY_MODEL_IDS: readonly string[];
|
|
23
|
+
/**
|
|
24
|
+
* The model a client is pointed at when it requires one and the user has not
|
|
25
|
+
* chosen.
|
|
26
|
+
*
|
|
27
|
+
* Copilot's BYOK path refuses to start without an explicit model —
|
|
28
|
+
* `copilot -p "..."` fails with "BYOK providers require an explicit model" —
|
|
29
|
+
* so a configurator that writes a base URL and stops has produced something
|
|
30
|
+
* unusable without an extra flag on every invocation.
|
|
31
|
+
*
|
|
32
|
+
* Sonnet rather than Opus or Haiku, matching the reasoning already recorded on
|
|
33
|
+
* DEFAULT_MODELS_BY_TIER's `api` entry in src/lib/models/anthropicModels.ts:
|
|
34
|
+
* the balance most callers want when nobody has expressed a preference.
|
|
35
|
+
*/
|
|
36
|
+
export declare const DEFAULT_PROXY_MODEL_ID = "claude-sonnet-4-6";
|
|
@@ -34,3 +34,17 @@ export const DEFAULT_PROXY_MODEL_IDS = [
|
|
|
34
34
|
"gemini-2.5-pro",
|
|
35
35
|
"gemini-2.5-flash",
|
|
36
36
|
];
|
|
37
|
+
/**
|
|
38
|
+
* The model a client is pointed at when it requires one and the user has not
|
|
39
|
+
* chosen.
|
|
40
|
+
*
|
|
41
|
+
* Copilot's BYOK path refuses to start without an explicit model —
|
|
42
|
+
* `copilot -p "..."` fails with "BYOK providers require an explicit model" —
|
|
43
|
+
* so a configurator that writes a base URL and stops has produced something
|
|
44
|
+
* unusable without an extra flag on every invocation.
|
|
45
|
+
*
|
|
46
|
+
* Sonnet rather than Opus or Haiku, matching the reasoning already recorded on
|
|
47
|
+
* DEFAULT_MODELS_BY_TIER's `api` entry in src/lib/models/anthropicModels.ts:
|
|
48
|
+
* the balance most callers want when nobody has expressed a preference.
|
|
49
|
+
*/
|
|
50
|
+
export const DEFAULT_PROXY_MODEL_ID = "claude-sonnet-4-6";
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads token usage out of Copilot CLI's local SQLite store.
|
|
3
|
+
*
|
|
4
|
+
* Store: `~/.copilot/session-store.db`. Usage lives on the
|
|
5
|
+
* `assistant_usage_events` table — one INSERT-only row per assistant turn,
|
|
6
|
+
* with `model`, `input_tokens`, `output_tokens`, `cache_read_tokens`,
|
|
7
|
+
* `cache_write_tokens`, `reasoning_tokens` and a `created_at` timestamp
|
|
8
|
+
* (ISO-8601 with a `Z` suffix). Confirmed no duplicate `(session_id,
|
|
9
|
+
* turn_index)` pair exists on a reference machine, so — like OpenCode's
|
|
10
|
+
* `message` table — this needs no dedup logic beyond the table's own
|
|
11
|
+
* uniqueness: every row is counted once, matching the `rowid-high-water-mark`
|
|
12
|
+
* strategy already established for that reader.
|
|
13
|
+
*
|
|
14
|
+
* The same database also holds a JSONL-adjacent `session.shutdown` event with
|
|
15
|
+
* its own `modelMetrics`/`tokenDetails` payload (written to
|
|
16
|
+
* `~/.copilot/session-state/*\/events.jsonl`, not this database). It was
|
|
17
|
+
* deliberately NOT used here: cross-checking real sessions confirmed the two
|
|
18
|
+
* sources report identical totals for the sessions they both cover, so
|
|
19
|
+
* combining them would double-count rather than add coverage. The SQLite
|
|
20
|
+
* table is the sole source; the tradeoff is a disclosed gap for any history
|
|
21
|
+
* that predates the table's introduction.
|
|
22
|
+
*
|
|
23
|
+
* `cache_read_tokens` and `cache_write_tokens` are both SUBSETS of
|
|
24
|
+
* `input_tokens`, not disjoint — confirmed arithmetically against a real
|
|
25
|
+
* `session.shutdown` record (session `c2c38d0b`): `tokenDetails.input.tokenCount
|
|
26
|
+
* = 3`, `cache_write = 24047`, and `usage.inputTokens = 24050` — exactly
|
|
27
|
+
* `3 + 24047`, with nothing left over for a separate additive interpretation.
|
|
28
|
+
* So `inputTokens` here is `input_tokens - cache_read_tokens -
|
|
29
|
+
* cache_write_tokens`, the same style of subtraction `codexReader.ts`,
|
|
30
|
+
* `qwenCodeReader.ts` and `geminiCliReader.ts` make for the same reason: this
|
|
31
|
+
* subsystem's `LocalUsageTotals.inputTokens` + `.cacheReadTokens` must sum to
|
|
32
|
+
* the true prompt size without double-counting. `reasoning_tokens` folds into
|
|
33
|
+
* output — Copilot's own SDK types (`ShutdownModelMetricUsage`) group it
|
|
34
|
+
* alongside `outputTokens` as a further breakdown of output, not a separate
|
|
35
|
+
* accounting bucket.
|
|
36
|
+
*
|
|
37
|
+
* Cost is deliberately `unavailable`. The table's own `request_multiplier`
|
|
38
|
+
* and `total_nano_aiu` columns are Copilot's request-quota accounting, not a
|
|
39
|
+
* USD figure — the SDK's own generated types tag the closest concept,
|
|
40
|
+
* `ShutdownModelMetricUsage.cost`, `@experimental`, and every real sample row
|
|
41
|
+
* on the reference machine had `request_multiplier: 0.0`. Modeling a
|
|
42
|
+
* per-token dollar figure from public list prices would also ignore that
|
|
43
|
+
* Copilot CLI is typically used under a flat-rate Copilot subscription, the
|
|
44
|
+
* same reasoning that keeps Codex's confidence `unavailable`. This holds even
|
|
45
|
+
* when the underlying model is a metered vendor's (e.g. `claude-haiku-4-5` on
|
|
46
|
+
* the reference machine) — Copilot's telemetry layer normalizes across
|
|
47
|
+
* backend vendors into its own accounting convention, and this reader has no
|
|
48
|
+
* way to tell whether a given row was billed by the minute, by the token, or
|
|
49
|
+
* not separately billed at all.
|
|
50
|
+
*/
|
|
51
|
+
import type { LocalUsageReader } from "../types/index.js";
|
|
52
|
+
export declare function createCopilotCliReader(): Promise<LocalUsageReader>;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads token usage out of Copilot CLI's local SQLite store.
|
|
3
|
+
*
|
|
4
|
+
* Store: `~/.copilot/session-store.db`. Usage lives on the
|
|
5
|
+
* `assistant_usage_events` table — one INSERT-only row per assistant turn,
|
|
6
|
+
* with `model`, `input_tokens`, `output_tokens`, `cache_read_tokens`,
|
|
7
|
+
* `cache_write_tokens`, `reasoning_tokens` and a `created_at` timestamp
|
|
8
|
+
* (ISO-8601 with a `Z` suffix). Confirmed no duplicate `(session_id,
|
|
9
|
+
* turn_index)` pair exists on a reference machine, so — like OpenCode's
|
|
10
|
+
* `message` table — this needs no dedup logic beyond the table's own
|
|
11
|
+
* uniqueness: every row is counted once, matching the `rowid-high-water-mark`
|
|
12
|
+
* strategy already established for that reader.
|
|
13
|
+
*
|
|
14
|
+
* The same database also holds a JSONL-adjacent `session.shutdown` event with
|
|
15
|
+
* its own `modelMetrics`/`tokenDetails` payload (written to
|
|
16
|
+
* `~/.copilot/session-state/*\/events.jsonl`, not this database). It was
|
|
17
|
+
* deliberately NOT used here: cross-checking real sessions confirmed the two
|
|
18
|
+
* sources report identical totals for the sessions they both cover, so
|
|
19
|
+
* combining them would double-count rather than add coverage. The SQLite
|
|
20
|
+
* table is the sole source; the tradeoff is a disclosed gap for any history
|
|
21
|
+
* that predates the table's introduction.
|
|
22
|
+
*
|
|
23
|
+
* `cache_read_tokens` and `cache_write_tokens` are both SUBSETS of
|
|
24
|
+
* `input_tokens`, not disjoint — confirmed arithmetically against a real
|
|
25
|
+
* `session.shutdown` record (session `c2c38d0b`): `tokenDetails.input.tokenCount
|
|
26
|
+
* = 3`, `cache_write = 24047`, and `usage.inputTokens = 24050` — exactly
|
|
27
|
+
* `3 + 24047`, with nothing left over for a separate additive interpretation.
|
|
28
|
+
* So `inputTokens` here is `input_tokens - cache_read_tokens -
|
|
29
|
+
* cache_write_tokens`, the same style of subtraction `codexReader.ts`,
|
|
30
|
+
* `qwenCodeReader.ts` and `geminiCliReader.ts` make for the same reason: this
|
|
31
|
+
* subsystem's `LocalUsageTotals.inputTokens` + `.cacheReadTokens` must sum to
|
|
32
|
+
* the true prompt size without double-counting. `reasoning_tokens` folds into
|
|
33
|
+
* output — Copilot's own SDK types (`ShutdownModelMetricUsage`) group it
|
|
34
|
+
* alongside `outputTokens` as a further breakdown of output, not a separate
|
|
35
|
+
* accounting bucket.
|
|
36
|
+
*
|
|
37
|
+
* Cost is deliberately `unavailable`. The table's own `request_multiplier`
|
|
38
|
+
* and `total_nano_aiu` columns are Copilot's request-quota accounting, not a
|
|
39
|
+
* USD figure — the SDK's own generated types tag the closest concept,
|
|
40
|
+
* `ShutdownModelMetricUsage.cost`, `@experimental`, and every real sample row
|
|
41
|
+
* on the reference machine had `request_multiplier: 0.0`. Modeling a
|
|
42
|
+
* per-token dollar figure from public list prices would also ignore that
|
|
43
|
+
* Copilot CLI is typically used under a flat-rate Copilot subscription, the
|
|
44
|
+
* same reasoning that keeps Codex's confidence `unavailable`. This holds even
|
|
45
|
+
* when the underlying model is a metered vendor's (e.g. `claude-haiku-4-5` on
|
|
46
|
+
* the reference machine) — Copilot's telemetry layer normalizes across
|
|
47
|
+
* backend vendors into its own accounting convention, and this reader has no
|
|
48
|
+
* way to tell whether a given row was billed by the minute, by the token, or
|
|
49
|
+
* not separately billed at all.
|
|
50
|
+
*/
|
|
51
|
+
import { stat } from "fs/promises";
|
|
52
|
+
import { homedir } from "os";
|
|
53
|
+
import { join } from "path";
|
|
54
|
+
import { resolveScanCutoffMs } from "./scanWindow.js";
|
|
55
|
+
const CLI_ID = "copilot";
|
|
56
|
+
function databasePath() {
|
|
57
|
+
return join(homedir(), ".copilot", "session-store.db");
|
|
58
|
+
}
|
|
59
|
+
function emptyTotals() {
|
|
60
|
+
return {
|
|
61
|
+
requests: 0,
|
|
62
|
+
inputTokens: 0,
|
|
63
|
+
outputTokens: 0,
|
|
64
|
+
cacheReadTokens: 0,
|
|
65
|
+
cacheCreationTokens: 0,
|
|
66
|
+
costUsd: 0,
|
|
67
|
+
costConfidence: "unavailable",
|
|
68
|
+
unpricedRequests: 0,
|
|
69
|
+
unpricedModels: [],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function num(value) {
|
|
73
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* `created_at` is an ISO-8601-with-`Z` text column, so the cutoff needs to be
|
|
77
|
+
* the same shape to sort and compare correctly in SQL. `Infinity`/`undefined`
|
|
78
|
+
* (no filter) becomes the epoch, matching `openCodeReader.ts`'s `?? 0`
|
|
79
|
+
* convention of expressing "unbounded" as "since the earliest possible time"
|
|
80
|
+
* rather than leaving the bound parameter out.
|
|
81
|
+
*/
|
|
82
|
+
function cutoffIso(sinceDays) {
|
|
83
|
+
const cutoffMs = resolveScanCutoffMs(sinceDays) ?? 0;
|
|
84
|
+
return new Date(cutoffMs).toISOString();
|
|
85
|
+
}
|
|
86
|
+
export async function createCopilotCliReader() {
|
|
87
|
+
return {
|
|
88
|
+
descriptor: {
|
|
89
|
+
id: CLI_ID,
|
|
90
|
+
displayName: "Copilot CLI",
|
|
91
|
+
verified: true,
|
|
92
|
+
dedupStrategy: "rowid-high-water-mark",
|
|
93
|
+
costConfidence: "unavailable",
|
|
94
|
+
requiresSqlite: true,
|
|
95
|
+
},
|
|
96
|
+
detect: async () => {
|
|
97
|
+
try {
|
|
98
|
+
const info = await stat(databasePath());
|
|
99
|
+
return info.isFile();
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
scan: async (options) => {
|
|
106
|
+
const totals = emptyTotals();
|
|
107
|
+
const errors = [];
|
|
108
|
+
const models = new Set();
|
|
109
|
+
const dbPath = databasePath();
|
|
110
|
+
// `node:sqlite` is experimental and may be absent or change shape — the
|
|
111
|
+
// same lazy, validated import `openCodeReader.ts` uses, so one runtime
|
|
112
|
+
// missing it degrades to a reported failure for this reader alone.
|
|
113
|
+
let DatabaseSync;
|
|
114
|
+
try {
|
|
115
|
+
const sqlite = await import("node:sqlite");
|
|
116
|
+
if (typeof sqlite === "object" &&
|
|
117
|
+
sqlite !== null &&
|
|
118
|
+
"DatabaseSync" in sqlite &&
|
|
119
|
+
typeof sqlite.DatabaseSync ===
|
|
120
|
+
"function") {
|
|
121
|
+
DatabaseSync = sqlite.DatabaseSync;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
errors.push({
|
|
126
|
+
cliId: CLI_ID,
|
|
127
|
+
filePath: dbPath,
|
|
128
|
+
message: `node:sqlite unavailable on this runtime: ${error instanceof Error ? error.message : String(error)}`,
|
|
129
|
+
});
|
|
130
|
+
return { cliId: CLI_ID, totals, filesScanned: 0, errors };
|
|
131
|
+
}
|
|
132
|
+
if (!DatabaseSync) {
|
|
133
|
+
errors.push({
|
|
134
|
+
cliId: CLI_ID,
|
|
135
|
+
filePath: dbPath,
|
|
136
|
+
message: "node:sqlite did not expose a callable DatabaseSync — the experimental API has likely changed shape",
|
|
137
|
+
});
|
|
138
|
+
return { cliId: CLI_ID, totals, filesScanned: 0, errors };
|
|
139
|
+
}
|
|
140
|
+
const cutoff = cutoffIso(options?.sinceDays);
|
|
141
|
+
let db;
|
|
142
|
+
try {
|
|
143
|
+
// Read-only: this is the user's live store and Copilot CLI may be
|
|
144
|
+
// running. Bound parameter, not interpolation — never splice a
|
|
145
|
+
// caller-influenced value into SQL text.
|
|
146
|
+
db = new DatabaseSync(dbPath, { readOnly: true });
|
|
147
|
+
const rows = db
|
|
148
|
+
.prepare("SELECT model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, created_at FROM assistant_usage_events WHERE created_at >= ?")
|
|
149
|
+
.all(cutoff);
|
|
150
|
+
for (const row of rows) {
|
|
151
|
+
const inputTokens = num(row.input_tokens);
|
|
152
|
+
const outputTokens = num(row.output_tokens);
|
|
153
|
+
const cacheRead = num(row.cache_read_tokens);
|
|
154
|
+
const cacheWrite = num(row.cache_write_tokens);
|
|
155
|
+
const reasoning = num(row.reasoning_tokens);
|
|
156
|
+
if (inputTokens === 0 &&
|
|
157
|
+
outputTokens === 0 &&
|
|
158
|
+
cacheRead === 0 &&
|
|
159
|
+
cacheWrite === 0 &&
|
|
160
|
+
reasoning === 0) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
totals.requests += 1;
|
|
164
|
+
totals.inputTokens += Math.max(0, inputTokens - cacheRead - cacheWrite);
|
|
165
|
+
totals.outputTokens += outputTokens + reasoning;
|
|
166
|
+
totals.cacheReadTokens += cacheRead;
|
|
167
|
+
totals.cacheCreationTokens += cacheWrite;
|
|
168
|
+
// Every counted row contributes a name. A null model still raised
|
|
169
|
+
// unpricedRequests, so skipping it here produced a report saying N
|
|
170
|
+
// turns went unpriced while naming fewer than N models — the operator
|
|
171
|
+
// is then chasing a gap the report refuses to identify.
|
|
172
|
+
models.add(row.model ?? "unknown");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
errors.push({
|
|
177
|
+
cliId: CLI_ID,
|
|
178
|
+
filePath: dbPath,
|
|
179
|
+
message: error instanceof Error ? error.message : String(error),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
try {
|
|
184
|
+
db?.close();
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// Closing a database that failed to open is not a second failure.
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
totals.unpricedRequests = totals.requests;
|
|
191
|
+
totals.unpricedModels = [...models].sort();
|
|
192
|
+
return {
|
|
193
|
+
cliId: CLI_ID,
|
|
194
|
+
totals,
|
|
195
|
+
filesScanned: totals.requests > 0 || errors.length === 0 ? 1 : 0,
|
|
196
|
+
errors,
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads token usage out of Gemini CLI's own session transcripts.
|
|
3
|
+
*
|
|
4
|
+
* Layout, confirmed on a real machine (18 files spanning June–August 2026):
|
|
5
|
+
* `~/.gemini/tmp/<projectSlug>/chats/session-<timestamp>-<id>.jsonl`. The
|
|
6
|
+
* sibling `~/.gemini/history/<projectSlug>/` directories are a git-based
|
|
7
|
+
* shadow-history of edited files, not chat logs — no JSONL lives there.
|
|
8
|
+
*
|
|
9
|
+
* Each file is a small append-only patch log rather than one JSON object per
|
|
10
|
+
* line of "the same shape": line 0 is a header (`sessionId`, `projectHash`,
|
|
11
|
+
* `startTime`, `lastUpdated`, `kind`); every line after that is either
|
|
12
|
+
* `{"$set": {...}}` (a partial merge — `messages` on session bootstrap,
|
|
13
|
+
* scalar fields like `lastUpdated`/`summary` afterwards) or a bare message
|
|
14
|
+
* object appended directly. Confirmed on the installed CLI's own bundled
|
|
15
|
+
* source (`@google/gemini-cli` `ChatRecordingService.pushMessage`): a bare
|
|
16
|
+
* append is `appendRecord(msg)` with no wrapper, which is why real transcripts
|
|
17
|
+
* mix both shapes. This reader unwraps `$set.messages[]` (only ever seen once
|
|
18
|
+
* per file, holding the single injected `<session_context>` bootstrap
|
|
19
|
+
* message) and reads bare objects that carry `id`/`type` directly.
|
|
20
|
+
*
|
|
21
|
+
* The installed source also revealed a real double-write race, invisible in
|
|
22
|
+
* this machine's own samples (0 duplicate ids across all 18 files) but
|
|
23
|
+
* reachable on any machine: `recordMessage()` first pushes a `type: "gemini"`
|
|
24
|
+
* message with whatever `tokens` value is already queued (often `null`,
|
|
25
|
+
* before the response's usage metadata has arrived), and
|
|
26
|
+
* `recordMessageTokens()` — called separately once usage arrives — re-pushes
|
|
27
|
+
* the SAME message id with `tokens` now filled in if it finds the last
|
|
28
|
+
* message still token-less. `pushMessage()` unconditionally appends a new
|
|
29
|
+
* line every time it is called, even for an id it has already written. So the
|
|
30
|
+
* same `id` can legitimately appear twice: once without tokens, once with.
|
|
31
|
+
* Dedup here keeps, per id, whichever record has the larger `tokens.total`
|
|
32
|
+
* (a record with no tokens contributes 0), which is correct for both that
|
|
33
|
+
* race and an ordinary resumed-session replay. The map is per-file, mirroring
|
|
34
|
+
* `claudeCodeReader.ts`.
|
|
35
|
+
*
|
|
36
|
+
* `tokens` is `{input, output, cached, thoughts, tool, total}`, and the source
|
|
37
|
+
* (`recordMessageTokens`) maps it straight from the GenAI response's own
|
|
38
|
+
* `usageMetadata` — `input = promptTokenCount`, `output = candidatesTokenCount`,
|
|
39
|
+
* `cached = cachedContentTokenCount`, `thoughts = thoughtsTokenCount`,
|
|
40
|
+
* `tool = toolUsePromptTokenCount`, `total = totalTokenCount`. Real data
|
|
41
|
+
* confirms `total = input + output + thoughts + tool` and that `cached` is a
|
|
42
|
+
* SUBSET of `input`: the one real record with nonzero cache had
|
|
43
|
+
* `input: 12121, cached: 4073`, and `total (12344) = input (12121) + output
|
|
44
|
+
* (1) + thoughts (222)` — cached tokens are not added on top of input, they
|
|
45
|
+
* are already inside it. So `inputTokens` here is `input - cached`, the same
|
|
46
|
+
* subtraction `codexReader.ts` and `qwenCodeReader.ts` make for the same
|
|
47
|
+
* reason: this subsystem's `LocalUsageTotals.inputTokens` +
|
|
48
|
+
* `.cacheReadTokens` must sum to the true prompt size without double-counting.
|
|
49
|
+
* No cache-CREATION concept exists in this API family's usage metadata, so
|
|
50
|
+
* `cacheCreationTokens` stays 0.
|
|
51
|
+
*
|
|
52
|
+
* The write path also nests subagent transcripts one level deeper —
|
|
53
|
+
* `chats/<parentSessionId>/<subagentSessionId>.jsonl`, per the same source —
|
|
54
|
+
* so this reader recurses under `chats/` rather than globbing one level, even
|
|
55
|
+
* though no nested subagent file exists on the machine this was verified
|
|
56
|
+
* against.
|
|
57
|
+
*
|
|
58
|
+
* Cost is deliberately `unavailable`. Gemini CLI supports three genuinely
|
|
59
|
+
* different auth modes with unrelated billing (`gemini-api-key`: metered, and
|
|
60
|
+
* the Flash models specifically have a real free tier; `oauth-personal`: free,
|
|
61
|
+
* rate-limited; `vertex-ai`: billed to a GCP project at negotiated rates), and
|
|
62
|
+
* no session record — not the header, not a message — carries which mode was
|
|
63
|
+
* active when it was written. `settings.json` records only the CURRENT mode,
|
|
64
|
+
* which cannot be projected onto historical sessions. Modeling a per-token
|
|
65
|
+
* dollar figure from the public API price list would misrepresent every
|
|
66
|
+
* free-tier or Vertex-billed session as if it were pay-as-you-go. Concretely,
|
|
67
|
+
* on the reference machine even the majority model logged (`gemini-3.5-flash`,
|
|
68
|
+
* 12 of 14 sampled turns) has no entry in `pricing.ts` at all — only the
|
|
69
|
+
* minority model (`gemini-3-flash-preview`) does — so most real traffic would
|
|
70
|
+
* be unpriced regardless.
|
|
71
|
+
*/
|
|
72
|
+
import type { LocalUsageReader } from "../types/index.js";
|
|
73
|
+
export declare function createGeminiCliReader(): Promise<LocalUsageReader>;
|