log-llm-config 1.5.10 → 1.5.11

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.
@@ -14,6 +14,13 @@ import { homedir } from 'node:os';
14
14
  * ONLY those (email, name, plan, account) — never the token, access_token, or API key. Configured
15
15
  * model is read from ~/.codex/config.toml. Numbers + identity claims only — no transcript content.
16
16
  *
17
+ * Routing config also comes from a scoped, section-aware line scan of config.toml (see
18
+ * readCodexRoutingConfig): top-level `model_provider`/`openai_base_url`/`oss_provider`, and each
19
+ * `[model_providers.<id>]` table's `base_url`/`wire_api` keys only — never `env_key`,
20
+ * `experimental_bearer_token`, `auth.*`, or any other key that can carry credential material or
21
+ * a command string. `[model_providers.amazon-bedrock(.aws)]`'s mere presence is recorded (not its
22
+ * region/profile values) since selecting that built-in provider id is itself the Bedrock signal.
23
+ *
17
24
  * Bounded by rollout-file mtime recency so a long session history isn't fully re-parsed each run.
18
25
  */
19
26
  const TOKEN_USAGE_RECENCY_DAYS = 30;
@@ -117,6 +124,90 @@ function readConfiguredModel(home) {
117
124
  return '';
118
125
  }
119
126
  }
127
+ function emptyCodexRoutingConfig() {
128
+ return {
129
+ modelProvider: '',
130
+ openaiBaseUrl: '',
131
+ ossProvider: '',
132
+ hasBedrockConfig: false,
133
+ customProviders: {},
134
+ };
135
+ }
136
+ /** Strip quotes from a TOML string value and any trailing inline comment. */
137
+ function stripTomlString(value) {
138
+ const trimmed = value.trim();
139
+ if (trimmed.startsWith('"')) {
140
+ const end = trimmed.indexOf('"', 1);
141
+ return end !== -1 ? trimmed.slice(1, end) : trimmed.slice(1);
142
+ }
143
+ if (trimmed.startsWith("'")) {
144
+ const end = trimmed.indexOf("'", 1);
145
+ return end !== -1 ? trimmed.slice(1, end) : trimmed.slice(1);
146
+ }
147
+ const commentIdx = trimmed.indexOf('#');
148
+ return (commentIdx === -1 ? trimmed : trimmed.slice(0, commentIdx)).trim();
149
+ }
150
+ /**
151
+ * Scoped, section-aware scan of config.toml for routing-relevant keys ONLY:
152
+ * - top-level `model_provider` / `openai_base_url` / `oss_provider`
153
+ * - each `[model_providers.<id>]` table's `base_url` / `wire_api` keys
154
+ * - `[model_providers.amazon-bedrock]` (or its `.aws` sub-table) presence only — never its
155
+ * region/profile values
156
+ * Deliberately not a full TOML parse: every other key in every other section (MCP servers,
157
+ * hooks, marketplaces, `model_providers.<id>.env_key`/`auth.*`/`experimental_bearer_token`, ...)
158
+ * is never read, since some of those can carry credential material or shell commands.
159
+ */
160
+ function readCodexRoutingConfig(home) {
161
+ const result = emptyCodexRoutingConfig();
162
+ let text;
163
+ try {
164
+ text = readFileSync(join(home, '.codex', 'config.toml'), 'utf8');
165
+ }
166
+ catch {
167
+ return result;
168
+ }
169
+ let currentProviderId = '';
170
+ for (const rawLine of text.split('\n')) {
171
+ const line = rawLine.trim();
172
+ if (!line || line.startsWith('#'))
173
+ continue;
174
+ const sectionMatch = line.match(/^\[([^\]]+)\]$/);
175
+ if (sectionMatch) {
176
+ const section = sectionMatch[1].trim();
177
+ currentProviderId = '';
178
+ if (/^model_providers\.amazon-bedrock(\.aws)?$/.test(section)) {
179
+ result.hasBedrockConfig = true;
180
+ continue;
181
+ }
182
+ const providerMatch = section.match(/^model_providers\.([^.]+)$/);
183
+ if (providerMatch)
184
+ currentProviderId = providerMatch[1].replace(/^["']|["']$/g, '');
185
+ continue;
186
+ }
187
+ const kv = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*=\s*(.*)$/);
188
+ if (!kv)
189
+ continue;
190
+ const [, key, rawValue] = kv;
191
+ if (!currentProviderId) {
192
+ if (key === 'model_provider')
193
+ result.modelProvider = stripTomlString(rawValue);
194
+ else if (key === 'openai_base_url')
195
+ result.openaiBaseUrl = stripTomlString(rawValue);
196
+ else if (key === 'oss_provider')
197
+ result.ossProvider = stripTomlString(rawValue);
198
+ continue;
199
+ }
200
+ if (key === 'base_url' || key === 'wire_api') {
201
+ const entry = result.customProviders[currentProviderId] ?? { baseUrl: '', wireApi: '' };
202
+ if (key === 'base_url')
203
+ entry.baseUrl = stripTomlString(rawValue);
204
+ else
205
+ entry.wireApi = stripTomlString(rawValue);
206
+ result.customProviders[currentProviderId] = entry;
207
+ }
208
+ }
209
+ return result;
210
+ }
120
211
  /**
121
212
  * Aggregate Codex token usage / sessions / version across recent rollout transcripts + identity.
122
213
  * Returns a single-element array (one per-machine aggregate) or [] when there is no recent data.
@@ -221,6 +312,7 @@ function collectCodexTokenUsage(home = homedir()) {
221
312
  }
222
313
  const identity = readCodexIdentity(home);
223
314
  const configuredModel = readConfiguredModel(home);
315
+ const routingConfig = readCodexRoutingConfig(home);
224
316
  const sawUsage = files.length > 0;
225
317
  if (!sawUsage && !identity.email)
226
318
  return [];
@@ -237,6 +329,11 @@ function collectCodexTokenUsage(home = homedir()) {
237
329
  configured_model: configuredModel,
238
330
  window_days: TOKEN_USAGE_RECENCY_DAYS,
239
331
  totals,
332
+ model_provider: routingConfig.modelProvider,
333
+ openai_base_url: routingConfig.openaiBaseUrl,
334
+ oss_provider: routingConfig.ossProvider,
335
+ has_bedrock_config: routingConfig.hasBedrockConfig,
336
+ custom_model_providers: routingConfig.customProviders,
240
337
  ...identity, // auth_mode, email, name, plan, account_id, org_name (claims only)
241
338
  },
242
339
  },
@@ -10,6 +10,15 @@ import { homedir } from 'node:os';
10
10
  * `totalPremiumRequests`, and per-model `modelMetrics`. We aggregate the *numbers* (and version /
11
11
  * model ids) on the endpoint and emit only those — never transcript content.
12
12
  *
13
+ * Per-model split uses each model's `modelMetrics.<model>.tokenDetails` — NOT its sibling
14
+ * `modelMetrics.<model>.usage` — because `usage.{input,output,cacheRead}Tokens` counts a larger,
15
+ * differently-scoped quantity (confirmed on a real transcript: usage.inputTokens was ~25x the
16
+ * session's own top-level tokenDetails.input.tokenCount for the same shutdown event, apparently
17
+ * including reprocessed/cumulative context rather than the exchange delta). Each model's nested
18
+ * `tokenDetails` mirrors the top-level `tokenDetails` shape and, for a single-model session,
19
+ * its exact values — so summing nested tokenDetails per model stays consistent with the
20
+ * top-level `totals` this same function computes, unlike `usage`.
21
+ *
13
22
  * Bounded by mtime recency so a large session history is not fully re-parsed on every hook run.
14
23
  */
