@qverisai/cli 0.3.0 → 0.5.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/README.md CHANGED
@@ -9,12 +9,12 @@ Found 5 capabilities matching your query
9
9
  1. gridpoint_forecast by Weather.gov
10
10
  weather_gov.gridpoints.forecast.retrieve.v1
11
11
  Returns a textual forecast for a 2.5km grid area
12
- relevance: 95% · success: 99.8% · latency: ~180ms · cost: 3 cr
12
+ relevance: 95% · success: 99.8% · latency: ~180ms · billing: 3 credits / request
13
13
 
14
14
  2. current_weather by OpenWeather
15
15
  openweathermap.weather.current.v1
16
16
  Get current weather data for any location
17
- relevance: 87% · success: 99.5% · latency: ~200ms · cost: 2 cr
17
+ relevance: 87% · success: 99.5% · latency: ~200ms · billing: 2 credits / request
18
18
 
19
19
  $ qveris inspect 1
20
20
  gridpoint_forecast
@@ -23,7 +23,7 @@ Returns a textual forecast for a 2.5km grid area
23
23
  Provider: Weather.gov
24
24
  Latency: ~180ms
25
25
  Success: 99.8%
26
- Cost: 3 credits
26
+ Billing: 3 credits / request
27
27
 
28
28
  Parameters:
29
29
  wfo string required
@@ -38,9 +38,13 @@ Returns a textual forecast for a 2.5km grid area
38
38
  {"wfo": "LWX", "x": 90, "y": 90}
39
39
 
40
40
  $ qveris call 1 --params '{"wfo":"LWX","x":90,"y":90}'
41
- ✓ success · 523ms · 10 credits · (1368087.93 remaining)
41
+ ✓ success · 523ms · 10 credits pre-settlement · (1368087.93 remaining)
42
42
  tool: weather_gov... · id: 7ebcaf9d-...
43
43
 
