webseek 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 +109 -2
- package/dist/cli/index.cjs +439 -6
- package/dist/cli/index.mjs +439 -6
- package/dist/index.cjs +2 -1
- package/dist/index.d.cts +56 -9
- package/dist/index.d.mts +56 -9
- package/dist/index.mjs +2 -2
- package/dist/{tools-DBF9nCIs.mjs → tools-1ZllUYBx.mjs} +269 -5
- package/dist/{tools-Ci1EDrCT.cjs → tools-Brr43TiA.cjs} +274 -4
- package/package.json +15 -15
package/README.md
CHANGED
|
@@ -19,6 +19,41 @@ The Gemini provider supports two backends that share the same request/response
|
|
|
19
19
|
shape: the **Gemini Developer API** (`gemini-api`, default) and **Vertex AI
|
|
20
20
|
express mode** (`vertex-express`).
|
|
21
21
|
|
|
22
|
+
### Usage and cost
|
|
23
|
+
|
|
24
|
+
Every search reports what it consumed and an estimated cost in USD. The text
|
|
25
|
+
output ends with a line such as:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
Usage: 1,100 tokens (1,000 in, 100 out), 1 search, ~$0.0180
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
JSON output (and the MCP tool result) carries the same data as `usage` and
|
|
32
|
+
`cost`, plus the `model` that served the search:
|
|
33
|
+
|
|
34
|
+
```jsonc
|
|
35
|
+
{
|
|
36
|
+
"model": "gpt-5.5",
|
|
37
|
+
"usage": { "inputTokens": 1000, "outputTokens": 100, "totalTokens": 1100, "searchCalls": 1 },
|
|
38
|
+
"cost": { "totalUsd": 0.018, "tokensUsd": 0.008, "searchUsd": 0.01 },
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- `usage.inputTokens` includes cached tokens (`usage.cachedInputTokens`) and the
|
|
43
|
+
search content the model read; `usage.outputTokens` includes reasoning/thinking
|
|
44
|
+
tokens. SERP providers (`google`) report no tokens.
|
|
45
|
+
- `usage.searchCalls` counts OpenAI `web_search` calls, Gemini grounding queries,
|
|
46
|
+
or Google Custom Search requests (one per page of 10 results).
|
|
47
|
+
- `cost` is computed from built-in list prices: token rates for the model plus
|
|
48
|
+
the provider's search fee (OpenAI $10 per 1k calls, Gemini 3.x $14 per 1k
|
|
49
|
+
queries, Gemini 2.5 $35 per 1k grounded prompts, Google Custom Search $5 per 1k
|
|
50
|
+
queries). It is a list-price estimate, not your bill: free allowances,
|
|
51
|
+
discounts and long-context rates are **not** applied. `cost` is omitted when the model has no known price
|
|
52
|
+
or the provider reported no token counts.
|
|
53
|
+
|
|
54
|
+
The CLI and the MCP server also append every successful search to a local usage
|
|
55
|
+
log, which [`webseek stats`](#usage-stats) totals.
|
|
56
|
+
|
|
22
57
|
## Install
|
|
23
58
|
|
|
24
59
|
```bash
|
|
@@ -61,6 +96,54 @@ webseek "summarize the latest TypeScript release" -p openai
|
|
|
61
96
|
webseek "who won euro 2024" -p gemini --gemini-backend vertex-express --json
|
|
62
97
|
```
|
|
63
98
|
|
|
99
|
+
`mcp` and `stats` are subcommands: a query whose first word is `mcp` or
|
|
100
|
+
`stats` must be quoted as a whole (`webseek "stats for the nba" -p openai`), and
|
|
101
|
+
a query of just that one word cannot be searched from the CLI.
|
|
102
|
+
|
|
103
|
+
### Usage stats
|
|
104
|
+
|
|
105
|
+
Every successful search — from the CLI or the MCP server — is appended as one
|
|
106
|
+
JSON line to `usage.jsonl` in the data directory: the time, provider, model,
|
|
107
|
+
`usage` and `cost`. The query text is **not** stored. `webseek stats` totals
|
|
108
|
+
the log, with flags modelled on `opencode stats`:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
webseek stats # this year so far
|
|
112
|
+
webseek stats --days 7 --full # the last 7 days with every section
|
|
113
|
+
webseek stats --all -p openai --models
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
webseek stats · 2026 so far · all providers
|
|
118
|
+
|
|
119
|
+
3 searches · 4 search calls · 12.9k tokens
|
|
120
|
+
~$0.0550 estimated · 1 active day
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
| Flag | Description |
|
|
124
|
+
| ----------------------- | --------------------------------------------------- |
|
|
125
|
+
| `--days <n>` | The last N days; `0` means today |
|
|
126
|
+
| `--year <year>` | A calendar year (default: this year so far) |
|
|
127
|
+
| `--all` | Lifetime statistics |
|
|
128
|
+
| `-p, --provider <name>` | Only count one provider |
|
|
129
|
+
| `--cost` | Cost split (tokens / search fees) and token details |
|
|
130
|
+
| `--models` | Searches, tokens and cost per model |
|
|
131
|
+
| `--full` | Every detailed section |
|
|
132
|
+
| `--limit <n>` | Number of rows in the models section |
|
|
133
|
+
| `--json` | Statistics as JSON |
|
|
134
|
+
|
|
135
|
+
`--days`, `--year` and `--all` cannot be combined. Costs are the same list-price
|
|
136
|
+
estimates as in each search's output; searches whose model has no known price
|
|
137
|
+
are counted but left out of the cost (and reported as unpriced).
|
|
138
|
+
|
|
139
|
+
| Environment variable | Effect |
|
|
140
|
+
| -------------------- | -------------------------------------------------------------------------------- |
|
|
141
|
+
| `WEBSEEK_DATA_DIR` | Data directory (default `$XDG_DATA_HOME/webseek`, else `~/.local/share/webseek`) |
|
|
142
|
+
| `WEBSEEK_USAGE_LOG` | Set to `0`, `false` or `off` to stop recording searches |
|
|
143
|
+
|
|
144
|
+
Delete `usage.jsonl` to reset the statistics. A failure to write the log is
|
|
145
|
+
reported as a warning on stderr and never fails the search.
|
|
146
|
+
|
|
64
147
|
## MCP server mode
|
|
65
148
|
|
|
66
149
|
Start a [Model Context Protocol](https://modelcontextprotocol.io) server over
|
|
@@ -105,8 +188,30 @@ CommonJS consumers can `require` it the same way:
|
|
|
105
188
|
const { runSearch } = require("webseek");
|
|
106
189
|
```
|
|
107
190
|
|
|
191
|
+
Each result carries `usage` and, when it can be priced, an estimated `cost` (see
|
|
192
|
+
[Usage and cost](#usage-and-cost)). To price usage yourself — for example to sum
|
|
193
|
+
several searches — call `estimateCost` with the provider, the model and a
|
|
194
|
+
`usage` object; it returns `undefined` when the model has no known price:
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
import { estimateCost } from "webseek";
|
|
198
|
+
|
|
199
|
+
const cost = estimateCost({
|
|
200
|
+
provider: "openai",
|
|
201
|
+
model: "gpt-5.5",
|
|
202
|
+
usage: { inputTokens: 1000, outputTokens: 100, searchCalls: 1 },
|
|
203
|
+
});
|
|
204
|
+
console.log(cost?.totalUsd.toFixed(3)); // "0.018"
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Prices are list prices built into the package and change with its releases.
|
|
208
|
+
|
|
108
209
|
To embed the `web_search` tool into your own MCP server, use the
|
|
109
|
-
`createWebSearchTool` factory exported from the same entry point.
|
|
210
|
+
`createWebSearchTool` factory exported from the same entry point. Its optional
|
|
211
|
+
`onResult` callback receives every successful result (the `webseek mcp` server
|
|
212
|
+
uses it to write the usage log).
|
|
213
|
+
|
|
214
|
+
The library itself never writes the usage log; only the CLI and `webseek mcp` do.
|
|
110
215
|
|
|
111
216
|
## Authentication
|
|
112
217
|
|
|
@@ -135,10 +240,12 @@ they don't leak into shell history or process listings.
|
|
|
135
240
|
```
|
|
136
241
|
src/
|
|
137
242
|
index.ts public library entry (runSearch, types, createWebSearchTool)
|
|
138
|
-
cli/ commander program: root search command and `
|
|
243
|
+
cli/ commander program: root search command, `mcp` and `stats` commands
|
|
139
244
|
mcp/ MCP server + the web_search tool
|
|
140
245
|
lib/ runSearch — the shared core called by both CLI and MCP
|
|
141
246
|
providers/ per-provider implementations (openai, google-cse, gemini)
|
|
247
|
+
pricing/ list-price table and cost estimation
|
|
248
|
+
stats/ local usage log, aggregation and the `stats` report
|
|
142
249
|
config/ credential + base-URL resolution from env
|
|
143
250
|
output/ text / JSON formatting
|
|
144
251
|
utils/ logger, error formatter
|
package/dist/cli/index.cjs
CHANGED
|
@@ -1,10 +1,122 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
const require_tools = require("../tools-
|
|
2
|
+
const require_tools = require("../tools-Brr43TiA.cjs");
|
|
3
|
+
let zod = require("zod");
|
|
3
4
|
let node_fs = require("node:fs");
|
|
4
5
|
let node_path = require("node:path");
|
|
5
6
|
let commander = require("commander");
|
|
6
7
|
let _modelcontextprotocol_sdk_server_mcp_js = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
7
8
|
let _modelcontextprotocol_sdk_server_stdio_js = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
9
|
+
let node_fs_promises = require("node:fs/promises");
|
|
10
|
+
let node_os = require("node:os");
|
|
11
|
+
//#region src/stats/usage-log.ts
|
|
12
|
+
/**
|
|
13
|
+
* The local usage log: one JSON line per successful search, appended by the
|
|
14
|
+
* CLI and the MCP server and read back by `webseek stats`.
|
|
15
|
+
*
|
|
16
|
+
* Records hold what a search consumed and cost — never the query text — so the
|
|
17
|
+
* log is safe to keep around. The library entry point never writes to it.
|
|
18
|
+
*/
|
|
19
|
+
const USAGE_LOG_FILE = "usage.jsonl";
|
|
20
|
+
const RECORD_VERSION = 1;
|
|
21
|
+
/**
|
|
22
|
+
* The directory the usage log lives in: `WEBSEEK_DATA_DIR`, else
|
|
23
|
+
* `$XDG_DATA_HOME/webseek`, else `~/.local/share/webseek`.
|
|
24
|
+
*/
|
|
25
|
+
function resolveDataDir(params = {}) {
|
|
26
|
+
const env = params.env ?? process.env;
|
|
27
|
+
if (env.WEBSEEK_DATA_DIR) return env.WEBSEEK_DATA_DIR;
|
|
28
|
+
if (env.XDG_DATA_HOME) return (0, node_path.join)(env.XDG_DATA_HOME, "webseek");
|
|
29
|
+
return (0, node_path.join)((0, node_os.homedir)(), ".local", "share", "webseek");
|
|
30
|
+
}
|
|
31
|
+
function resolveUsageLogPath(params = {}) {
|
|
32
|
+
return (0, node_path.join)(resolveDataDir(params), USAGE_LOG_FILE);
|
|
33
|
+
}
|
|
34
|
+
/** Logging is on unless `WEBSEEK_USAGE_LOG` is `0`, `false` or `off`. */
|
|
35
|
+
function isUsageLogEnabled(params = {}) {
|
|
36
|
+
const value = (params.env ?? process.env).WEBSEEK_USAGE_LOG?.trim().toLowerCase();
|
|
37
|
+
return value !== "0" && value !== "false" && value !== "off";
|
|
38
|
+
}
|
|
39
|
+
function toUsageRecord(params) {
|
|
40
|
+
const { result } = params;
|
|
41
|
+
return {
|
|
42
|
+
v: RECORD_VERSION,
|
|
43
|
+
at: params.now ?? Date.now(),
|
|
44
|
+
provider: result.provider,
|
|
45
|
+
model: result.model,
|
|
46
|
+
usage: result.usage,
|
|
47
|
+
cost: result.cost
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Append a search to the usage log. Never throws: a failed write is reported
|
|
52
|
+
* through `onError` so it cannot fail (or corrupt the output of) the search.
|
|
53
|
+
*/
|
|
54
|
+
async function appendUsageRecord(params) {
|
|
55
|
+
if (!isUsageLogEnabled({ env: params.env })) return;
|
|
56
|
+
try {
|
|
57
|
+
const dir = resolveDataDir({ env: params.env });
|
|
58
|
+
await (0, node_fs_promises.mkdir)(dir, {
|
|
59
|
+
recursive: true,
|
|
60
|
+
mode: 448
|
|
61
|
+
});
|
|
62
|
+
const record = toUsageRecord({
|
|
63
|
+
result: params.result,
|
|
64
|
+
now: params.now
|
|
65
|
+
});
|
|
66
|
+
await (0, node_fs_promises.appendFile)((0, node_path.join)(dir, USAGE_LOG_FILE), `${JSON.stringify(record)}\n`, {
|
|
67
|
+
encoding: "utf8",
|
|
68
|
+
mode: 384
|
|
69
|
+
});
|
|
70
|
+
} catch (error) {
|
|
71
|
+
params.onError?.(error);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Read every valid record from the usage log; a missing log reads as empty. */
|
|
75
|
+
async function readUsageRecords(params = {}) {
|
|
76
|
+
let text;
|
|
77
|
+
try {
|
|
78
|
+
text = await (0, node_fs_promises.readFile)(resolveUsageLogPath(params), "utf8");
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (error.code === "ENOENT") return [];
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
return parseUsageRecords(text);
|
|
84
|
+
}
|
|
85
|
+
const usageRecordSchema = zod.z.looseObject({
|
|
86
|
+
v: zod.z.literal(RECORD_VERSION),
|
|
87
|
+
at: zod.z.number(),
|
|
88
|
+
provider: zod.z.enum(require_tools.PROVIDER_NAMES),
|
|
89
|
+
model: zod.z.string().optional(),
|
|
90
|
+
usage: zod.z.looseObject({
|
|
91
|
+
inputTokens: zod.z.number().optional(),
|
|
92
|
+
cachedInputTokens: zod.z.number().optional(),
|
|
93
|
+
outputTokens: zod.z.number().optional(),
|
|
94
|
+
totalTokens: zod.z.number().optional(),
|
|
95
|
+
searchCalls: zod.z.number()
|
|
96
|
+
}).optional(),
|
|
97
|
+
cost: zod.z.looseObject({
|
|
98
|
+
totalUsd: zod.z.number(),
|
|
99
|
+
tokensUsd: zod.z.number(),
|
|
100
|
+
searchUsd: zod.z.number()
|
|
101
|
+
}).optional()
|
|
102
|
+
});
|
|
103
|
+
/** Parse JSONL, skipping blank, malformed and unknown-version lines. */
|
|
104
|
+
function parseUsageRecords(text) {
|
|
105
|
+
const records = [];
|
|
106
|
+
for (const line of text.split("\n")) {
|
|
107
|
+
if (!line.trim()) continue;
|
|
108
|
+
let json;
|
|
109
|
+
try {
|
|
110
|
+
json = JSON.parse(line);
|
|
111
|
+
} catch {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const parsed = usageRecordSchema.safeParse(json);
|
|
115
|
+
if (parsed.success) records.push(parsed.data);
|
|
116
|
+
}
|
|
117
|
+
return records;
|
|
118
|
+
}
|
|
119
|
+
//#endregion
|
|
8
120
|
//#region src/mcp/server.ts
|
|
9
121
|
/**
|
|
10
122
|
* MCP server mode: exposes the `web_search` tool over the stdio transport so
|
|
@@ -17,7 +129,10 @@ async function startMcpServer(params) {
|
|
|
17
129
|
name: "webseek",
|
|
18
130
|
version: params.version
|
|
19
131
|
});
|
|
20
|
-
const tool = require_tools.createWebSearchTool()
|
|
132
|
+
const tool = require_tools.createWebSearchTool({ onResult: (result) => appendUsageRecord({
|
|
133
|
+
result,
|
|
134
|
+
onError: (error) => params.logger.warn(`could not record usage: ${require_tools.formatError(error)}`)
|
|
135
|
+
}) });
|
|
21
136
|
server.registerTool(tool.name, tool.config, tool.handler);
|
|
22
137
|
const transport = new _modelcontextprotocol_sdk_server_stdio_js.StdioServerTransport();
|
|
23
138
|
await server.connect(transport);
|
|
@@ -102,7 +217,10 @@ function formatJson(result) {
|
|
|
102
217
|
results: result.results,
|
|
103
218
|
answer: result.answer,
|
|
104
219
|
citations: result.citations,
|
|
105
|
-
searchQueries: result.searchQueries
|
|
220
|
+
searchQueries: result.searchQueries,
|
|
221
|
+
model: result.model,
|
|
222
|
+
usage: result.usage,
|
|
223
|
+
cost: result.cost
|
|
106
224
|
};
|
|
107
225
|
if (result.raw !== void 0) payload.raw = result.raw;
|
|
108
226
|
return JSON.stringify(payload, null, 2);
|
|
@@ -128,8 +246,29 @@ function formatText(result) {
|
|
|
128
246
|
lines.push("");
|
|
129
247
|
}
|
|
130
248
|
if (result.searchQueries.length > 0) lines.push(`Searches: ${result.searchQueries.join(" | ")}`);
|
|
249
|
+
if (result.usage) lines.push(formatUsage({
|
|
250
|
+
usage: result.usage,
|
|
251
|
+
cost: result.cost
|
|
252
|
+
}));
|
|
131
253
|
return lines.join("\n").trimEnd();
|
|
132
254
|
}
|
|
255
|
+
function formatUsage(params) {
|
|
256
|
+
const { usage, cost } = params;
|
|
257
|
+
const parts = [];
|
|
258
|
+
if (usage.inputTokens !== void 0 || usage.outputTokens !== void 0) {
|
|
259
|
+
const input = usage.inputTokens ?? 0;
|
|
260
|
+
const output = usage.outputTokens ?? 0;
|
|
261
|
+
const total = usage.totalTokens ?? input + output;
|
|
262
|
+
parts.push(`${total.toLocaleString("en-US")} tokens (${input.toLocaleString("en-US")} in, ${output.toLocaleString("en-US")} out)`);
|
|
263
|
+
}
|
|
264
|
+
parts.push(`${usage.searchCalls} search${usage.searchCalls === 1 ? "" : "es"}`);
|
|
265
|
+
parts.push(cost === void 0 ? "cost unknown" : `~${formatUsd(cost.totalUsd)}`);
|
|
266
|
+
return `Usage: ${parts.join(", ")}`;
|
|
267
|
+
}
|
|
268
|
+
function formatUsd(value) {
|
|
269
|
+
if (value > 0 && value < 5e-5) return "<$0.0001";
|
|
270
|
+
return `$${value.toFixed(4)}`;
|
|
271
|
+
}
|
|
133
272
|
//#endregion
|
|
134
273
|
//#region src/cli/commands/search.ts
|
|
135
274
|
/**
|
|
@@ -152,10 +291,15 @@ function toSearchRequest(params) {
|
|
|
152
291
|
};
|
|
153
292
|
}
|
|
154
293
|
async function runSearchCommand(params) {
|
|
155
|
-
const
|
|
294
|
+
const request = toSearchRequest({
|
|
156
295
|
queryParts: params.queryParts,
|
|
157
296
|
options: params.options
|
|
158
|
-
})
|
|
297
|
+
});
|
|
298
|
+
const result = await require_tools.runSearch(request);
|
|
299
|
+
await appendUsageRecord({
|
|
300
|
+
result,
|
|
301
|
+
onError: (error) => params.logger.warn(`could not record usage: ${require_tools.formatError(error)}`)
|
|
302
|
+
});
|
|
159
303
|
params.logger.result(formatResult({
|
|
160
304
|
result,
|
|
161
305
|
json: Boolean(params.options.json)
|
|
@@ -175,6 +319,293 @@ function registerSearchCommand(program) {
|
|
|
175
319
|
}));
|
|
176
320
|
}
|
|
177
321
|
//#endregion
|
|
322
|
+
//#region src/stats/aggregate.ts
|
|
323
|
+
/**
|
|
324
|
+
* `--all` → all time; `--days N` → since local midnight N-1 days ago (0 or 1
|
|
325
|
+
* mean today); `--year Y` → that calendar year; default → this year so far.
|
|
326
|
+
*/
|
|
327
|
+
function resolveStatsRange(params = {}) {
|
|
328
|
+
const now = params.now ?? /* @__PURE__ */ new Date();
|
|
329
|
+
const to = now.getTime() + 1;
|
|
330
|
+
if (params.all) return {
|
|
331
|
+
from: void 0,
|
|
332
|
+
to,
|
|
333
|
+
label: "all time"
|
|
334
|
+
};
|
|
335
|
+
if (params.days !== void 0) {
|
|
336
|
+
const from = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
337
|
+
from.setDate(from.getDate() - Math.max(0, params.days - 1));
|
|
338
|
+
return {
|
|
339
|
+
from: from.getTime(),
|
|
340
|
+
to,
|
|
341
|
+
label: params.days <= 1 ? "today" : `last ${params.days} days`
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
const year = params.year ?? now.getFullYear();
|
|
345
|
+
const isCurrentYear = year === now.getFullYear();
|
|
346
|
+
return {
|
|
347
|
+
from: new Date(year, 0, 1).getTime(),
|
|
348
|
+
to: isCurrentYear ? to : new Date(year + 1, 0, 1).getTime(),
|
|
349
|
+
label: isCurrentYear ? `${year} so far` : String(year)
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
function aggregateUsage(params) {
|
|
353
|
+
const { range } = params;
|
|
354
|
+
const stats = {
|
|
355
|
+
range: {
|
|
356
|
+
from: range.from,
|
|
357
|
+
to: range.to
|
|
358
|
+
},
|
|
359
|
+
searches: 0,
|
|
360
|
+
searchCalls: 0,
|
|
361
|
+
tokens: {
|
|
362
|
+
input: 0,
|
|
363
|
+
cachedInput: 0,
|
|
364
|
+
output: 0,
|
|
365
|
+
total: 0
|
|
366
|
+
},
|
|
367
|
+
cost: {
|
|
368
|
+
totalUsd: 0,
|
|
369
|
+
tokensUsd: 0,
|
|
370
|
+
searchUsd: 0
|
|
371
|
+
},
|
|
372
|
+
unpricedSearches: 0,
|
|
373
|
+
activeDays: 0,
|
|
374
|
+
models: []
|
|
375
|
+
};
|
|
376
|
+
const days = /* @__PURE__ */ new Set();
|
|
377
|
+
const models = /* @__PURE__ */ new Map();
|
|
378
|
+
for (const record of params.records) {
|
|
379
|
+
if (!((range.from === void 0 || record.at >= range.from) && record.at < range.to) || params.provider !== void 0 && record.provider !== params.provider) continue;
|
|
380
|
+
addRecord({
|
|
381
|
+
stats,
|
|
382
|
+
models,
|
|
383
|
+
record
|
|
384
|
+
});
|
|
385
|
+
const at = new Date(record.at);
|
|
386
|
+
days.add(`${at.getFullYear()}-${at.getMonth()}-${at.getDate()}`);
|
|
387
|
+
}
|
|
388
|
+
stats.activeDays = days.size;
|
|
389
|
+
stats.models = [...models.values()].toSorted((a, b) => b.costUsd - a.costUsd || b.searches - a.searches);
|
|
390
|
+
return stats;
|
|
391
|
+
}
|
|
392
|
+
function addRecord(params) {
|
|
393
|
+
const { stats, record } = params;
|
|
394
|
+
const usage = record.usage;
|
|
395
|
+
const input = usage?.inputTokens ?? 0;
|
|
396
|
+
const output = usage?.outputTokens ?? 0;
|
|
397
|
+
const total = usage?.totalTokens ?? input + output;
|
|
398
|
+
const searchCalls = usage?.searchCalls ?? 0;
|
|
399
|
+
stats.searches += 1;
|
|
400
|
+
stats.searchCalls += searchCalls;
|
|
401
|
+
stats.tokens.input += input;
|
|
402
|
+
stats.tokens.cachedInput += usage?.cachedInputTokens ?? 0;
|
|
403
|
+
stats.tokens.output += output;
|
|
404
|
+
stats.tokens.total += total;
|
|
405
|
+
if (record.cost) {
|
|
406
|
+
stats.cost.totalUsd += record.cost.totalUsd;
|
|
407
|
+
stats.cost.tokensUsd += record.cost.tokensUsd;
|
|
408
|
+
stats.cost.searchUsd += record.cost.searchUsd;
|
|
409
|
+
} else stats.unpricedSearches += 1;
|
|
410
|
+
const key = `${record.provider}/${record.model ?? ""}`;
|
|
411
|
+
const model = params.models.get(key) ?? {
|
|
412
|
+
provider: record.provider,
|
|
413
|
+
model: record.model,
|
|
414
|
+
searches: 0,
|
|
415
|
+
searchCalls: 0,
|
|
416
|
+
tokens: 0,
|
|
417
|
+
costUsd: 0,
|
|
418
|
+
unpricedSearches: 0
|
|
419
|
+
};
|
|
420
|
+
model.searches += 1;
|
|
421
|
+
model.searchCalls += searchCalls;
|
|
422
|
+
model.tokens += total;
|
|
423
|
+
model.costUsd += record.cost?.totalUsd ?? 0;
|
|
424
|
+
if (!record.cost) model.unpricedSearches += 1;
|
|
425
|
+
params.models.set(key, model);
|
|
426
|
+
}
|
|
427
|
+
//#endregion
|
|
428
|
+
//#region src/stats/render.ts
|
|
429
|
+
/**
|
|
430
|
+
* Renders aggregated usage as the `webseek stats` text report, laid out after
|
|
431
|
+
* `opencode stats`: a summary by default, plus COST & TOKENS and MODELS
|
|
432
|
+
* sections on request.
|
|
433
|
+
*/
|
|
434
|
+
function renderStats(params) {
|
|
435
|
+
const { stats } = params;
|
|
436
|
+
const detailed = params.cost || params.models;
|
|
437
|
+
const header = `webseek stats · ${params.label} · ${params.scope}`;
|
|
438
|
+
const lines = [];
|
|
439
|
+
if (detailed) lines.push(`${params.label} · ${params.scope}`);
|
|
440
|
+
else if (stats.searches === 0) lines.push(header, "", "no searches in this range");
|
|
441
|
+
else {
|
|
442
|
+
lines.push(header, "", `${plural({
|
|
443
|
+
value: stats.searches,
|
|
444
|
+
singular: "search",
|
|
445
|
+
pluralForm: "searches"
|
|
446
|
+
})} · ${plural({
|
|
447
|
+
value: stats.searchCalls,
|
|
448
|
+
singular: "search call"
|
|
449
|
+
})} · ${plural({
|
|
450
|
+
value: stats.tokens.total,
|
|
451
|
+
singular: "token"
|
|
452
|
+
})}`, `~${formatUsd(stats.cost.totalUsd)} estimated · ${plural({
|
|
453
|
+
value: stats.activeDays,
|
|
454
|
+
singular: "active day"
|
|
455
|
+
})}`);
|
|
456
|
+
if (stats.unpricedSearches > 0) lines.push(`${plural({
|
|
457
|
+
value: stats.unpricedSearches,
|
|
458
|
+
singular: "search",
|
|
459
|
+
pluralForm: "searches"
|
|
460
|
+
})} without a price estimate`);
|
|
461
|
+
}
|
|
462
|
+
if (params.cost) lines.push("", ...renderCost(stats));
|
|
463
|
+
if (params.models) lines.push("", ...renderModels({
|
|
464
|
+
models: stats.models,
|
|
465
|
+
limit: params.limit
|
|
466
|
+
}));
|
|
467
|
+
return lines.join("\n");
|
|
468
|
+
}
|
|
469
|
+
function renderCost(stats) {
|
|
470
|
+
const rows = [
|
|
471
|
+
["cost", `~${formatUsd(stats.cost.totalUsd)}`],
|
|
472
|
+
[" tokens", formatUsd(stats.cost.tokensUsd)],
|
|
473
|
+
[" search fees", formatUsd(stats.cost.searchUsd)],
|
|
474
|
+
["input", compact(stats.tokens.input)],
|
|
475
|
+
["cached input", compact(stats.tokens.cachedInput)],
|
|
476
|
+
["output", compact(stats.tokens.output)],
|
|
477
|
+
["search calls", compact(stats.searchCalls)]
|
|
478
|
+
];
|
|
479
|
+
if (stats.unpricedSearches > 0) rows.push(["unpriced searches", compact(stats.unpricedSearches)]);
|
|
480
|
+
return ["COST & TOKENS", ...rows.map(([label, value]) => ` ${label.padEnd(20)}${value}`)];
|
|
481
|
+
}
|
|
482
|
+
function renderModels(params) {
|
|
483
|
+
const { models, limit } = params;
|
|
484
|
+
if (models.length === 0) return ["MODELS", " no searches"];
|
|
485
|
+
const shown = limit === void 0 ? models : models.slice(0, limit);
|
|
486
|
+
const rest = models.length - shown.length;
|
|
487
|
+
return [
|
|
488
|
+
"MODELS",
|
|
489
|
+
tableRow({
|
|
490
|
+
name: "model",
|
|
491
|
+
searches: "searches",
|
|
492
|
+
tokens: "tokens",
|
|
493
|
+
cost: "cost"
|
|
494
|
+
}),
|
|
495
|
+
...shown.map((model) => tableRow({
|
|
496
|
+
name: modelName(model),
|
|
497
|
+
searches: compact(model.searches),
|
|
498
|
+
tokens: compact(model.tokens),
|
|
499
|
+
cost: model.unpricedSearches === model.searches ? "-" : formatUsd(model.costUsd)
|
|
500
|
+
})),
|
|
501
|
+
...rest > 0 ? ["", `+${rest.toLocaleString("en-US")} more model${rest === 1 ? "" : "s"}`] : []
|
|
502
|
+
];
|
|
503
|
+
}
|
|
504
|
+
function modelName(model) {
|
|
505
|
+
const name = model.model?.replace(/\p{Cc}/gu, "");
|
|
506
|
+
return name ? `${model.provider}/${name}` : model.provider;
|
|
507
|
+
}
|
|
508
|
+
function tableRow(cells) {
|
|
509
|
+
return `${(cells.name.length <= 34 ? cells.name : `${cells.name.slice(0, 33)}…`).padEnd(34)}${cells.searches.padStart(10)}${cells.tokens.padStart(10)}${cells.cost.padStart(12)}`;
|
|
510
|
+
}
|
|
511
|
+
/** 1234 → "1.2k", 2_000_000 → "2m". */
|
|
512
|
+
function compact(value) {
|
|
513
|
+
if (value >= 1e9) return `${oneDecimal(value / 1e9)}b`;
|
|
514
|
+
if (value >= 1e6) return `${oneDecimal(value / 1e6)}m`;
|
|
515
|
+
if (value >= 1e3) return `${oneDecimal(value / 1e3)}k`;
|
|
516
|
+
return Math.round(value).toLocaleString("en-US");
|
|
517
|
+
}
|
|
518
|
+
function oneDecimal(value) {
|
|
519
|
+
return value.toFixed(1).replace(/\.0$/, "");
|
|
520
|
+
}
|
|
521
|
+
function plural(params) {
|
|
522
|
+
const noun = params.value === 1 ? params.singular : params.pluralForm ?? `${params.singular}s`;
|
|
523
|
+
return `${compact(params.value)} ${noun}`;
|
|
524
|
+
}
|
|
525
|
+
//#endregion
|
|
526
|
+
//#region src/cli/commands/stats.ts
|
|
527
|
+
/** Validate CLI options into a stats request. Pure apart from reading `now`. */
|
|
528
|
+
function toStatsRequest(params) {
|
|
529
|
+
const { options } = params;
|
|
530
|
+
if ([
|
|
531
|
+
options.days !== void 0,
|
|
532
|
+
options.year !== void 0,
|
|
533
|
+
options.all
|
|
534
|
+
].filter(Boolean).length > 1) throw new require_tools.WebseekError({
|
|
535
|
+
code: "invalid_usage",
|
|
536
|
+
message: "--days, --year, and --all cannot be combined."
|
|
537
|
+
});
|
|
538
|
+
return {
|
|
539
|
+
range: resolveStatsRange({
|
|
540
|
+
days: options.days === void 0 ? void 0 : parseInteger({
|
|
541
|
+
name: "days",
|
|
542
|
+
value: options.days,
|
|
543
|
+
min: 0,
|
|
544
|
+
max: 36500
|
|
545
|
+
}),
|
|
546
|
+
year: options.year === void 0 ? void 0 : parseInteger({
|
|
547
|
+
name: "year",
|
|
548
|
+
value: options.year,
|
|
549
|
+
min: 1970,
|
|
550
|
+
max: 9999
|
|
551
|
+
}),
|
|
552
|
+
all: options.all,
|
|
553
|
+
now: params.now
|
|
554
|
+
}),
|
|
555
|
+
provider: options.provider === void 0 ? void 0 : require_tools.coerceProvider(options.provider),
|
|
556
|
+
cost: Boolean(options.cost || options.full),
|
|
557
|
+
models: Boolean(options.models || options.full),
|
|
558
|
+
limit: options.limit === void 0 ? void 0 : parseInteger({
|
|
559
|
+
name: "limit",
|
|
560
|
+
value: options.limit,
|
|
561
|
+
min: 1,
|
|
562
|
+
max: Number.MAX_SAFE_INTEGER
|
|
563
|
+
}),
|
|
564
|
+
json: Boolean(options.json)
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
function parseInteger(params) {
|
|
568
|
+
const { name, value, min, max } = params;
|
|
569
|
+
const parsed = Number(value);
|
|
570
|
+
if (!/^\d+$/.test(value.trim()) || !Number.isSafeInteger(parsed) || parsed < min || parsed > max) throw new require_tools.WebseekError({
|
|
571
|
+
code: "invalid_usage",
|
|
572
|
+
message: `--${name} must be an integer between ${min} and ${max}.`
|
|
573
|
+
});
|
|
574
|
+
return parsed;
|
|
575
|
+
}
|
|
576
|
+
async function runStatsCommand(params) {
|
|
577
|
+
const request = toStatsRequest({ options: params.options });
|
|
578
|
+
const stats = aggregateUsage({
|
|
579
|
+
records: await readUsageRecords({ env: params.env }),
|
|
580
|
+
range: request.range,
|
|
581
|
+
provider: request.provider
|
|
582
|
+
});
|
|
583
|
+
if (request.json) {
|
|
584
|
+
const models = request.limit === void 0 ? stats.models : stats.models.slice(0, request.limit);
|
|
585
|
+
params.logger.result(JSON.stringify({
|
|
586
|
+
...stats,
|
|
587
|
+
models
|
|
588
|
+
}, null, 2));
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
params.logger.result(renderStats({
|
|
592
|
+
stats,
|
|
593
|
+
label: request.range.label,
|
|
594
|
+
scope: request.provider ?? "all providers",
|
|
595
|
+
cost: request.cost,
|
|
596
|
+
models: request.models,
|
|
597
|
+
limit: request.limit
|
|
598
|
+
}));
|
|
599
|
+
}
|
|
600
|
+
function registerStatsCommand(program) {
|
|
601
|
+
program.command("stats").description("Show usage and estimated cost of the searches recorded locally").option("--days <n>", "show the last N days; 0 means today").option("--year <year>", "show a calendar year").option("--all", "show lifetime statistics").option("-p, --provider <name>", "only count one provider: openai | google | gemini").option("--cost", "show cost and token details").option("--models", "show usage per model").option("--full", "show every detailed section").option("--limit <n>", "number of rows in the models section").option("--json", "output statistics as JSON").action(wrapCommand(async ({ logger }, options) => {
|
|
602
|
+
await runStatsCommand({
|
|
603
|
+
logger,
|
|
604
|
+
options
|
|
605
|
+
});
|
|
606
|
+
}));
|
|
607
|
+
}
|
|
608
|
+
//#endregion
|
|
178
609
|
//#region src/cli/index.ts
|
|
179
610
|
/**
|
|
180
611
|
* webseek CLI entry point.
|
|
@@ -182,6 +613,7 @@ function registerSearchCommand(program) {
|
|
|
182
613
|
* Two modes:
|
|
183
614
|
* webseek <query> --provider <name> run a one-off web search (root command)
|
|
184
615
|
* webseek mcp start the MCP server (stdio)
|
|
616
|
+
* webseek stats summarize the locally recorded usage
|
|
185
617
|
*/
|
|
186
618
|
function readPackageVersion(dir) {
|
|
187
619
|
try {
|
|
@@ -203,12 +635,13 @@ function getVersion() {
|
|
|
203
635
|
function buildProgram() {
|
|
204
636
|
const program = new commander.Command();
|
|
205
637
|
const version = getVersion();
|
|
206
|
-
program.name("webseek").description("Unified multi-provider web search (CLI + MCP server)").version(version, "-v, --version", "Show version");
|
|
638
|
+
program.name("webseek").description("Unified multi-provider web search (CLI + MCP server)").version(version, "-v, --version", "Show version").enablePositionalOptions();
|
|
207
639
|
registerSearchCommand(program);
|
|
208
640
|
registerMcpCommand({
|
|
209
641
|
program,
|
|
210
642
|
version
|
|
211
643
|
});
|
|
644
|
+
registerStatsCommand(program);
|
|
212
645
|
return program;
|
|
213
646
|
}
|
|
214
647
|
async function main() {
|