15
24
  const TOKEN_USAGE_RECENCY_DAYS = 30;
@@ -123,8 +132,12 @@ function collectCopilotTokenUsage(home = homedir()) {
123
132
  const modelMetrics = asObject(data.modelMetrics);
124
133
  for (const [model, metrics] of Object.entries(modelMetrics)) {
125
134
  models.add(model);
126
- const usage = asObject(asObject(metrics).usage);
127
- const sum = num(usage.inputTokens) + num(usage.outputTokens) + num(usage.cacheReadTokens);
135
+ // tokenDetails, not usage see the module docstring's note on why usage.* isn't
136
+ // comparable to the top-level tokenDetails-derived `totals`.
137
+ const modelTokenDetails = asObject(asObject(metrics).tokenDetails);
138
+ const sum = num(asObject(modelTokenDetails.input).tokenCount) +
139
+ num(asObject(modelTokenDetails.output).tokenCount) +
140
+ num(asObject(modelTokenDetails.cache_read).tokenCount);
128
141
  if (sum > 0)
129
142
  modelTokenSplit[model] = (modelTokenSplit[model] ?? 0) + sum;
130
143
  }
@@ -1,8 +1,7 @@
1
- import { execFileSync } from 'node:child_process';
2
1
  import { existsSync } from 'node:fs';
3
2
  import { join } from 'node:path';
4
3
  import { homedir } from 'node:os';
5
- import { resolveSqlite3Binary } from '../runtime/sqlite_binary.js';
4
+ import { execSqlite3, resolveSqlite3Binary } from '../runtime/sqlite_binary.js';
6
5
  /**
7
6
  * Session / version / identity aggregator for Cursor.
8
7
  *
@@ -15,7 +14,9 @@ import { resolveSqlite3Binary } from '../runtime/sqlite_binary.js';
15
14
  *
16
15
  * We read identity (email, plan, version) from ItemTable and aggregate session counts +
17
16
  * model names from composerData entries in cursorDiskKV, bounded to the last 30 days.
18
- * Token counts are no longer written by Cursor 3.8+ so totals are always 0.
17
+ * Token counts are no longer written by Cursor 3.8+ so totals are always 0 — `model_token_split`
18
+ * therefore carries per-model composer COUNTs, not tokens (same convention as Cowork's
19
+ * session-count split), so the Agent Profile can still surface a "Most used model".
19
20
  * No message content is read — only model names and timestamps.
20
21
  */
21
22
  const TOKEN_USAGE_RECENCY_DAYS = 30;
@@ -36,11 +37,7 @@ function vscdbPath(home) {
36
37
  }
37
38
  function querySqlite(sqlite3, dbPath, sql) {
38
39
  try {
39
- return execFileSync(sqlite3, ['-readonly', '-noheader', dbPath], {
40
- input: `.timeout 5000\n${sql}\n`,
41
- encoding: 'utf8',
42
- stdio: ['pipe', 'pipe', 'pipe'],
43
- });
40
+ return execSqlite3(sqlite3, ['-readonly', '-noheader', dbPath], `.timeout 5000\n${sql}\n`);
44
41
  }
45
42
  catch {
46
43
  return null;
@@ -145,6 +142,11 @@ function collectCursorTokenUsage(home = homedir()) {
145
142
  GROUP BY json_extract(value, '$.modelConfig.modelName');`);
146
143
  const models = new Set();
147
144
  let sessionCount = 0;
145
+ // Cursor 3.8+ doesn't write token counts, so there's no real per-model token weighting — the
146
+ // per-model composer COUNT from the query above is the only usage-weight signal available.
147
+ // Mirrors Cowork's session-count convention (see cowork_extract.py): "Most used model" is
148
+ // derived from whichever model has the most composers, not the most tokens.
149
+ const modelComposerCounts = {};
148
150
  if (usageRaw) {
149
151
  for (const row of usageRaw.split(ROW_SEP)) {
150
152
  const trimmed = row.trim();
@@ -156,8 +158,10 @@ function collectCursorTokenUsage(home = homedir()) {
156
158
  const model = trimmed.slice(0, sep).trim();
157
159
  const cnt = num(trimmed.slice(sep + 1));
158
160
  sessionCount += cnt;
159
- if (model)
161
+ if (model) {
160
162
  models.add(model);
163
+ modelComposerCounts[model] = (modelComposerCounts[model] ?? 0) + cnt;
164
+ }
161
165
  }
162
166
  }
163
167
  if (sessionCount === 0 && !email && !version)
@@ -170,7 +174,7 @@ function collectCursorTokenUsage(home = homedir()) {
170
174
  version,
171
175
  session_count: sessionCount,
172
176
  models: [...models].sort(),
173
- model_token_split: {},
177
+ model_token_split: modelComposerCounts,
174
178
  window_days: TOKEN_USAGE_RECENCY_DAYS,
175
179
  totals: {
176
180
  input_tokens: 0,
@@ -1,19 +1,23 @@
1
- import { execFileSync } from 'node:child_process';
2
1
  import { existsSync, readFileSync } from 'node:fs';
3
2
  import { join } from 'node:path';
4
3
  import { homedir } from 'node:os';
5
- import { resolveSqlite3Binary } from '../runtime/sqlite_binary.js';
4
+ import { execSqlite3, resolveSqlite3Binary } from '../runtime/sqlite_binary.js';
6
5
  /**
7
6
  * Session / token usage / model / version aggregator for Hermes Agent (Nous Research).
8
7
  *
9
8
  * Hermes keeps all state under ~/.hermes/. Three sources feed this aggregate, each read
10
9
  * narrowly to avoid ever touching credential material:
11
10
  * - state.db (SQLite) `sessions` table — real per-session model + token columns
12
- * (input_tokens, output_tokens, reasoning_tokens, cache_read_tokens). started_at is a
13
- * Unix epoch *seconds* REAL column, bounded to the last 30 days.
14
- * - config.yaml only the top-level `model` key (string, or a block with a `default`
15
- * sub-key) is extracted; config.yaml can hold literal provider API keys under
16
- * `providers:`, so this is a scoped line-scan, never a full YAML parse.
11
+ * (input_tokens, output_tokens, reasoning_tokens, cache_read_tokens) plus real USD cost
12
+ * columns (actual_cost_usd, falling back to estimated_cost_usd Hermes computes this
13
+ * itself via a provider pricing-table lookup, cost_source="provider_models_api" on a real
14
+ * install). started_at is a Unix epoch *seconds* REAL column, bounded to the last 30 days.
15
+ * - config.yaml only the top-level `model` key is extracted: the plain-string form
16
+ * (`model: "name"`), or the block form's `default`/`provider`/`base_url` sub-keys
17
+ * (`model:\n default: "name"\n provider: openrouter\n base_url: ''`). config.yaml can
18
+ * hold literal provider API keys under `providers:`, so this is a scoped line-scan, never a
19
+ * full YAML parse — `provider`/`base_url` live directly under `model:`, not `providers:`,
20
+ * so reading them carries no credential risk.
17
21
  * - auth.json — only the top-level `active_provider` key (e.g. "nous") is read; the
18
22
  * per-provider blocks under `providers:` hold OAuth/API-key secrets and are never
19
23
  * touched. "nous" is Nous Portal OAuth; anything else is a traditional API-key
@@ -45,11 +49,7 @@ function num(value) {
45
49
  }
46
50
  function querySqlite(sqlite3, dbPath, sql) {
47
51
  try {
48
- return execFileSync(sqlite3, ['-readonly', '-noheader', dbPath], {
49
- input: `.timeout 5000\n${sql}\n`,
50
- encoding: 'utf8',
51
- stdio: ['pipe', 'pipe', 'pipe'],
52
- });
52
+ return execSqlite3(sqlite3, ['-readonly', '-noheader', dbPath], `.timeout 5000\n${sql}\n`);
53
53
  }
54
54
  catch {
55
55
  return null;
@@ -75,19 +75,21 @@ function stripYamlScalar(value) {
75
75
  const commentIdx = trimmed.search(/(?:^|\s)#/);
76
76
  return (commentIdx === -1 ? trimmed : trimmed.slice(0, commentIdx)).trim();
77
77
  }
78
+ const EMPTY_MODEL_CONFIG = { model: '', provider: '', baseUrl: '' };
78
79
  /**
79
80
  * Scoped read of config.yaml's top-level `model` key only. Handles both the plain-string
80
- * form (`model: "name"`) and the block form (`model:\n default: "name"\n provider: ...`).
81
- * Never parses or returns any other key — config.yaml's `providers:` block can carry
82
- * literal API keys, so this deliberately avoids a full YAML parse.
81
+ * form (`model: "name"`, which carries no provider/base_url) and the block form
82
+ * (`model:\n default: "name"\n provider: openrouter\n base_url: ''`). Never parses or
83
+ * returns any other key — config.yaml's `providers:` block can carry literal API keys, so this
84
+ * deliberately avoids a full YAML parse.
83
85
  */
84
- function readConfiguredModel(home) {
86
+ function readModelConfig(home) {
85
87
  let text;
86
88
  try {
87
89
  text = readFileSync(join(home, '.hermes', 'config.yaml'), 'utf8');
88
90
  }
89
91
  catch {
90
- return '';
92
+ return EMPTY_MODEL_CONFIG;
91
93
  }
92
94
  const lines = text.split('\n');
93
95
  for (let i = 0; i < lines.length; i++) {
@@ -96,8 +98,11 @@ function readConfiguredModel(home) {
96
98
  continue;
97
99
  const inline = stripYamlScalar(m[1]);
98
100
  if (inline)
99
- return inline;
100
- // Block form: scan indented lines immediately below for `default:`.
101
+ return { ...EMPTY_MODEL_CONFIG, model: inline };
102
+ // Block form: scan indented lines immediately below for default/provider/base_url —
103
+ // order isn't fixed, so keep scanning until the block dedents rather than stopping at the
104
+ // first match.
105
+ const result = { ...EMPTY_MODEL_CONFIG };
101
106
  for (let j = i + 1; j < lines.length; j++) {
102
107
  const next = lines[j];
103
108
  if (next.trim() === '')
@@ -105,12 +110,23 @@ function readConfiguredModel(home) {
105
110
  if (/^\S/.test(next))
106
111
  break; // dedented — end of the `model:` block
107
112
  const dm = next.match(/^\s+default:\s*(.*)$/);
108
- if (dm)
109
- return stripYamlScalar(dm[1]);
113
+ if (dm) {
114
+ result.model = stripYamlScalar(dm[1]);
115
+ continue;
116
+ }
117
+ const pm = next.match(/^\s+provider:\s*(.*)$/);
118
+ if (pm) {
119
+ result.provider = stripYamlScalar(pm[1]);
120
+ continue;
121
+ }
122
+ const bm = next.match(/^\s+base_url:\s*(.*)$/);
123
+ if (bm) {
124
+ result.baseUrl = stripYamlScalar(bm[1]);
125
+ }
110
126
  }
111
- break; // found the top-level `model:` key; stop (avoid nested `model:` keys elsewhere)
127
+ return result;
112
128
  }
113
- return '';
129
+ return EMPTY_MODEL_CONFIG;
114
130
  }
115
131
  /** Scoped read of auth.json's top-level `active_provider` key only — never the `providers` block. */
116
132
  function readActiveProvider(home) {
@@ -152,7 +168,8 @@ function collectHermesTokenUsage(home = homedir()) {
152
168
  COALESCE(SUM(input_tokens), 0) || char(31) ||
153
169
  COALESCE(SUM(output_tokens), 0) || char(31) ||
154
170
  COALESCE(SUM(reasoning_tokens), 0) || char(31) ||
155
- COALESCE(SUM(cache_read_tokens), 0) || char(30)
171
+ COALESCE(SUM(cache_read_tokens), 0) || char(31) ||
172
+ COALESCE(SUM(COALESCE(actual_cost_usd, estimated_cost_usd, 0)), 0) || char(30)
156
173
  FROM sessions
157
174
  WHERE started_at >= ${cutoffSeconds}
158
175
  GROUP BY model;`);
@@ -160,15 +177,16 @@ function collectHermesTokenUsage(home = homedir()) {
160
177
  const totals = emptyTotals();
161
178
  const modelTokenSplit = {};
162
179
  let sessionCount = 0;
180
+ let cost = 0;
163
181
  if (usageRaw) {
164
182
  for (const row of usageRaw.split(ROW_SEP)) {
165
183
  const trimmed = row.trim();
166
184
  if (!trimmed)
167
185
  continue;
168
186
  const fields = trimmed.split(FIELD_SEP);
169
- if (fields.length < 6)
187
+ if (fields.length < 7)
170
188
  continue;
171
- const [model, count, input, output, reasoning, cacheRead] = fields;
189
+ const [model, count, input, output, reasoning, cacheRead, modelCost] = fields;
172
190
  const cnt = num(count);
173
191
  sessionCount += cnt;
174
192
  if (model)
@@ -182,13 +200,14 @@ function collectHermesTokenUsage(home = homedir()) {
182
200
  totals.input_tokens += modelInput;
183
201
  totals.output_tokens += modelOutput;
184
202
  totals.cache_read_input_tokens += modelCacheRead;
203
+ cost += num(modelCost);
185
204
  const modelSum = modelInput + modelOutput + modelCacheRead;
186
205
  if (model && modelSum > 0) {
187
206
  modelTokenSplit[model] = (modelTokenSplit[model] ?? 0) + modelSum;
188
207
  }
189
208
  }
190
209
  }
191
- const configuredModel = readConfiguredModel(home);
210
+ const modelConfig = readModelConfig(home);
192
211
  const activeProvider = readActiveProvider(home);
193
212
  const version = readHermesVersion(home);
194
213
  const authMethod = activeProvider
@@ -196,7 +215,7 @@ function collectHermesTokenUsage(home = homedir()) {
196
215
  ? AUTH_NOUS_OAUTH
197
216
  : AUTH_API_KEY
198
217
  : '';
199
- if (sessionCount === 0 && !configuredModel && !activeProvider)
218
+ if (sessionCount === 0 && !modelConfig.model && !activeProvider)
200
219
  return [];
201
220
  return [
202
221
  {
@@ -207,9 +226,12 @@ function collectHermesTokenUsage(home = homedir()) {
207
226
  session_count: sessionCount,
208
227
  models: [...models].sort(),
209
228
  model_token_split: modelTokenSplit,
210
- configured_model: configuredModel,
229
+ configured_model: modelConfig.model,
230
+ configured_model_provider: modelConfig.provider,
231
+ configured_model_base_url: modelConfig.baseUrl,
211
232
  window_days: TOKEN_USAGE_RECENCY_DAYS,
212
233
  totals,
234
+ cost,
213
235
  active_provider: activeProvider,
214
236
  auth_method: authMethod,
215
237
  },
@@ -27,6 +27,13 @@ import { homedir } from 'node:os';
27
27
  * begin with). Exposed per-provider so the extractor can match it against the provider actually
28
28
  * observed in session usage.
29
29
  *
30
+ * Routing config comes from two more scoped reads of openclaw.json:
31
+ * - `agents.defaults.model.primary` (format `"provider/model"`) — the configured provider id,
32
+ * same precedence convention as other agents' `"provider/model"` pinning.
33
+ * - `models.providers.<id>` tables — `baseUrl`/`auth` (a strategy string like `"aws-sdk"`)
34
+ * only, never `apiKey`, `headers`, or the `models` catalog (this is a DIFFERENT top-level
35
+ * key than `auth.profiles` above; a real install can have one without the other).
36
+ *
30
37
  * Only checks ~/.openclaw (the canonical root); the moltbot/clawdbot compatible-fork roots
31
38
  * tracked by the presence/log file types are not covered here.
32
39
  */
@@ -106,46 +113,69 @@ function readSessionsStore(storePath, cutoffMs, acc) {
106
113
  }
107
114
  }
108
115
  }
109
- /** Scoped read of openclaw.json's top-level `meta.lastTouchedVersion` key only. */
110
- function readVersion(openclawDir) {
116
+ /** Parses openclaw.json once; both readVersion and readAuthModesByProvider derive from this. */
117
+ function parseOpenclawJson(openclawDir) {
111
118
  try {
112
- const raw = JSON.parse(readFileSync(join(openclawDir, 'openclaw.json'), 'utf8'));
113
- const meta = raw.meta;
114
- return meta && typeof meta === 'object'
115
- ? str(meta.lastTouchedVersion)
116
- : '';
119
+ return JSON.parse(readFileSync(join(openclawDir, 'openclaw.json'), 'utf8'));
117
120
  }
118
121
  catch {
119
- return '';
122
+ return null;
120
123
  }
121
124
  }
125
+ /** Scoped read of openclaw.json's top-level `meta.lastTouchedVersion` key only. */
126
+ function readVersion(raw) {
127
+ const meta = raw?.meta;
128
+ return meta && typeof meta === 'object'
129
+ ? str(meta.lastTouchedVersion)
130
+ : '';
131
+ }
122
132
  /**
123
133
  * Scoped read of openclaw.json's `auth.profiles` — provider + mode only, never credentials
124
134
  * (the profiles carry no token/key fields). Returns a provider -> mode map (e.g.
125
135
  * `{"anthropic": "api_key"}`); a provider with multiple profiles keeps the last one read.
126
136
  */
127
- function readAuthModesByProvider(openclawDir) {
128
- try {
129
- const raw = JSON.parse(readFileSync(join(openclawDir, 'openclaw.json'), 'utf8'));
130
- const auth = raw.auth;
131
- const profiles = auth && typeof auth === 'object' ? auth.profiles : undefined;
132
- const out = {};
133
- if (profiles && typeof profiles === 'object') {
134
- for (const value of Object.values(profiles)) {
135
- if (!value || typeof value !== 'object')
136
- continue;
137
- const entry = value;
138
- const provider = str(entry.provider);
139
- const mode = str(entry.mode);
140
- if (provider && mode)
141
- out[provider] = mode;
142
- }
137
+ function readAuthModesByProvider(raw) {
138
+ const auth = raw?.auth;
139
+ const profiles = auth && typeof auth === 'object' ? auth.profiles : undefined;
140
+ const out = {};
141
+ if (profiles && typeof profiles === 'object') {
142
+ for (const value of Object.values(profiles)) {
143
+ if (!value || typeof value !== 'object')
144
+ continue;
145
+ const entry = value;
146
+ const provider = str(entry.provider);
147
+ const mode = str(entry.mode);
148
+ if (provider && mode)
149
+ out[provider] = mode;
143
150
  }
144
- return out;
145
151
  }
146
- catch {
147
- return {};
152
+ return out;
153
+ }
154
+ /** Scoped read of openclaw.json's `agents.defaults.model.primary` key only (format "provider/model"). */
155
+ function readPrimaryModelProvider(raw) {
156
+ const agents = raw?.agents;
157
+ const defaults = agents && typeof agents === 'object' ? agents.defaults : undefined;
158
+ const model = defaults && typeof defaults === 'object' ? defaults.model : undefined;
159
+ const primary = model && typeof model === 'object' ? str(model.primary) : '';
160
+ return primary.includes('/') ? primary.split('/', 1)[0] : '';
161
+ }
162
+ /**
163
+ * Scoped read of openclaw.json's top-level `models.providers.<id>` tables — `baseUrl`/`auth`
164
+ * (a strategy string, e.g. `"aws-sdk"`) only, never `apiKey`, `headers`, or the `models` catalog.
165
+ */
166
+ function readModelProviders(raw) {
167
+ const models = raw?.models;
168
+ const providers = models && typeof models === 'object' ? models.providers : undefined;
169
+ const out = {};
170
+ if (providers && typeof providers === 'object') {
171
+ for (const [id, cfg] of Object.entries(providers)) {
172
+ if (!cfg || typeof cfg !== 'object')
173
+ continue;
174
+ const entry = cfg;
175
+ out[id] = { baseUrl: str(entry.baseUrl), auth: str(entry.auth) };
176
+ }
148
177
  }
178
+ return out;
149
179
  }
150
180
  /**
151
181
  * Aggregate OpenClaw session count / token usage / models / providers / version across every
@@ -170,19 +200,22 @@ function collectOpenclawTokenUsage(home = homedir()) {
170
200
  }
171
201
  if (acc.sessionIds.size === 0)
172
202
  return [];
203
+ const openclawJson = parseOpenclawJson(openclawDir);
173
204
  return [
174
205
  {
175
206
  file_type: TOKEN_USAGE_FILE_TYPE,
176
207
  file_path: openclawDir,
177
208
  raw_content: {
178
- version: readVersion(openclawDir),
209
+ version: readVersion(openclawJson),
179
210
  session_count: acc.sessionIds.size,
180
211
  models: [...acc.models].sort(),
181
212
  providers: [...acc.providers].sort(),
182
213
  model_token_split: acc.modelTokenSplit,
183
214
  window_days: TOKEN_USAGE_RECENCY_DAYS,
184
215
  totals: acc.totals,
185
- auth_modes_by_provider: readAuthModesByProvider(openclawDir),
216
+ auth_modes_by_provider: readAuthModesByProvider(openclawJson),
217
+ primary_model_provider: readPrimaryModelProvider(openclawJson),
218
+ model_providers: readModelProviders(openclawJson),
186
219
  },
187
220
  },
188
221
  ];
@@ -1,8 +1,7 @@
1
- import { execFileSync } from 'node:child_process';
2
1
  import { existsSync, readFileSync, statSync } from 'node:fs';
3
2
  import { join } from 'node:path';
4
3
  import { homedir } from 'node:os';
5
- import { resolveSqlite3Binary } from '../runtime/sqlite_binary.js';
4
+ import { execSqlite3, resolveSqlite3Binary } from '../runtime/sqlite_binary.js';
6
5
  /**
7
6
  * Token-usage / cost / session / version / identity aggregator for OpenCode.
8
7
  *
@@ -40,11 +39,7 @@ function asObject(value) {
40
39
  /** Run a read-only query and return raw stdout, or null when sqlite3/DB is unavailable. */
41
40
  function querySqlite(sqlite3, dbPath, sql) {
42
41
  try {
43
- return execFileSync(sqlite3, ['-readonly', '-noheader', dbPath], {
44
- input: `.timeout 5000\n${sql}\n`,
45
- encoding: 'utf8',
46
- stdio: ['pipe', 'pipe', 'pipe'],
47
- });
42
+ return execSqlite3(sqlite3, ['-readonly', '-noheader', dbPath], `.timeout 5000\n${sql}\n`);
48
43
  }
49
44
  catch {
50
45
  return null;
@@ -8,10 +8,12 @@ import { homedir } from 'node:os';
8
8
  * ~/.pi/agent/sessions/<sanitized-cwd>/<timestamp>_<uuid>.jsonl. The first line is a
9
9
  * `type: "session"` header carrying the session `id`; assistant turns are `type: "message"`
10
10
  * lines whose `message.usage` block carries `{input, output, cacheRead, cacheWrite,
11
- * totalTokens, reasoning?}` alongside `message.model` / `message.provider`. We aggregate the
12
- * *numbers* (and model/provider ids) on the endpoint and emit only those never
13
- * `message.content` (the actual prompt/response text)so the backend gets token usage,
14
- * session count, and models/providers used without ingesting transcript content.
11
+ * totalTokens, reasoning?, cost?}` alongside `message.model` / `message.provider`. `usage.cost`
12
+ * (confirmed on a real install) is `{input, output, cacheRead, cacheWrite, total}` in USD, computed
13
+ * by Pi itself per messagewe sum only `cost.total`. We aggregate the *numbers* (and model/
14
+ * provider ids) on the endpoint and emit only those never `message.content` (the actual
15
+ * prompt/response text) — so the backend gets token usage, cost, session count, and models/
16
+ * providers used without ingesting transcript content.
15
17
  *
16
18
  * Configured defaults come from a scoped read of settings.json's `defaultProvider` /
17
19
  * `defaultModel` / `lastChangelogVersion` keys only — never any other key.
@@ -27,6 +29,14 @@ import { homedir } from 'node:os';
27
29
  * `version` field is a JSONL schema version (small int, e.g. `3`), not the CLI version, and is
28
30
  * not used here.
29
31
  *
32
+ * `~/.pi/agent/models.json` handles custom providers (Ollama, LM Studio, vLLM, or any OpenAI/
33
+ * Anthropic-compatible API per Pi's own docs) — but no official doc example shows its actual key
34
+ * shape (unlike auth.json/settings.json above, which have confirmed real examples). We read only
35
+ * a best-effort guessed `{ "<id>": { "baseURL": "..." } }` shape (mirroring the `baseUrl`/
36
+ * `baseURL` convention every other agent's custom-provider config uses) and treat anything else
37
+ * in the file as unknown; this is explicitly unconfirmed and may need correcting against a real
38
+ * install with the file actually populated.
39
+ *
30
40
  * Bounded by mtime recency so a large session history is not fully re-parsed on every run.
31
41
  */
32
42
  const TOKEN_USAGE_RECENCY_DAYS = 30;
@@ -105,6 +115,28 @@ function readAuthTypesByProvider(home) {
105
115
  return {};
106
116
  }
107
117
  }
118
+ /**
119
+ * Best-effort, UNCONFIRMED scoped read of models.json's custom-provider `baseURL` values —
120
+ * see the module docstring's caveat. Returns a provider id -> base URL map.
121
+ */
122
+ function readCustomProviderBaseUrls(home) {
123
+ try {
124
+ const raw = JSON.parse(readFileSync(join(home, '.pi', 'agent', 'models.json'), 'utf8'));
125
+ const out = {};
126
+ for (const [providerId, value] of Object.entries(raw)) {
127
+ if (!value || typeof value !== 'object')
128
+ continue;
129
+ const entry = value;
130
+ const baseUrl = str(entry.baseURL) || str(entry.baseUrl);
131
+ if (baseUrl)
132
+ out[providerId] = baseUrl;
133
+ }
134
+ return out;
135
+ }
136
+ catch {
137
+ return {};
138
+ }
139
+ }
108
140
  /**
109
141
  * Aggregate Pi session count / token usage / models / providers across recent session
110
142
  * transcripts. Returns a single-element array (one per-machine aggregate) or [] when there is
@@ -123,6 +155,7 @@ function collectPiTokenUsage(home = homedir()) {
123
155
  const models = new Set();
124
156
  const providers = new Set();
125
157
  const modelTokenSplit = {};
158
+ let cost = 0;
126
159
  let sawData = false;
127
160
  for (const file of files) {
128
161
  let content;
@@ -171,6 +204,7 @@ function collectPiTokenUsage(home = homedir()) {
171
204
  totals.input_tokens += input;
172
205
  totals.output_tokens += output;
173
206
  totals.cache_read_input_tokens += cacheRead;
207
+ cost += num(asObject(usage.cost).total);
174
208
  const sum = input + output + cacheRead;
175
209
  if (model && sum > 0) {
176
210
  modelTokenSplit[model] = (modelTokenSplit[model] ?? 0) + sum;
@@ -195,7 +229,9 @@ function collectPiTokenUsage(home = homedir()) {
195
229
  configured_provider: defaults.provider,
196
230
  window_days: TOKEN_USAGE_RECENCY_DAYS,
197
231
  totals,
232
+ cost,
198
233
  auth_types_by_provider: readAuthTypesByProvider(home),
234
+ custom_provider_base_urls: readCustomProviderBaseUrls(home),
199
235
  },
200
236
  },
201
237
  ];
@@ -1,4 +1,4 @@
1
- import { CURSOR_SHADOW_KEYS_NESTED_WINS, coalesceCursorEnabledShadowValue, stripShadowKeysFromReactiveBlobUpload, } from './cursor_shadow_merge_policy.js';
1
+ import { CURSOR_SHADOW_KEYS_NESTED_WINS, coalesceCursorEnabledShadowValue, stripShadowKeysFromReactiveBlobUpload, } from './cursor_shadow_merge_ruleset.js';
2
2
  const CURSOR_SHADOW_KEYS_MERGE_ENABLED_OR = new Set([
3
3
  'isWebSearchToolEnabled',
4
4
  'isWebSearchToolEnabled2',
@@ -35,7 +35,7 @@ export function coalesceCursorEnabledShadowValue(root, nested) {
35
35
  return nested;
36
36
  }
37
37
  /**
38
- * After merge, policy and uploads use composerState.<key> only — drop stale blob-root copies.
38
+ * After merge, ruleset scanning and uploads use composerState.<key> only — drop stale blob-root copies.
39
39
  */
40
40
  export function stripShadowKeysFromReactiveBlobUpload(stateData) {
41
41
  if (!('composerState' in stateData))
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { readFileCollectionVscdbContract } from '../runtime/management_storage.js';
4
4
  import { resolveSqlite3Binary } from '../runtime/sqlite_binary.js';
5
- import { CURSOR_COMPOSER_SHADOW_KEYS } from './cursor_shadow_merge_policy.js';
5
+ import { CURSOR_COMPOSER_SHADOW_KEYS } from './cursor_shadow_merge_ruleset.js';
6
6
  /** Suffix shared by Cursor reactive-storage ItemTable keys (see file_path_registry cursor agent). */
7
7
  export const REACTIVE_STORAGE_ITEM_KEY_SUFFIX = 'reactiveStorageServiceImpl.persistentStorage.applicationUser';
8
8
  function querySqliteValue(dbPath, key) {
@@ -1,6 +1,5 @@
1
1
  import { existsSync } from 'node:fs';
2
- import { execFileSync } from 'node:child_process';
3
- import { resolveSqlite3Binary } from '../runtime/sqlite_binary.js';
2
+ import { execSqlite3, resolveSqlite3Binary } from '../runtime/sqlite_binary.js';
4
3
  import { mergeCursorShadowKeyValue, mergeShadowKeysIntoComposerState, shadowKeysFromReadQueries, } from './composer_shadow_merge.js';
5
4
  import { composerShadowKeysFromContract, reactiveStorageItemKeyForVscdb, } from './vscdb_reactive_storage.js';
6
5
  import { readFileCollectionVscdbContract, sanitizeFileCollectionVscdbContract, writeFileCollectionVscdbContract, } from '../runtime/management_storage.js';
@@ -19,11 +18,7 @@ function querySqlite(dbPath, key) {
19
18
  throw new Error('sqlite3 not found');
20
19
  const safe = key.replace(/'/g, "''");
21
20
  const script = `.timeout 60000\nSELECT value FROM ItemTable WHERE key='${safe}';\n`;
22
- return execFileSync(bin, ['-noheader', dbPath], {
23
- input: script,
24
- encoding: 'utf8',
25
- stdio: ['pipe', 'pipe', 'pipe'],
26
- }).trim();
21
+ return execSqlite3(bin, ['-noheader', dbPath], script).trim();
27
22
  }
28
23
  /**
29
24
  * Cursor keeps live agent settings (e.g. yoloCommandAllowlist) on nested composerState inside
@@ -1,7 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
2
2
  import { delimiter, dirname, join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { execFileSync } from 'node:child_process';
5
4
  import { executeBody } from '../../endpoint_client/http_transport.js';
6
5
  import { parseJsonWithJsoncFallback } from '../readers/file_readers.js';
7
6
  import { complianceRunnerDiag, hookRunLog, logRemediationApplyFailure } from './hook_logger.js';
@@ -19,8 +18,8 @@ import { reportAbsentWorktrees } from './worktree_absent.js';
19
18
  import { buildApiUrl, getFileCollectionPatterns } from '../../endpoint_client/registry_api.js';
20
19
  import { resolveRemediationConfigPath } from './remediation_config_path.js';
21
20
  import { resolveOpsTargetPath } from './ops_target_path.js';
22
- import { resolveSqlite3Binary } from './sqlite_binary.js';
23
- import { CURSOR_COMPOSER_SHADOW_KEYS } from '../readers/cursor_shadow_merge_policy.js';
21
+ import { execSqlite3, resolveSqlite3Binary } from './sqlite_binary.js';
22
+ import { CURSOR_COMPOSER_SHADOW_KEYS } from '../readers/cursor_shadow_merge_ruleset.js';
24
23
  import { REACTIVE_STORAGE_ITEM_KEY_SUFFIX, composerShadowKeysFromContract, reactiveStorageItemKeyForVscdb, } from '../readers/vscdb_reactive_storage.js';
25
24
  /** Best-effort detail from execFileSync failures (stderr, exit code, errno). */
26
25
  function formatNodeChildException(err) {
@@ -936,11 +935,7 @@ function sqliteSelectValueCell(dbPath, table, key_column, value_column, target_k
936
935
  const safeName = target_key.replace(/'/g, "''");
937
936
  const script = `.timeout 60000\nSELECT ${value_column} FROM ${table} WHERE ${key_column}='${safeName}';\n`;
938
937
  try {
939
- return execFileSync(sqlite3, ['-noheader', dbPath], {
940
- input: script,
941
- encoding: 'utf8',
942
- stdio: ['pipe', 'pipe', 'pipe'],
943
- }).trim();
938
+ return execSqlite3(sqlite3, ['-noheader', dbPath], script).trim();
944
939
  }
945
940
  catch (err) {
946
941
  const { short, long } = formatNodeChildException(err);
@@ -955,11 +950,7 @@ function sqliteExecWithTimeout(dbPath, sqlBody) {
955
950
  throw new Error('sqlite3 not found');
956
951
  const script = `.timeout 60000\n${sqlBody}\n`;
957
952
  try {
958
- execFileSync(sqlite3, [dbPath], {
959
- input: script,
960
- encoding: 'utf8',
961
- stdio: ['pipe', 'pipe', 'pipe'],
962
- });
953
+ execSqlite3(sqlite3, [dbPath], script);
963
954
  }
964
955
  catch (err) {
965
956
  const { long } = formatNodeChildException(err);
@@ -976,11 +967,7 @@ function sqliteRunUpdateReturningChanges(dbPath, updateSql) {
976
967
  const script = `.timeout 60000\n${updateSql}\nSELECT changes();\n`;
977
968
  let out;
978
969
  try {
979
- out = execFileSync(sqlite3, [dbPath], {
980
- input: script,
981
- encoding: 'utf8',
982
- stdio: ['pipe', 'pipe', 'pipe'],
983
- }).trim();
970
+ out = execSqlite3(sqlite3, [dbPath], script).trim();
984
971
  }
985
972
  catch (err) {
986
973
  const { long } = formatNodeChildException(err);
@@ -90,3 +90,35 @@ export function resolveSqlite3Binary() {
90
90
  export function clearSqlite3BinaryCacheForTests() {
91
91
  cached = undefined;
92
92
  }
93
+ /**
94
+ * Run the sqlite3 CLI with `-escape off` inserted before the trailing dbPath argument, so raw
95
+ * control-byte output (our char(30)/char(31) row/field separators, or any control byte in a
96
+ * stored value) survives instead of being rendered as printable caret-notation text — the
97
+ * default in sqlite3 CLI builds that support `-escape` (added alongside the `-escape symbol`
98
+ * default; confirmed present in a macOS-bundled 3.51.0 from 2025-06).
99
+ *
100
+ * Falls back to the same call without `-escape off` if the flag itself isn't recognized (an
101
+ * older CLI predating the flag also predates the `-escape symbol` default that motivates it, so
102
+ * dropping the flag there is safe, not a silent behavior change). `argsWithDbPathLast`'s last
103
+ * element must be the database path.
104
+ */
105
+ export function execSqlite3(sqlite3, argsWithDbPathLast, input) {
106
+ const dbPath = argsWithDbPathLast[argsWithDbPathLast.length - 1];
107
+ const leadingArgs = argsWithDbPathLast.slice(0, -1);
108
+ const options = {
109
+ input,
110
+ encoding: 'utf8',
111
+ stdio: ['pipe', 'pipe', 'pipe'],
112
+ };
113
+ try {
114
+ return execFileSync(sqlite3, [...leadingArgs, '-escape', 'off', dbPath], options);
115
+ }
116
+ catch (err) {
117
+ try {
118
+ return execFileSync(sqlite3, argsWithDbPathLast, options);
119
+ }
120
+ catch {
121
+ throw err;
122
+ }
123
+ }
124
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "log-llm-config",
3
- "version": "1.5.10",
3
+ "version": "1.5.11",
4
4
  "description": "CLI helpers for logging hardware UUIDs and posting startup payloads to Optimus Security.",
5
5
  "type": "module",
6
6
  "bin": {