44
+ Billing:
45
+ 10 credits per successful request
46
+ Pre-settlement: 10 credits
47
+
44
48
  {
45
49
  "type": "Feature",
46
50
  "properties": {
@@ -96,25 +100,27 @@ qveris call 1 --params '{"wfo": "LWX", "x": 90, "y": 90}'
96
100
 
97
101
  | Command | Description |
98
102
  |---------|-------------|
99
- | `qveris discover <query>` | Find capabilities by natural language. Shows tool ID, provider, description, relevance, success rate, latency, and cost for each result. |
103
+ | `qveris discover <query>` | Find capabilities by natural language. Shows tool ID, provider, description, relevance, success rate, latency, and billing rule when available. |
100
104
  | `qveris inspect <id\|index>` | View full tool details: parameters (type, required, description, enum values), example, provider info, execution history. |
101
- | `qveris call <id\|index>` | Execute a capability. Shows result data, execution time, cost, and remaining credits. |
105
+ | `qveris call <id\|index>` | Execute a capability. Shows result data, execution time, pre-settlement billing, and remaining credits. |
102
106
 
103
107
  ### Account
104
108
 
105
109
  | Command | Description |
106
110
  |---------|-------------|
107
- | `qveris login` | Authenticate with API key (opens browser or `--token` for direct input) |
111
+ | `qveris login` | Authenticate with API key (interactive region selection, opens browser, or `--token` for direct input) |
108
112
  | `qveris logout` | Remove stored key |
109
- | `qveris whoami` | Show current auth status and key source |
113
+ | `qveris whoami` | Show current auth status, key source, and region |
110
114
  | `qveris credits` | Check credit balance |
115
+ | `qveris usage` | Context-safe usage audit summary/search/export |
116
+ | `qveris ledger` | Context-safe credits ledger summary/search/export |
111
117
 
112
118
  ### Utilities
113
119
 
114
120
  | Command | Description |
115
121
  |---------|-------------|
116
122
  | `qveris interactive` | Launch REPL mode (discover/inspect/call/codegen in one session) |
117
- | `qveris doctor` | Self-check: Node.js version, API key, connectivity |
123
+ | `qveris doctor` | Self-check: Node.js version, API key, region, connectivity |
118
124
  | `qveris config <subcommand>` | Manage CLI settings (set, get, list, reset, path) |
119
125
  | `qveris completions <shell>` | Generate shell completions (bash/zsh/fish) |
120
126
 
@@ -146,7 +152,7 @@ qveris inspect 1 2 3
146
152
 
147
153
  ### Call
148
154
 
149
- Execute a tool. Results are automatically truncated for terminal display (4KB). Large results get an OSS download link.
155
+ Execute a tool. Results are automatically truncated for terminal display (4KB). Large results get an OSS download link. Billing shown here is pre-settlement; use `qveris usage` or `qveris ledger` for final charge status.
150
156
 
151
157
  ```bash
152
158
  # Inline params
@@ -178,7 +184,7 @@ For terminal use, results larger than 4KB are automatically truncated. The CLI s
178
184
  - The response schema so you know the data structure
179
185
 
180
186
  ```
181
- ✓ success · 1200ms · 5 credits
187
+ ✓ success · 1200ms · 5 credits pre-settlement
182
188
 
183
189
  Response truncated (32KB → 4KB preview)
184
190
 
@@ -199,6 +205,40 @@ Preview:
199
205
 
200
206
  For agent/script use (`--json` or piped output), the default increases to 20KB (matching the MCP server). Use `--max-size -1` for unlimited.
201
207
 
208
+ ### Usage Audit
209
+
210
+ Use `qveris usage` to answer whether recent calls succeeded, failed, or charged credits. It defaults to `summary` mode to protect Agent context.
211
+ Summary mode requests service-side `summary=true` aggregates when available and falls back to bounded client-side aggregation for older deployments.
212
+
213
+ ```bash
214
+ # Aggregate recent usage by hour/day/week
215
+ qveris usage --mode summary --bucket hour
216
+
217
+ # Check one execution precisely
218
+ qveris usage --mode search --execution-id <execution_id> --json
219
+
220
+ # Find high-cost or suspicious records
221
+ qveris usage --mode search --min-credits 30 --max-credits 100 --json
222
+ qveris usage --mode search --charge-outcome failed_charged_review --json
223
+
224
+ # Export raw matching rows to a local JSONL file instead of stdout
225
+ qveris usage --mode export-file --start-date 2026-05-01 --end-date 2026-05-04
226
+ ```
227
+
228
+ ### Credits Ledger
229
+
230
+ Use `qveris ledger` to explain final credit balance movements. It also defaults to `summary` mode.
231
+ Summary mode requests service-side `summary=true` aggregates when available and falls back to bounded client-side aggregation for older deployments.
232
+
233
+ ```bash
234
+ qveris ledger --mode summary --bucket day
235
+ qveris ledger --mode search --direction consume --min-credits 50 --json
236
+ qveris ledger --mode search --entry-type consume_tool_execute --json
237
+ qveris ledger --mode export-file --start-date 2026-05-01 --end-date 2026-05-04
238
+ ```
239
+
240
+ `summary` and `search` never print full history. Large record sets are written to `.qveris/exports/*.jsonl` with `--mode export-file`.
241
+
202
242
  ### Interactive Mode
203
243
 
204
244
  ```bash
@@ -251,13 +291,18 @@ The API region is auto-detected from your key prefix:
251
291
  | `sk-xxx` | Global | `https://qveris.ai/api/v1` |
252
292
  | `sk-cn-xxx` | China | `https://qveris.cn/api/v1` |
253
293
 
254
- No extra configuration needed. To override manually:
294
+ No extra configuration needed. `qveris login` prompts for region selection interactively on first use.
295
+
296
+ **Agent / script usage:** Use `--token` with a region-prefixed key, or set environment variables:
255
297
 
256
298
  ```bash
257
- # Via environment variable
299
+ # Key prefix auto-detection (recommended)
300
+ qveris login --token "sk-cn-xxx"
301
+
302
+ # Or environment variable
258
303
  export QVERIS_REGION=cn
259
304
 
260
- # Or set a custom base URL
305
+ # Or custom base URL
261
306
  export QVERIS_BASE_URL=https://custom.endpoint/api/v1
262
307
 
263
308
  # Or per-command
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qverisai/cli",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "QVeris CLI -- discover, inspect, and call 10,000+ capabilities",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,7 +36,12 @@ async function requestJson(path, { method = "POST", query = {}, body, timeoutMs
36
36
  errorDetail = jsonBody.error_message || jsonBody.message || null;
37
37
  } catch { /* not JSON */ }
38
38
  if (status === 401 || status === 403) throw new CliError("AUTH_INVALID_KEY", errorDetail);
39
- if (status === 402) throw new CliError("CREDITS_INSUFFICIENT", errorDetail);
39
+ if (status === 402) {
40
+ const pricingHost = baseUrl.includes("qveris.cn") ? "https://qveris.cn" : "https://qveris.ai";
41
+ const err = new CliError("CREDITS_INSUFFICIENT", errorDetail);
42
+ err.hint = `Purchase credits at ${pricingHost}/pricing`;
43
+ throw err;
44
+ }
40
45
  if (status === 429) throw new CliError("RATE_LIMITED", errorDetail);
41
46
  const err = new CliError("API_ERROR", `HTTP ${status}: ${errorDetail || rawText}`);
42
47
  if (jsonBody) err.responseData = jsonBody;
@@ -57,6 +62,21 @@ async function requestJson(path, { method = "POST", query = {}, body, timeoutMs
57
62
  }
58
63
  }
59
64
 
65
+ export function unwrapApiResponse(response) {
66
+ if (
67
+ response &&
68
+ typeof response === "object" &&
69
+ Object.prototype.hasOwnProperty.call(response, "status") &&
70
+ Object.prototype.hasOwnProperty.call(response, "data")
71
+ ) {
72
+ if (response.status === "failure") {
73
+ throw new CliError("API_ERROR", response.message || "API request failed");
74
+ }
75
+ return response.data;
76
+ }
77
+ return response;
78
+ }
79
+
60
80
  export async function discoverTools({ apiKey, baseUrl: baseUrlFlag, query, limit = 5, timeoutMs = 30000 }) {
61
81
  const baseUrl = getBaseUrl(baseUrlFlag, apiKey);
62
82
  return requestJson("/search", { apiKey, baseUrl, body: { query, limit }, timeoutMs });
@@ -91,3 +111,45 @@ export async function callTool({
91
111
  timeoutMs,
92
112
  });
93
113
  }
114
+
115
+ export async function getCredits({ apiKey, baseUrl: baseUrlFlag, timeoutMs = 30000 }) {
116
+ const baseUrl = getBaseUrl(baseUrlFlag, apiKey);
117
+ return requestJson("/auth/credits", {
118
+ method: "GET",
119
+ apiKey,
120
+ baseUrl,
121
+ timeoutMs,
122
+ });
123
+ }
124
+
125
+ export async function getUsageHistory({
126
+ apiKey,
127
+ baseUrl: baseUrlFlag,
128
+ query = {},
129
+ timeoutMs = 30000,
130
+ }) {
131
+ const baseUrl = getBaseUrl(baseUrlFlag, apiKey);
132
+ return requestJson("/auth/usage/history/v2", {
133
+ method: "GET",
134
+ apiKey,
135
+ baseUrl,
136
+ query,
137
+ timeoutMs,
138
+ });
139
+ }
140
+
141
+ export async function getCreditsLedger({
142
+ apiKey,
143
+ baseUrl: baseUrlFlag,
144
+ query = {},
145
+ timeoutMs = 30000,
146
+ }) {
147
+ const baseUrl = getBaseUrl(baseUrlFlag, apiKey);
148
+ return requestJson("/auth/credits/ledger", {
149
+ method: "GET",
150
+ apiKey,
151
+ baseUrl,
152
+ query,
153
+ timeoutMs,
154
+ });
155
+ }
@@ -1,6 +1,6 @@
1
1
  const COMMANDS = [
2
2
  "discover", "inspect", "call",
3
- "login", "logout", "whoami", "credits",
3
+ "login", "logout", "whoami", "credits", "usage", "ledger",
4
4
  "config", "interactive", "history", "doctor", "completions",
5
5
  ];
6
6
 
@@ -1,5 +1,6 @@
1
1
  import { getConfigPath, getConfigValue, setConfigValue, writeConfig } from "../config/store.mjs";
2
- import { resolveAll } from "../config/resolve.mjs";
2
+ import { resolve, resolveAll } from "../config/resolve.mjs";
3
+ import { resolveBaseUrl } from "../config/region.mjs";
3
4
  import { bold, dim, cyan } from "../output/colors.mjs";
4
5
  import { outputJson } from "../output/json.mjs";
5
6
 
@@ -64,11 +65,17 @@ function configGet(key, flags) {
64
65
 
65
66
  function configList(flags) {
66
67
  const all = resolveAll();
68
+
69
+ // Resolve effective region
70
+ const { value: apiKey } = resolve("api_key", flags.apiKey);
71
+ const { region, source: regionSource, baseUrl } = resolveBaseUrl({ baseUrlFlag: flags.baseUrl, apiKey });
72
+
67
73
  if (flags.json) {
68
74
  const obj = {};
69
75
  for (const [k, v] of Object.entries(all)) {
70
76
  obj[k] = { value: k === "api_key" && v.value ? mask(v.value) : v.value, source: v.source };
71
77
  }
78
+ obj._region = { region, source: regionSource, baseUrl };
72
79
  outputJson(obj);
73
80
  return;
74
81
  }
@@ -78,6 +85,7 @@ function configList(flags) {
78
85
  const display = key === "api_key" && value ? mask(value) : String(value ?? dim("(not set)"));
79
86
  console.log(` ${cyan(key.padEnd(20))}${display.padEnd(25)}${dim(source)}`);
80
87
  }
88
+ console.log(`\n ${bold("Effective region:")} ${region} ${dim(`(${regionSource})`)} → ${dim(baseUrl)}`);
81
89
  console.log();
82
90
  }
83
91
 
@@ -1,32 +1,49 @@
1
1
  import { resolveApiKey } from "../client/auth.mjs";
2
- import { discoverTools } from "../client/api.mjs";
2
+ import { getCredits, unwrapApiResponse } from "../client/api.mjs";
3
+ import { resolveBaseUrl, getSiteUrl } from "../config/region.mjs";
3
4
  import { bold, dim, cyan, yellow, green } from "../output/colors.mjs";
4
5
  import { outputJson } from "../output/json.mjs";
5
6
  import { createSpinner } from "../output/spinner.mjs";
6
7
 
7
8
  export async function runCredits(flags) {
8
9
  const apiKey = resolveApiKey(flags.apiKey);
10
+ const { region, baseUrl } = resolveBaseUrl({ baseUrlFlag: flags.baseUrl, apiKey });
11
+ const accountUrl = `${getSiteUrl(region, baseUrl)}/account`;
9
12
 
10
13
  const spinner = flags.json ? { stop() {} } : createSpinner("Checking credits...");
11
14
 
12
15
  try {
13
- // The backend returns remaining_credits in every /search response
14
- const result = await discoverTools({ apiKey, baseUrl: flags.baseUrl, query: "credit balance", limit: 1, timeoutMs: 10000 });
16
+ const result = unwrapApiResponse(await getCredits({ apiKey, baseUrl, timeoutMs: 10000 }));
15
17
  spinner.stop();
16
18
 
17
19
  const credits = result.remaining_credits;
18
20
 
19
21
  if (flags.json) {
20
- outputJson({ remaining_credits: credits ?? null });
22
+ outputJson(result);
21
23
  } else if (credits !== undefined && credits !== null) {
22
24
  console.log(`\n ${green("\u2713")} Credits remaining: ${bold(yellow(String(credits)))}`);
23
- console.log(` ${dim("Manage at:")} ${cyan("https://qveris.ai/account")}\n`);
25
+ printBucket("Daily free", result.daily_free, "remaining", "total");
26
+ printBucket("Invite reward", result.invite_reward, "remaining", "total");
27
+ printBucket("Welcome bonus", result.welcome_bonus, "remaining", "initial");
28
+ printBucket("Purchased", result.purchased, "remaining", "total");
29
+ console.log(` ${dim("Manage at:")} ${cyan(accountUrl)}\n`);
24
30
  } else {
25
31
  console.log(`\n ${dim("Credit balance not available in API response.")}`);
26
- console.log(` Check at: ${cyan("https://qveris.ai/account")}\n`);
32
+ console.log(` Check at: ${cyan(accountUrl)}\n`);
27
33
  }
28
34
  } catch (err) {
29
35
  spinner.stop();
30
36
  throw err;
31
37
  }
32
38
  }
39
+
40
+ function printBucket(label, bucket, remainingKey, totalKey) {
41
+ if (!bucket || typeof bucket !== "object") return;
42
+ const remaining = bucket[remainingKey];
43
+ const total = bucket[totalKey];
44
+ if (remaining === undefined && total === undefined) return;
45
+ const parts = [];
46
+ if (remaining !== undefined) parts.push(`remaining ${yellow(String(remaining))}`);
47
+ if (total !== undefined) parts.push(`${dim("of")} ${String(total)}`);
48
+ console.log(` ${dim(label + ":")} ${parts.join(" ")}`);
49
+ }
@@ -1,5 +1,6 @@
1
1
  import { resolveApiKey } from "../client/auth.mjs";
2
2
  import { discoverTools } from "../client/api.mjs";
3
+ import { resolveBaseUrl } from "../config/region.mjs";
3
4
  import { writeSession } from "../session/session.mjs";
4
5
  import { formatDiscoverResult } from "../output/formatter.mjs";
5
6
  import { outputJson } from "../output/json.mjs";
@@ -7,6 +8,7 @@ import { createSpinner } from "../output/spinner.mjs";
7
8
 
8
9
  export async function runDiscover(query, flags) {
9
10
  const apiKey = resolveApiKey(flags.apiKey);
11
+ const { region, baseUrl: resolvedBaseUrl } = resolveBaseUrl({ baseUrlFlag: flags.baseUrl, apiKey });
10
12
  const limit = parseInt(flags.limit, 10) || 5;
11
13
  const timeoutMs = (parseInt(flags.timeout, 10) || 30) * 1000;
12
14
 
@@ -15,7 +17,7 @@ export async function runDiscover(query, flags) {
15
17
  try {
16
18
  const result = await discoverTools({
17
19
  apiKey,
18
- baseUrl: flags.baseUrl,
20
+ baseUrl: resolvedBaseUrl,
19
21
  query,
20
22
  limit,
21
23
  timeoutMs,
@@ -28,6 +30,8 @@ export async function runDiscover(query, flags) {
28
30
  writeSession({
29
31
  discoveryId: result.search_id,
30
32
  query,
33
+ region,
34
+ baseUrl: resolvedBaseUrl,
31
35
  results: tools.map((t, i) => ({
32
36
  index: i + 1,
33
37
  tool_id: t.tool_id,
@@ -1,5 +1,6 @@
1
1
  import { resolve } from "../config/resolve.mjs";
2
2
  import { discoverTools } from "../client/api.mjs";
3
+ import { resolveBaseUrl } from "../config/region.mjs";
3
4
  import { bold, green, red, dim, cyan } from "../output/colors.mjs";
4
5
 
5
6
  export async function runDoctor(flags) {
@@ -22,10 +23,14 @@ export async function runDoctor(flags) {
22
23
  const masked = apiKey.slice(0, 6) + "..." + apiKey.slice(-4);
23
24
  console.log(` ${green("\u2713")} API key configured (${masked} via ${source})`);
24
25
 
26
+ // Show resolved region
27
+ const { baseUrl, region, source: regionSource } = resolveBaseUrl({ baseUrlFlag: flags.baseUrl, apiKey });
28
+ console.log(` ${green("\u2713")} Region: ${region} ${dim(`(${regionSource})`)} → ${dim(baseUrl)}`);
29
+
25
30
  // Test connectivity
26
31
  process.stderr.write(` \u2026 Testing API connectivity...\r`);
27
32
  try {
28
- await discoverTools({ apiKey, baseUrl: flags.baseUrl, query: "test", limit: 1, timeoutMs: 10000 });
33
+ await discoverTools({ apiKey, baseUrl, query: "test", limit: 1, timeoutMs: 10000 });
29
34
  console.log(` ${green("\u2713")} API connectivity OK `);
30
35
  } catch (err) {
31
36
  console.log(` ${red("\u2718")} API connectivity failed: ${err.message} `);
@@ -1,6 +1,7 @@
1
1
  import { createInterface } from "node:readline";
2
2
  import { resolveApiKey } from "../client/auth.mjs";
3
3
  import { discoverTools, inspectToolsByIds, callTool } from "../client/api.mjs";
4
+ import { resolveBaseUrl } from "../config/region.mjs";
4
5
  import { resolveParams } from "../utils/params.mjs";
5
6
  import { formatDiscoverResult, formatInspectResult, formatCallResult } from "../output/formatter.mjs";
6
7
  import { generateSnippet } from "../output/codegen.mjs";
@@ -13,7 +14,7 @@ import { createSpinner } from "../output/spinner.mjs";
13
14
 
14
15
  export async function runInteractive(flags) {
15
16
  const apiKey = resolveApiKey(flags.apiKey);
16
- const baseUrl = flags.baseUrl;
17
+ const { region, baseUrl: resolvedBaseUrl } = resolveBaseUrl({ baseUrlFlag: flags.baseUrl, apiKey });
17
18
  const limit = parseInt(flags.limit, 10) || 5;
18
19
  const discoverTimeout = (parseInt(flags.timeout, 10) || 30) * 1000;
19
20
  const callTimeout = (parseInt(flags.timeout, 10) || 60) * 1000;
@@ -56,14 +57,14 @@ export async function runInteractive(flags) {
56
57
  const query = rest.join(" ");
57
58
  if (!query) { console.log(" Usage: discover <query>"); break; }
58
59
  const sp = createSpinner("Discovering...");
59
- const result = await discoverTools({ apiKey, baseUrl, query, limit, timeoutMs: discoverTimeout });
60
+ const result = await discoverTools({ apiKey, baseUrl: resolvedBaseUrl, query, limit, timeoutMs: discoverTimeout });
60
61
  sp.stop();
61
62
  state.discoveryId = result.search_id;
62
63
  state.results = (result.results ?? []).map((t, i) => ({
63
64
  index: i + 1, tool_id: t.tool_id, name: t.name, provider_name: t.provider_name,
64
65
  }));
65
66
  // Persist session so index shortcuts work in subsequent non-interactive calls
66
- writeSession({ discoveryId: result.search_id, query, results: state.results });
67
+ writeSession({ discoveryId: result.search_id, query, region, baseUrl: resolvedBaseUrl, results: state.results });
67
68
  console.log(formatDiscoverResult(result));
68
69
  break;
69
70
  }
@@ -71,7 +72,7 @@ export async function runInteractive(flags) {
71
72
  const toolId = resolveId(rest[0], state);
72
73
  if (!toolId) { console.log(" Usage: inspect <index|tool_id>"); break; }
73
74
  const sp2 = createSpinner("Inspecting...");
74
- const result = await inspectToolsByIds({ apiKey, baseUrl, toolIds: [toolId], discoveryId: state.discoveryId, timeoutMs: discoverTimeout });
75
+ const result = await inspectToolsByIds({ apiKey, baseUrl: resolvedBaseUrl, toolIds: [toolId], discoveryId: state.discoveryId, timeoutMs: discoverTimeout });
75
76
  sp2.stop();
76
77
  console.log(formatInspectResult(result));
77
78
  break;
@@ -83,7 +84,7 @@ export async function runInteractive(flags) {
83
84
  const paramsStr = rest.slice(1).join(" ") || "{}";
84
85
  const parameters = resolveParams(paramsStr);
85
86
  const sp3 = createSpinner("Calling...");
86
- const result = await callTool({ apiKey, baseUrl, toolId, discoveryId: state.discoveryId, parameters, timeoutMs: callTimeout });
87
+ const result = await callTool({ apiKey, baseUrl: resolvedBaseUrl, toolId, discoveryId: state.discoveryId, parameters, timeoutMs: callTimeout });
87
88
  sp3.stop();
88
89
  console.log(formatCallResult(result));
89
90
  if (result.success) {
@@ -0,0 +1,186 @@
1
+ import { resolveApiKey } from "../client/auth.mjs";
2
+ import { getCreditsLedger, unwrapApiResponse } from "../client/api.mjs";
3
+ import { outputJson } from "../output/json.mjs";
4
+ import { createSpinner } from "../output/spinner.mjs";
5
+ import {
6
+ DEFAULT_PAGE_SIZE,
7
+ MAX_EXPORT_ROWS,
8
+ MAX_SEARCH_SCAN_ROWS,
9
+ MAX_SUMMARY_ROWS,
10
+ buildLedgerQuery,
11
+ buildLedgerSummary,
12
+ buildLedgerSummaryFromServer,
13
+ chooseBucket,
14
+ clampLimit,
15
+ extractItems,
16
+ extractTotal,
17
+ formatExportMetadata,
18
+ formatLedgerRows,
19
+ formatLedgerSummary,
20
+ matchesLedgerFilters,
21
+ pickLedgerRow,
22
+ resolveDateRange,
23
+ resolveMode,
24
+ writeJsonlExport,
25
+ } from "../output/audit.mjs";
26
+
27
+ export async function runLedger(flags) {
28
+ const apiKey = resolveApiKey(flags.apiKey);
29
+ const mode = resolveMode(flags.mode);
30
+ const timeoutMs = (parseInt(flags.timeout, 10) || 30) * 1000;
31
+ const limit = clampLimit(flags.limit);
32
+ const { startDate, endDate } = resolveDateRange(flags);
33
+ const bucket = chooseBucket(startDate, endDate, flags.bucket);
34
+
35
+ const spinner = flags.json ? { stop() {} } : createSpinner("Querying credits ledger...");
36
+
37
+ try {
38
+ if (mode === "search") {
39
+ const { rows, total, partial } = await collectLedgerRows({
40
+ apiKey,
41
+ flags,
42
+ timeoutMs,
43
+ limit,
44
+ maxRows: MAX_SEARCH_SCAN_ROWS,
45
+ stopWhenLimitReached: true,
46
+ });
47
+ spinner.stop();
48
+ const payload = {
49
+ mode,
50
+ start_date: startDate,
51
+ end_date: endDate,
52
+ shown_records: rows.length,
53
+ matched_records: total,
54
+ truncated: partial,
55
+ items: rows.map(pickLedgerRow),
56
+ };
57
+ if (flags.json) outputJson(payload);
58
+ else console.log(formatLedgerRows(rows, { total, partial }));
59
+ return;
60
+ }
61
+
62
+ if (mode === "export_file") {
63
+ const { rows, total, partial } = await collectLedgerRows({
64
+ apiKey,
65
+ flags,
66
+ timeoutMs,
67
+ limit: MAX_EXPORT_ROWS,
68
+ maxRows: MAX_EXPORT_ROWS,
69
+ stopWhenLimitReached: false,
70
+ });
71
+ spinner.stop();
72
+ const metadata = writeJsonlExport("credits_ledger", rows, {
73
+ start_date: startDate,
74
+ end_date: endDate,
75
+ matched_records: total,
76
+ truncated: partial,
77
+ filters: ledgerFilters(flags),
78
+ });
79
+ if (flags.json) outputJson(metadata);
80
+ else console.log(formatExportMetadata(metadata));
81
+ return;
82
+ }
83
+
84
+ const summaryResponse = unwrapApiResponse(await getCreditsLedger({
85
+ apiKey,
86
+ baseUrl: flags.baseUrl,
87
+ query: buildLedgerQuery(flags, {
88
+ page: 1,
89
+ pageSize: limit,
90
+ mode: "summary",
91
+ }),
92
+ timeoutMs,
93
+ }));
94
+ if (summaryResponse?.summary) {
95
+ spinner.stop();
96
+ const rows = extractItems(summaryResponse);
97
+ const total = extractTotal(summaryResponse, rows.length);
98
+ const summary = buildLedgerSummaryFromServer(summaryResponse.summary, { startDate, endDate, bucket });
99
+ if (flags.json) {
100
+ outputJson({
101
+ mode,
102
+ summary,
103
+ scanned_records: rows.length,
104
+ matched_records: total,
105
+ truncated: false,
106
+ });
107
+ } else {
108
+ console.log(formatLedgerSummary(summary, { scannedRows: rows.length, total, partial: false }));
109
+ }
110
+ return;
111
+ }
112
+
113
+ const { rows, total, partial, scannedRows } = await collectLedgerRows({
114
+ apiKey,
115
+ flags,
116
+ timeoutMs,
117
+ limit: MAX_SUMMARY_ROWS,
118
+ maxRows: MAX_SUMMARY_ROWS,
119
+ stopWhenLimitReached: false,
120
+ });
121
+ spinner.stop();
122
+ const summary = buildLedgerSummary(rows, { startDate, endDate, bucket });
123
+ if (flags.json) {
124
+ outputJson({
125
+ mode,
126
+ summary,
127
+ scanned_records: scannedRows,
128
+ matched_records: total,
129
+ truncated: partial,
130
+ });
131
+ } else {
132
+ console.log(formatLedgerSummary(summary, { scannedRows, total, partial }));
133
+ }
134
+ } catch (err) {
135
+ spinner.stop();
136
+ throw err;
137
+ }
138
+ }
139
+
140
+ async function collectLedgerRows({ apiKey, flags, timeoutMs, limit, maxRows, stopWhenLimitReached }) {
141
+ const rows = [];
142
+ let page = 1;
143
+ let total;
144
+ let scannedRows = 0;
145
+ let partial = false;
146
+
147
+ while (rows.length < limit && scannedRows < maxRows) {
148
+ const query = buildLedgerQuery(flags, { page, pageSize: Math.min(DEFAULT_PAGE_SIZE, maxRows - scannedRows) });
149
+ const response = unwrapApiResponse(await getCreditsLedger({
150
+ apiKey,
151
+ baseUrl: flags.baseUrl,
152
+ query,
153
+ timeoutMs,
154
+ }));
155
+ const items = extractItems(response);
156
+ if (total === undefined) total = extractTotal(response, undefined);
157
+ if (items.length === 0) break;
158
+ scannedRows += items.length;
159
+ for (const item of items) {
160
+ if (!matchesLedgerFilters(item, flags)) continue;
161
+ rows.push(item);
162
+ if (stopWhenLimitReached && rows.length >= limit) break;
163
+ }
164
+ if (stopWhenLimitReached && rows.length >= limit) {
165
+ partial = Boolean(total && total > scannedRows);
166
+ break;
167
+ }
168
+ if (total !== undefined && scannedRows >= total) break;
169
+ if (items.length < DEFAULT_PAGE_SIZE) break;
170
+ page += 1;
171
+ }
172
+
173
+ if (total !== undefined && scannedRows < total) partial = true;
174
+ if (scannedRows >= maxRows && (total === undefined || scannedRows < total)) partial = true;
175
+
176
+ return { rows: rows.slice(0, limit), total, partial, scannedRows };
177
+ }
178
+
179
+ function ledgerFilters(flags) {
180
+ return {
181
+ entry_type: flags.entryType,
182
+ direction: flags.direction,
183
+ min_credits: flags.minCredits,
184
+ max_credits: flags.maxCredits,
185
+ };
186
+ }