onebrowser 0.6.0 → 0.8.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 +24 -18
- package/package.json +5 -2
- package/src/account.js +8 -6
- package/src/cli.js +6 -7
- package/src/credentials.js +7 -7
- package/src/index.js +29 -11
- package/src/ledger-path.js +27 -0
- package/src/ledger.js +45 -25
- package/src/providers/anchor.js +2 -1
- package/src/providers/firecrawl.js +5 -2
- package/src/providers/hyperbrowser.js +2 -1
- package/src/providers/index.js +5 -3
- package/src/providers/kernel.js +2 -2
- package/src/providers/lightpanda.js +1 -1
- package/src/providers/steel.js +2 -1
- package/src/providers/tinyfish.js +2 -1
- package/src/variables.js +19 -0
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ const page = await session.newPage(); // Playwright; or any CDP client on sessio
|
|
|
16
16
|
await page.goto('https://example.com');
|
|
17
17
|
await session.close();
|
|
18
18
|
|
|
19
|
-
await steel.readQuota(); // { remaining, unbilled, unit: 'USD', renewsAt: null }
|
|
19
|
+
await steel.readQuota(); // { remaining, unbilled, unit: 'USD', renewsAt: null, total: 30 }
|
|
20
20
|
steel.quota(); // the same now, from that reading: sessions opened since count at once
|
|
21
21
|
steel.maxSessionCost(); // what a session with the default options costs at most, in USD
|
|
22
22
|
await browsers.syncUsage(); // stores the providers' records of ended sessions in the ledger
|
|
@@ -30,13 +30,15 @@ Built in: [Steel](https://steel.dev), [TinyFish](https://tinyfish.ai), [Hyperbro
|
|
|
30
30
|
|
|
31
31
|
## Interface
|
|
32
32
|
|
|
33
|
-
`new OneBrowser({ client, ledgerPath, providers, adapters })`
|
|
33
|
+
`new OneBrowser({ client, env, envFile, ledgerPath, providers, adapters })`
|
|
34
34
|
|
|
35
35
|
| Option | |
|
|
36
36
|
|---|---|
|
|
37
37
|
| `client` | The program using it, recorded with each session; required |
|
|
38
|
+
| `env` | The variables it reads the API keys (see [Accounts](#accounts)) and the ledger's place (see [The ledger](#the-ledger)) from; default: `process.env` |
|
|
39
|
+
| `envFile` | An env file whose variables it reads under those set in `env`, parsed as Node parses env files; a variable set but empty is unset. They stay out of the process's environment, so a program's child processes do not see the keys |
|
|
38
40
|
| `ledgerPath` | SQLite file of the ledger (default below; `':memory:'` for tests). Opened when first needed |
|
|
39
|
-
| `providers` | Settings per provider name, on top of its adapter's `defaults`: `apiKeys` (see [Accounts](#accounts)), `price`, `billingDelayMs`, `minIdleTimeoutSec` (see [Quota](#quota)) and the adapter's own (API base URLs, plan facts such as `monthlyHours`) |
|
|
41
|
+
| `providers` | Settings per provider name, on top of its adapter's `defaults`: `apiKeys` (see [Accounts](#accounts)), `price`, `billingDelayMs`, `minIdleTimeoutSec` (see [Quota](#quota)) and the adapter's own (API base URLs, plan facts such as `monthlyHours` and `includedQuota`) |
|
|
40
42
|
| `adapters` | Further adapters by provider name, or replacements of built-in ones |
|
|
41
43
|
|
|
42
44
|
| Method | |
|
|
@@ -45,6 +47,8 @@ Built in: [Steel](https://steel.dev), [TinyFish](https://tinyfish.ai), [Hyperbro
|
|
|
45
47
|
| `accounts(provider?)` | The accounts with an API key, in the providers' order and then the keys': all of them, or the provider's (throws for an unknown provider) |
|
|
46
48
|
| `syncUsage()` | Stores the providers' records (duration, proxy traffic) of ended sessions: `{ reported, failed: [{ account, error }] }` |
|
|
47
49
|
| `usage({ since })` | Sessions opened since `since` (ms) per UTC day, account and client: `sessions`, `minutes` (the provider's record, else the open-to-close timing), `untimed`, `unsynced`, `proxyBytes` |
|
|
50
|
+
| `sessions({ since })` | The ledger's sessions opened since `since` (ms), oldest first, each as recorded: `provider`, `account`, `id`, `client`, `openedAt`, `endsBy`, `closedAt`, `durationMs`, `proxyBytes`, `syncedAt` (times in ms; `closedAt` and `syncedAt` null until then, `durationMs` and `proxyBytes` null without the provider's record) |
|
|
51
|
+
| `ledgerId` | The ledger's own id, kept for its life: a program that copies the ledger's record elsewhere tells it from another ledger's by it |
|
|
48
52
|
| `close()` | Closes the sessions still open and the ledger |
|
|
49
53
|
|
|
50
54
|
An account has `id` (`steel#3f9a1c`: the provider's name and the key's fingerprint), `provider`, `fingerprint`, and:
|
|
@@ -53,7 +57,7 @@ An account has `id` (`steel#3f9a1c`: the provider's name and the key's fingerpri
|
|
|
53
57
|
|---|---|
|
|
54
58
|
| `openSession({ proxyCountry, idleTimeoutSec, maxDurationMs })` | A new session, in the ledger from when the provider has created it. Throws `QuotaError` when the provider refuses it for the account's quota |
|
|
55
59
|
| `readQuota({ maxAgeMs })` | Reads the account's quota from the provider, unless a reading in progress or started at most `maxAgeMs` ago (default 0) can be used, a failed one too; then answers as `quota()` |
|
|
56
|
-
| `quota()` | What is left now, from the last reading: `{ remaining, unbilled, unit, renewsAt }` (see [Quota](#quota)). Throws until the account has been read, and while its last reading failed |
|
|
60
|
+
| `quota()` | What is left now, from the last reading: `{ remaining, unbilled, unit, renewsAt, total }` (see [Quota](#quota)). Throws until the account has been read, and while its last reading failed |
|
|
57
61
|
| `maxSessionCost(options)` | What a session with these options costs at most, in the unit of the quota |
|
|
58
62
|
|
|
59
63
|
A session has `id`, `account`, `egress`, `cdpUrl` (it may carry the API key: never log it), `openedAt` (when it was asked for), `closedAt` (when its provider released it), and:
|
|
@@ -90,29 +94,31 @@ How each provider meets them:
|
|
|
90
94
|
|
|
91
95
|
`quota()` is the last reading less `unbilled`: what the ledger's sessions of the account that the reading does not show cost at the provider's `price`. A reading shows the sessions that ended `billingDelayMs` before it; the others are open ones, counted as lasting as long as they can (`maxDurationMs`, then `idleTimeoutSec`, at least the provider's `minIdleTimeoutSec`), and those ended since, counted as long as they lasted. So each session counts once from when its provider has created it, whichever program on the machine opened it, and a program can check before each session that the account pays for it (`maxSessionCost`). A provider that bills a session while it runs has part of it counted twice, on the safe side. Not counted: sessions opened through another machine's ledger, until the provider bills them, and proxy traffic billed by the GB.
|
|
92
96
|
|
|
93
|
-
`price` is in the quota's unit: `perHour`, `perSession` (default 0), the session time billed in whole `stepSec`, rounded up (default 1), and at least `minSec` (default 0). Where a provider does not document its rounding, the adapter takes the minute. `renewsAt` (ms) is when the quota renews by itself; `null` when only a top-up renews it. `
|
|
97
|
+
`price` is in the quota's unit: `perHour`, `perSession` (default 0), the session time billed in whole `stepSec`, rounded up (default 1), and at least `minSec` (default 0). Where a provider does not document its rounding, the adapter takes the minute. `renewsAt` (ms) is when the quota renews by itself; `null` when only a top-up renews it. `total` is what the quota holds when whole, for showing how much of it is used: the plan's as the provider reports it, else the adapter's plan facts (`monthlyHours`, or `includedQuota`: the free plan's, or the grant a new account starts with); `null` when neither says. A top-up can leave `remaining` above it. `price`, `billingDelayMs` and the plan facts are settings: a program on another plan sets its own.
|
|
94
98
|
|
|
95
|
-
| Provider | Quota, unit | Read from | `price` | `billingDelayMs` |
|
|
96
|
-
|
|
97
|
-
| `steel` | balance, USD | `GET /v1/credits/balance`, the endpoint of Steel's dashboard (not in its API docs) | $0.10 an hour (Launch plan), by the minute | 5 min |
|
|
98
|
-
| `tinyfish` | wallet, USD | [`GET /v1/wallet`](https://docs.tinyfish.ai/wallet); a session refused with 402 throws `QuotaError` | $0.002 a minute, by the minute | 5 min |
|
|
99
|
-
| `hyperbrowser` | credits | `GET /api/team/credit-info`, the endpoint of Hyperbrowser's SDK (not in its API docs) | 100 an hour, by the minute | 5 min |
|
|
100
|
-
| `lightpanda` | browser hours of the calendar month (`monthlyHours`, default: the free plan's) | the ledger's record of its sessions that have ended: its console shows the month's usage, but its token cannot read it. So the reading is whole while the account is used only through ledgers of this machine | 1 per browser hour, by the second | 0 |
|
|
101
|
-
| `kernel` | browser hours of the calendar month (`monthlyHours`, default: what the free plan's credits pay for), counted from the first of the month, 00:00 UTC | the uptime of the account's sessions this month, from `GET /browsers` (Kernel has no API for what its credits leave); a session refused with 403 (spending cap) throws `QuotaError` | 1 per browser hour, by the second | 5 min |
|
|
102
|
-
| `anchor` | credits | `GET /v1/billing`; a session refused with 402 throws `QuotaError` | 0.01 a session and 0.15 an hour, by the minute: above what its sessions' records bill (its docs: 0.05 an hour) | 5 min |
|
|
103
|
-
| `firecrawl` | credits; the plan's credits renew at the end of each billing period | [`GET /v2/team/credit-usage`](https://docs.firecrawl.dev/api-reference/endpoint/credit-usage); a session refused with 402 throws `QuotaError` | 2 a minute, in 30 s steps, at least a minute | 5 min |
|
|
99
|
+
| Provider | Quota, unit | `total` | Read from | `price` | `billingDelayMs` |
|
|
100
|
+
|---|---|---|---|---|---|
|
|
101
|
+
| `steel` | balance, USD | `includedQuota`: 30, the Launch plan's one-time credits (valid 90 days) | `GET /v1/credits/balance`, the endpoint of Steel's dashboard (not in its API docs) | $0.10 an hour (Launch plan), by the minute | 5 min |
|
|
102
|
+
| `tinyfish` | wallet, USD | `includedQuota`: 8, what a new wallet starts with | [`GET /v1/wallet`](https://docs.tinyfish.ai/wallet); a session refused with 402 throws `QuotaError` | $0.002 a minute, by the minute | 5 min |
|
|
103
|
+
| `hyperbrowser` | credits | `includedQuota`: 5,000, a free account's ($5) | `GET /api/team/credit-info`, the endpoint of Hyperbrowser's SDK (not in its API docs) | 100 an hour, by the minute | 5 min |
|
|
104
|
+
| `lightpanda` | browser hours of the calendar month (`monthlyHours`, default: the free plan's) | `monthlyHours`: 10 | the ledger's record of its sessions that have ended: its console shows the month's usage, but its token cannot read it. So the reading is whole while the account is used only through ledgers of this machine | 1 per browser hour, by the second | 0 |
|
|
105
|
+
| `kernel` | browser hours of the calendar month (`monthlyHours`, default: what the free plan's credits pay for), counted from the first of the month, 00:00 UTC | `monthlyHours`: 80, what the free plan's $5 a month pays for | the uptime of the account's sessions this month, from `GET /browsers` (Kernel has no API for what its credits leave); a session refused with 403 (spending cap) throws `QuotaError` | 1 per browser hour, by the second | 5 min |
|
|
106
|
+
| `anchor` | credits | `includedQuota`: 5, the free plan's each month | `GET /v1/billing`; a session refused with 402 throws `QuotaError` | 0.01 a session and 0.15 an hour, by the minute: above what its sessions' records bill (its docs: 0.05 an hour) | 5 min |
|
|
107
|
+
| `firecrawl` | credits; the plan's credits renew at the end of each billing period | the plan's credits (`planCredits`), as read | [`GET /v2/team/credit-usage`](https://docs.firecrawl.dev/api-reference/endpoint/credit-usage); a session refused with 402 throws `QuotaError` | 2 a minute, in 30 s steps, at least a minute | 5 min |
|
|
104
108
|
|
|
105
109
|
Every provider read from its API showed a session's cost within about a minute of its end (2026-11); `billingDelayMs` leaves room for more.
|
|
106
110
|
|
|
107
111
|
### Accounts
|
|
108
112
|
|
|
109
|
-
An account is one API key at a provider: the quota, the plan's limits and the bill are the key's. The keys of a provider are `apiKeys` in its settings, else those in
|
|
113
|
+
An account is one API key at a provider: the quota, the plan's limits and the bill are the key's. The keys of a provider are `apiKeys` in its settings, else those in its variable `<PROVIDER>_API_KEY` (`STEEL_API_KEY`, …; `apiKeyEnv(provider)` names it), else those in the file named by `<PROVIDER>_API_KEY_FILE`, separated by commas, spaces or line breaks; each key makes one account, in that order, and a key given twice makes one. A provider without a key has no account.
|
|
110
114
|
|
|
111
115
|
An account is known by its `id`, the provider's name and the first 6 hex digits of the key's SHA-256 (`steel#3f9a1c`): the same for the same key in every program and run, without telling the key. The ledger records sessions under it, so an account's usage is whole however many programs open sessions with its key.
|
|
112
116
|
|
|
113
117
|
### The ledger
|
|
114
118
|
|
|
115
|
-
One SQLite file per machine by default:
|
|
119
|
+
One SQLite file per machine by default: the variable `ONEBROWSER_DB`, else `onebrowser/sessions.db` in `XDG_DATA_HOME` (ignored when relative, as the XDG spec has it; default `~/.local/share`), `~` standing for the home directory in either, shared by every program on the machine under its own `client`. A session is recorded when its provider has created it (it is billed from then on), with its account and when it ends at the latest; closed when its provider has released it; and completed with the provider's own record by `syncUsage()`. Nothing removes a session from the ledger, so its record is whole from the ledger's first session on. Each ledger has an id of its own (`ledgerId`), given when it is made (or first opened by onebrowser 0.7 or later) and kept for its life. A ledger made by an earlier onebrowser is brought up to date when opened; one made by a later onebrowser is refused. Sessions recorded by onebrowser 0.5 or earlier have no account: they become the provider's only account's when it has one here, and stay without one while it has several.
|
|
120
|
+
|
|
121
|
+
`defaultLedgerPath({ env, envFile })` says where a `OneBrowser` with these options keeps its ledger unless given a path, and `LEDGER_PATH_VARIABLES` which variables place it. A program that only looks for the ledger imports them from `onebrowser/ledger-path`, which loads no SQLite.
|
|
116
122
|
|
|
117
123
|
## Command line
|
|
118
124
|
|
|
@@ -125,7 +131,7 @@ npx onebrowser usage --days 7 # the ledger's sessions per UTC day, accoun
|
|
|
125
131
|
npx onebrowser usage --json --env-file .env
|
|
126
132
|
```
|
|
127
133
|
|
|
128
|
-
`usage` first fetches the providers' records of the sessions that have ended, as `syncUsage()` does. With `--json`, `quota` prints an array of what `readQuota()` returns, with the account: `{ account, provider, remaining, unbilled, unit, renewsAt }`, or `{ account, provider, error }` for a failed reading; `usage` prints `{ ledger, since, sessions, failed }`, where `sessions` is what `usage()` returns and `failed` what `syncUsage()` could not fetch. API keys are read as by the library, from the environment
|
|
134
|
+
`usage` first fetches the providers' records of the sessions that have ended, as `syncUsage()` does. With `--json`, `quota` prints an array of what `readQuota()` returns, with the account: `{ account, provider, remaining, unbilled, unit, renewsAt, total }`, or `{ account, provider, error }` for a failed reading; `usage` prints `{ ledger, ledgerId, since, sessions, failed }`, where `sessions` is what `usage()` returns and `failed` what `syncUsage()` could not fetch. API keys and the ledger are read as by the library, from the environment and, with `--env-file PATH`, from that file (its `envFile`). Exit status: 0 done, 1 a reading failed, 2 the command line is wrong.
|
|
129
135
|
|
|
130
136
|
## Adding a provider
|
|
131
137
|
|
|
@@ -135,7 +141,7 @@ Write `src/providers/<name>.js` and register it in `ADAPTERS` (`src/providers/in
|
|
|
135
141
|
- `createSession(settings, apiKey, { proxyCountry, idleTimeoutSec, maxDurationMs }) → { id, cdpUrl, egress }`: maps the session options onto the provider's parameters; throws `QuotaError` when the provider refuses the session for the account's quota.
|
|
136
142
|
- `releaseSession(settings, apiKey, session)`.
|
|
137
143
|
- `sessionUsage(settings, apiKey, [{ id, openedAt, closedAt }]) → [{ id, durationMs, proxyBytes }]`: the record of those sessions that have ended: the provider's own, or the ledger's timing for a provider whose records its API key cannot read.
|
|
138
|
-
- `quota(settings, apiKey, history) → { remaining, unit, renewsAt? }`: for a provider that cannot report its usage, `history.endedSessionMsSince(ms)` is the session time of the ledger's sessions that the reading shows.
|
|
144
|
+
- `quota(settings, apiKey, history) → { remaining, unit, renewsAt?, total? }`: for a provider that cannot report its usage, `history.endedSessionMsSince(ms)` is the session time of the ledger's sessions that the reading shows.
|
|
139
145
|
|
|
140
146
|
Requests go through `src/providers/http.js`: every status other than 2xx is an `HttpError` (with `status`), so an adapter handles only the statuses that mean something of their own, such as a quota refusal.
|
|
141
147
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "onebrowser",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "One interface to remote browser providers: sessions by account (an API key at a provider), a usage ledger, account quotas",
|
|
5
5
|
"keywords": ["browser", "remote-browser", "cdp", "playwright", "steel", "hyperbrowser", "lightpanda", "kernel", "tinyfish", "anchorbrowser", "firecrawl"],
|
|
6
6
|
"license": "MIT",
|
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
"homepage": "https://github.com/i-richardwang/onebrowser#readme",
|
|
10
10
|
"bugs": "https://github.com/i-richardwang/onebrowser/issues",
|
|
11
11
|
"type": "module",
|
|
12
|
-
"exports":
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./src/index.js",
|
|
14
|
+
"./ledger-path": "./src/ledger-path.js"
|
|
15
|
+
},
|
|
13
16
|
"bin": { "onebrowser": "bin/onebrowser.js" },
|
|
14
17
|
"files": ["src", "bin"],
|
|
15
18
|
"scripts": {
|
package/src/account.js
CHANGED
|
@@ -82,19 +82,21 @@ export class Account {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
|
-
* What is left of the quota now, in the provider's unit: { remaining, unbilled, unit, renewsAt }. The last
|
|
85
|
+
* What is left of the quota now, in the provider's unit: { remaining, unbilled, unit, renewsAt, total }. The last
|
|
86
86
|
* reading shows the sessions that ended billingDelayMs before it; `unbilled` is what the ledger's others cost
|
|
87
87
|
* at the provider's price, open ones as if they lasted as long as they can, and remaining the reading less
|
|
88
|
-
* that. renewsAt (ms): when the quota renews by itself; null: only a top-up renews it.
|
|
89
|
-
*
|
|
88
|
+
* that. renewsAt (ms): when the quota renews by itself; null: only a top-up renews it. total: what the quota
|
|
89
|
+
* holds when whole, the plan's or a free grant's, as the provider reports it or the adapter's plan facts give
|
|
90
|
+
* it, for showing how much of it is used; null when neither says. Throws until the provider has been read, and
|
|
91
|
+
* while the last reading failed.
|
|
90
92
|
*/
|
|
91
93
|
quota() {
|
|
92
94
|
const reading = this.#done;
|
|
93
95
|
if (!reading) throw new Error(`the quota of ${this.id} has not been read yet (readQuota)`);
|
|
94
96
|
if (reading.error) throw reading.error;
|
|
95
97
|
const unbilled = this.#ledger().openSince(this.id, reading.billedBefore).reduce((cost, s) => cost + sessionCost(this.#settings.price, s.durationMs), 0);
|
|
96
|
-
const { remaining, unit, renewsAt
|
|
97
|
-
return { remaining: remaining - unbilled, unbilled, unit, renewsAt };
|
|
98
|
+
const { remaining, unit, renewsAt, total } = reading.value;
|
|
99
|
+
return { remaining: remaining - unbilled, unbilled, unit, renewsAt, total };
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
/** The latest reading if it is in progress or at most maxAgeMs old, else a new one */
|
|
@@ -108,7 +110,7 @@ export class Account {
|
|
|
108
110
|
try {
|
|
109
111
|
const q = await this.#adapter.quota(this.#settings, this.#apiKey, this.#ledger().history(this.id, reading.billedBefore));
|
|
110
112
|
if (!(Number.isFinite(q?.remaining) && typeof q.unit === 'string')) throw new Error(`${this.provider} answered no quota: ${JSON.stringify(q)}`);
|
|
111
|
-
reading.value = q;
|
|
113
|
+
reading.value = { remaining: q.remaining, unit: q.unit, renewsAt: q.renewsAt ?? null, total: Number.isFinite(q.total) && q.total > 0 ? q.total : null };
|
|
112
114
|
} catch (e) {
|
|
113
115
|
reading.error = e;
|
|
114
116
|
}
|
package/src/cli.js
CHANGED
|
@@ -21,8 +21,8 @@ const GLOBAL_OPTIONS = {
|
|
|
21
21
|
/** The accounts `names` ask for: each name a provider's (it must have an API key) or one account's id */
|
|
22
22
|
function askedAccounts(browsers, names) {
|
|
23
23
|
return names.flatMap((name) => {
|
|
24
|
-
const own = browsers.accounts().
|
|
25
|
-
if (own
|
|
24
|
+
const own = browsers.accounts().find((a) => a.id === name);
|
|
25
|
+
if (own) return [own];
|
|
26
26
|
if (!browsers.providers.includes(name)) throw new CommandLineError(`unknown provider or account ${name} (expected ${browsers.providers.join(', ')}, or an account id)`);
|
|
27
27
|
const accounts = browsers.accounts(name);
|
|
28
28
|
if (!accounts.length) throw new Error(`${name} has no API key: set ${apiKeyEnv(name)} or ${apiKeyEnv(name)}_FILE`);
|
|
@@ -46,7 +46,7 @@ const COMMANDS = {
|
|
|
46
46
|
if (json) out.write(`${JSON.stringify(quotas, null, 2)}\n`);
|
|
47
47
|
else {
|
|
48
48
|
for (const q of quotas) {
|
|
49
|
-
const text = q.error ? `unknown (${q.error})` : `${round(q.remaining)} ${q.unit} left${q.unbilled ? ` (after ${round(q.unbilled)} not billed yet)` : ''}${q.renewsAt ? `, renews ${day(q.renewsAt)}` : ''}`;
|
|
49
|
+
const text = q.error ? `unknown (${q.error})` : `${round(q.remaining)}${q.total ? ` of ${round(q.total)}` : ''} ${q.unit} left${q.unbilled ? ` (after ${round(q.unbilled)} not billed yet)` : ''}${q.renewsAt ? `, renews ${day(q.renewsAt)}` : ''}`;
|
|
50
50
|
out.write(`${q.account.padEnd(21)} ${text}\n`);
|
|
51
51
|
}
|
|
52
52
|
}
|
|
@@ -63,7 +63,7 @@ const COMMANDS = {
|
|
|
63
63
|
const { failed } = await browsers.syncUsage();
|
|
64
64
|
for (const f of failed) err.write(`${f.account}: the provider's records were not fetched (${f.error})\n`);
|
|
65
65
|
const sessions = browsers.usage({ since });
|
|
66
|
-
if (json) out.write(`${JSON.stringify({ ledger: browsers.ledgerPath, since: day(since), sessions, failed }, null, 2)}\n`);
|
|
66
|
+
if (json) out.write(`${JSON.stringify({ ledger: browsers.ledgerPath, ledgerId: browsers.ledgerId, since: day(since), sessions, failed }, null, 2)}\n`);
|
|
67
67
|
else {
|
|
68
68
|
out.write(`sessions since ${day(since)} in ${browsers.ledgerPath}:\n`);
|
|
69
69
|
if (!sessions.length) out.write(' none\n');
|
|
@@ -88,7 +88,7 @@ Usage:
|
|
|
88
88
|
|
|
89
89
|
Options:
|
|
90
90
|
--json JSON on stdout instead of text
|
|
91
|
-
--env-file PATH read
|
|
91
|
+
--env-file PATH read the variables below from PATH too; those set in the environment win
|
|
92
92
|
-h, --help
|
|
93
93
|
|
|
94
94
|
Environment:
|
|
@@ -129,8 +129,7 @@ function parseCommandLine(argv) {
|
|
|
129
129
|
export async function run(argv, { out = process.stdout, err = process.stderr, browsers } = {}) {
|
|
130
130
|
try {
|
|
131
131
|
const { command, values, args } = parseCommandLine(argv);
|
|
132
|
-
|
|
133
|
-
browsers ??= new OneBrowser({ client: 'onebrowser' });
|
|
132
|
+
browsers ??= new OneBrowser({ client: 'onebrowser', envFile: values['env-file'] });
|
|
134
133
|
if (values.help || !command) {
|
|
135
134
|
(values.help ? out : err).write(help(browsers.providers));
|
|
136
135
|
return values.help ? 0 : 2;
|
package/src/credentials.js
CHANGED
|
@@ -11,16 +11,16 @@ export const fingerprint = (apiKey) => crypto.createHash('sha256').update(apiKey
|
|
|
11
11
|
const split = (text) => text.split(/[\s,]+/).filter(Boolean);
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* A provider's API keys, one per account: `apiKeys` as given in its settings, else those in the
|
|
15
|
-
*
|
|
14
|
+
* A provider's API keys, one per account: `apiKeys` as given in its settings, else those in the variable
|
|
15
|
+
* apiKeyEnv(provider) of `variables`, else those in the file named by that variable with _FILE appended, in their
|
|
16
16
|
* order and each once. Separators: commas, spaces or line breaks. None is fine; the file must exist.
|
|
17
17
|
*/
|
|
18
|
-
export function readApiKeys(provider, apiKeys) {
|
|
19
|
-
const
|
|
20
|
-
const file =
|
|
21
|
-
let keys = apiKeys ?? split(
|
|
18
|
+
export function readApiKeys(provider, apiKeys, variables) {
|
|
19
|
+
const name = apiKeyEnv(provider);
|
|
20
|
+
const file = variables[`${name}_FILE`];
|
|
21
|
+
let keys = apiKeys ?? split(variables[name] ?? '');
|
|
22
22
|
if (!keys.length && file) {
|
|
23
|
-
if (!fs.existsSync(file)) throw new Error(`${provider} keys: ${
|
|
23
|
+
if (!fs.existsSync(file)) throw new Error(`${provider} keys: ${name}_FILE names no file (${file})`);
|
|
24
24
|
keys = split(fs.readFileSync(file, 'utf8'));
|
|
25
25
|
}
|
|
26
26
|
return [...new Set(keys.map((k) => k.trim()).filter(Boolean))];
|
package/src/index.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { Account } from './account.js';
|
|
2
2
|
import { readApiKeys } from './credentials.js';
|
|
3
3
|
import { QuotaError } from './errors.js';
|
|
4
|
-
import {
|
|
4
|
+
import { Ledger } from './ledger.js';
|
|
5
|
+
import { ledgerPathIn } from './ledger-path.js';
|
|
5
6
|
import { ADAPTERS, checkAdapter, fullSettings } from './providers/index.js';
|
|
7
|
+
import { readVariables } from './variables.js';
|
|
6
8
|
|
|
7
9
|
export { apiKeyEnv } from './credentials.js';
|
|
10
|
+
export { defaultLedgerPath, LEDGER_PATH_VARIABLES } from './ledger-path.js';
|
|
8
11
|
export { sessionOptions } from './options.js';
|
|
9
12
|
export { QuotaError };
|
|
10
13
|
|
|
@@ -15,19 +18,22 @@ export { QuotaError };
|
|
|
15
18
|
* what its quota has left at any moment, are known. Which account to use, and when to stop using one, are the
|
|
16
19
|
* caller's decisions.
|
|
17
20
|
*
|
|
18
|
-
* client: the program using it, recorded with each session;
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
+
* client: the program using it, recorded with each session; env, envFile: the variables it reads the API keys
|
|
22
|
+
* and the ledger's place from (variables.js), the process's environment by default, over those of an env file
|
|
23
|
+
* if one is named; ledgerPath: the ledger's SQLite file, opened when first needed; providers: settings per
|
|
24
|
+
* provider name on top of its adapter's defaults, apiKeys among them; adapters: further or replacement adapters
|
|
25
|
+
* by provider name.
|
|
21
26
|
*/
|
|
22
27
|
export class OneBrowser {
|
|
23
28
|
#providers = []; // names, in the adapters' order
|
|
24
29
|
#accounts = [];
|
|
25
30
|
#ledger = null;
|
|
26
31
|
|
|
27
|
-
constructor({ client,
|
|
32
|
+
constructor({ client, env = process.env, envFile, ledgerPath, providers = {}, adapters = {} }) {
|
|
28
33
|
if (!client?.trim()) throw new Error('onebrowser: client must name the program using it');
|
|
34
|
+
const variables = readVariables(env, envFile);
|
|
29
35
|
this.client = client;
|
|
30
|
-
this.ledgerPath = ledgerPath;
|
|
36
|
+
this.ledgerPath = ledgerPath ?? ledgerPathIn(variables);
|
|
31
37
|
const all = { ...ADAPTERS, ...adapters };
|
|
32
38
|
const unknown = Object.keys(providers).filter((name) => !all[name]);
|
|
33
39
|
if (unknown.length) throw new Error(`onebrowser: no adapter for provider ${unknown.join(', ')}`);
|
|
@@ -36,16 +42,13 @@ export class OneBrowser {
|
|
|
36
42
|
const { apiKeys, ...given } = providers[provider] ?? {};
|
|
37
43
|
const settings = fullSettings(provider, { ...adapter.defaults, ...given });
|
|
38
44
|
this.#providers.push(provider);
|
|
39
|
-
for (const apiKey of readApiKeys(provider, apiKeys)) {
|
|
45
|
+
for (const apiKey of readApiKeys(provider, apiKeys, variables)) {
|
|
40
46
|
this.#accounts.push(new Account({ provider, adapter, settings, apiKey, client, ledger: () => this.#store }));
|
|
41
47
|
}
|
|
42
48
|
}
|
|
43
49
|
}
|
|
44
50
|
|
|
45
|
-
/**
|
|
46
|
-
* The ledger, opened on first use. Sessions recorded before accounts were (onebrowser 0.5 and earlier) are
|
|
47
|
-
* the provider's only account's, when it has one here; with several, no one knows which they belong to.
|
|
48
|
-
*/
|
|
51
|
+
/** The ledger, opened on first use; a provider with one account here claims its sessions recorded without one */
|
|
49
52
|
get #store() {
|
|
50
53
|
if (!this.#ledger) {
|
|
51
54
|
this.#ledger = new Ledger(this.ledgerPath);
|
|
@@ -56,6 +59,11 @@ export class OneBrowser {
|
|
|
56
59
|
return this.#ledger;
|
|
57
60
|
}
|
|
58
61
|
|
|
62
|
+
/** The ledger's own id, kept for the ledger's life: it tells its record from another ledger's */
|
|
63
|
+
get ledgerId() {
|
|
64
|
+
return this.#store.id;
|
|
65
|
+
}
|
|
66
|
+
|
|
59
67
|
/** Names of the providers it has adapters for */
|
|
60
68
|
get providers() {
|
|
61
69
|
return [...this.#providers];
|
|
@@ -96,6 +104,16 @@ export class OneBrowser {
|
|
|
96
104
|
return this.#store.usage(since);
|
|
97
105
|
}
|
|
98
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The ledger's sessions opened since `since` (ms), oldest first, each as recorded:
|
|
109
|
+
* [{ provider, account, id, client, openedAt, endsBy, closedAt, durationMs, proxyBytes, syncedAt }], times in
|
|
110
|
+
* ms (closedAt, syncedAt null until then; durationMs and proxyBytes null without the provider's record). account:
|
|
111
|
+
* the provider's name alone for a session whose account is not known.
|
|
112
|
+
*/
|
|
113
|
+
sessions({ since }) {
|
|
114
|
+
return this.#store.sessions(since);
|
|
115
|
+
}
|
|
116
|
+
|
|
99
117
|
/** Closes every session still open and the ledger */
|
|
100
118
|
async close() {
|
|
101
119
|
await Promise.all(this.#accounts.map((a) => a.close()));
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readVariables } from './variables.js';
|
|
4
|
+
|
|
5
|
+
// Where the ledger lives: the one part of onebrowser a program needs to find the ledger without opening it. The
|
|
6
|
+
// package exports it on its own (onebrowser/ledger-path), which loads no SQLite.
|
|
7
|
+
|
|
8
|
+
/** The variables that place the ledger, in the order they are read */
|
|
9
|
+
export const LEDGER_PATH_VARIABLES = ['ONEBROWSER_DB', 'XDG_DATA_HOME'];
|
|
10
|
+
|
|
11
|
+
/** A path as a variable holds it, `~` standing for the home directory: an env file's are not expanded by a shell */
|
|
12
|
+
const expandHome = (p) => (p === '~' || p.startsWith('~/') ? path.join(os.homedir(), p.slice(1)) : p);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Where the ledger lives unless a path is given, by onebrowser's variables (variables.js): ONEBROWSER_DB, else
|
|
16
|
+
* onebrowser/sessions.db in the user's data directory: XDG_DATA_HOME, which the XDG spec ignores when relative,
|
|
17
|
+
* else ~/.local/share. One ledger per machine is shared by every project on it, so that the record of an
|
|
18
|
+
* account's sessions is whole, whichever project opened them.
|
|
19
|
+
*/
|
|
20
|
+
export function ledgerPathIn(variables) {
|
|
21
|
+
if (variables.ONEBROWSER_DB) return expandHome(variables.ONEBROWSER_DB);
|
|
22
|
+
const data = variables.XDG_DATA_HOME && expandHome(variables.XDG_DATA_HOME);
|
|
23
|
+
return path.join(data && path.isAbsolute(data) ? data : path.join(os.homedir(), '.local', 'share'), 'onebrowser', 'sessions.db');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Where a OneBrowser given `env` and `envFile` (as its options) keeps its ledger unless it is given a path */
|
|
27
|
+
export const defaultLedgerPath = ({ env = process.env, envFile } = {}) => ledgerPathIn(readVariables(env, envFile));
|
package/src/ledger.js
CHANGED
|
@@ -1,25 +1,14 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import os from 'node:os';
|
|
3
2
|
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { DatabaseSync } from 'node:sqlite';
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* Where the ledger lives unless a path is given: $ONEBROWSER_DB, else onebrowser/sessions.db in the user's data
|
|
8
|
-
* directory ($XDG_DATA_HOME, default ~/.local/share). One ledger per machine is shared by every project on it, so
|
|
9
|
-
* that the record of an account's sessions is whole, whichever project opened them.
|
|
10
|
-
*/
|
|
11
|
-
export function defaultLedgerPath() {
|
|
12
|
-
if (process.env.ONEBROWSER_DB) return process.env.ONEBROWSER_DB;
|
|
13
|
-
const data = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
|
14
|
-
return path.join(data, 'onebrowser', 'sessions.db');
|
|
15
|
-
}
|
|
16
|
-
|
|
17
6
|
/** Every remote browser session, from when its provider has created it: it is billed from then on, even if connecting fails */
|
|
18
7
|
const SCHEMA = `
|
|
19
8
|
CREATE TABLE sessions (
|
|
20
9
|
provider TEXT NOT NULL,
|
|
21
|
-
account TEXT, -- the account it is billed to (Account.id); empty when
|
|
22
|
-
--
|
|
10
|
+
account TEXT, -- the account it is billed to (Account.id); empty when recorded before the
|
|
11
|
+
-- ledger had accounts (schema 2) and not claimed since (claim())
|
|
23
12
|
id TEXT NOT NULL, -- the session id its provider gave it
|
|
24
13
|
client TEXT NOT NULL, -- the program that opened it (OneBrowser's client)
|
|
25
14
|
opened_at TEXT NOT NULL, -- when it was asked for; its provider created it right after
|
|
@@ -65,6 +54,13 @@ const UPGRADES = {
|
|
|
65
54
|
DROP TABLE sessions_2;`,
|
|
66
55
|
};
|
|
67
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Beside the schema, meta holds the ledger's own id (`id`): a program that copies the ledger's record elsewhere
|
|
59
|
+
* tells it from another ledger's by it. It came with onebrowser 0.7 without a schema step, made whenever it is
|
|
60
|
+
* missing, so that a ledger a 0.7 has opened still opens in 0.6, which knows schema 3 too and ignores the table.
|
|
61
|
+
*/
|
|
62
|
+
const META = 'CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) WITHOUT ROWID;';
|
|
63
|
+
|
|
68
64
|
/** ms from one ISO time to another */
|
|
69
65
|
const msBetween = (from, to) => `CAST(ROUND((julianday(${to}) - julianday(${from})) * 86400000) AS INTEGER)`;
|
|
70
66
|
|
|
@@ -96,16 +92,29 @@ export class Ledger {
|
|
|
96
92
|
/** Makes the ledger, or brings an earlier one up to date, in one transaction: another process may be opening it too */
|
|
97
93
|
#migrate() {
|
|
98
94
|
const version = () => this.#db.prepare('PRAGMA user_version').get().user_version;
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
95
|
+
const has = (table) => this.#db.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?").get(table);
|
|
96
|
+
if (version() !== VERSION) {
|
|
97
|
+
this.tx(() => {
|
|
98
|
+
if (!has('sessions')) this.#db.exec(SCHEMA);
|
|
99
|
+
else {
|
|
100
|
+
const from = version() || 1;
|
|
101
|
+
if (from > VERSION) throw new Error(`onebrowser: the ledger ${this.#file} was made by a later onebrowser (schema ${from}; this one knows ${VERSION})`);
|
|
102
|
+
for (let v = from; v < VERSION; v++) this.#db.exec(UPGRADES[v]);
|
|
103
|
+
}
|
|
104
|
+
this.#db.exec(`PRAGMA user_version = ${VERSION}`);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
if (!has('meta')) {
|
|
108
|
+
this.tx(() => {
|
|
109
|
+
this.#db.exec(META);
|
|
110
|
+
this.#db.prepare("INSERT OR IGNORE INTO meta (key, value) VALUES ('id', ?)").run(randomUUID());
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** This ledger's id, given when it was made or first opened by a onebrowser that has ids, and kept for life */
|
|
116
|
+
get id() {
|
|
117
|
+
return this.#q("SELECT value FROM meta WHERE key = 'id'").get().value;
|
|
109
118
|
}
|
|
110
119
|
|
|
111
120
|
#q(sql) {
|
|
@@ -122,7 +131,7 @@ export class Ledger {
|
|
|
122
131
|
this.#q('UPDATE sessions SET closed_at=? WHERE account=? AND id=? AND closed_at IS NULL').run(iso(closedAt), account, id);
|
|
123
132
|
}
|
|
124
133
|
|
|
125
|
-
/** The provider's sessions
|
|
134
|
+
/** The provider's sessions without an account (recorded before schema 3) are `account`'s: the provider's only one */
|
|
126
135
|
claim(provider, account) {
|
|
127
136
|
this.#q('UPDATE sessions SET account=? WHERE provider=? AND account IS NULL').run(account, provider);
|
|
128
137
|
}
|
|
@@ -173,6 +182,17 @@ export class Ledger {
|
|
|
173
182
|
).all(iso(since));
|
|
174
183
|
}
|
|
175
184
|
|
|
185
|
+
/** See OneBrowser.sessions() */
|
|
186
|
+
sessions(since) {
|
|
187
|
+
return this.#q(
|
|
188
|
+
`SELECT provider, COALESCE(account, provider) account, id, client, opened_at openedAt, ends_by endsBy, closed_at closedAt,
|
|
189
|
+
duration_ms durationMs, proxy_bytes proxyBytes, synced_at syncedAt
|
|
190
|
+
FROM sessions WHERE opened_at >= ? ORDER BY opened_at, provider, id`,
|
|
191
|
+
)
|
|
192
|
+
.all(iso(since))
|
|
193
|
+
.map((r) => ({ ...r, openedAt: Date.parse(r.openedAt), endsBy: Date.parse(r.endsBy), closedAt: r.closedAt && Date.parse(r.closedAt), syncedAt: r.syncedAt && Date.parse(r.syncedAt) }));
|
|
194
|
+
}
|
|
195
|
+
|
|
176
196
|
close() {
|
|
177
197
|
this.#db.close();
|
|
178
198
|
}
|
package/src/providers/anchor.js
CHANGED
|
@@ -12,6 +12,7 @@ import { json } from './http.js';
|
|
|
12
12
|
*/
|
|
13
13
|
export const defaults = {
|
|
14
14
|
apiBase: 'https://api.anchorbrowser.io/v1',
|
|
15
|
+
includedQuota: 5, // credits the free plan includes each month (docs.anchorbrowser.io/pricing, 2026-11)
|
|
15
16
|
// credits ($1 each): $0.01 a browser and, by its docs, $0.05 an hour by the minute; the sessions' own records
|
|
16
17
|
// (credits_used) bill more, about $0.06 an hour and a step of $0.003 in the second minute, which this covers
|
|
17
18
|
price: { perSession: 0.01, perHour: 0.15, stepSec: 60 },
|
|
@@ -45,7 +46,7 @@ export async function releaseSession(settings, apiKey, session) {
|
|
|
45
46
|
/** The account's credits left, gifted ones included. Unlike the other endpoints, /billing answers without a `data` wrapper */
|
|
46
47
|
export async function quota(settings, apiKey) {
|
|
47
48
|
const { credits } = await json(`${settings.apiBase}/billing`, { headers: headers(apiKey) });
|
|
48
|
-
return { remaining: credits, unit: 'credits' };
|
|
49
|
+
return { remaining: credits, unit: 'credits', total: settings.includedQuota };
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
/** Usage of the sessions among `sessions` that have ended, fetched by id: duration in whole seconds */
|
|
@@ -44,10 +44,13 @@ export async function releaseSession(settings, apiKey, session) {
|
|
|
44
44
|
await json(`${settings.apiBase}/interact/${session.id}`, { method: 'DELETE', headers: headers(apiKey) });
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* The team's credits left, coupon credits included; the plan's credits (planCredits, without coupon or
|
|
49
|
+
* pay-as-you-go ones) renew at the end of its billing period
|
|
50
|
+
*/
|
|
48
51
|
export async function quota(settings, apiKey) {
|
|
49
52
|
const { data } = await json(`${settings.apiBase}/team/credit-usage`, { headers: headers(apiKey) });
|
|
50
|
-
return { remaining: data.remainingCredits, unit: 'credits', renewsAt: data.billingPeriodEnd ? Date.parse(data.billingPeriodEnd) : null };
|
|
53
|
+
return { remaining: data.remainingCredits, unit: 'credits', renewsAt: data.billingPeriodEnd ? Date.parse(data.billingPeriodEnd) : null, total: data.planCredits };
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
/** Usage of the sessions among `sessions` that have ended, fetched by id: the session list has no durations (GET /interact/{id} is not in Firecrawl's API docs) */
|
|
@@ -9,6 +9,7 @@ import { json } from './http.js';
|
|
|
9
9
|
*/
|
|
10
10
|
export const defaults = {
|
|
11
11
|
apiBase: 'https://api.hyperbrowser.ai/api',
|
|
12
|
+
includedQuota: 5000, // credits a free account comes with, worth $5 (hyperbrowser.ai/pricing, 2026-11)
|
|
12
13
|
price: { perHour: 100, stepSec: 60 }, // credits; its rounding is not documented
|
|
13
14
|
billingDelayMs: 5 * 60_000, // billed while the session runs, seen within 10 s of its end
|
|
14
15
|
};
|
|
@@ -30,7 +31,7 @@ export async function releaseSession(settings, apiKey, session) {
|
|
|
30
31
|
/** The team's credits left (the endpoint of Hyperbrowser's SDK; it is not in Hyperbrowser's API docs) */
|
|
31
32
|
export async function quota(settings, apiKey) {
|
|
32
33
|
const { remaining } = await json(`${settings.apiBase}/team/credit-info`, { headers: headers(apiKey) });
|
|
33
|
-
return { remaining, unit: 'credits' };
|
|
34
|
+
return { remaining, unit: 'credits', total: settings.includedQuota };
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
/** Usage of the sessions among `sessions` that have ended (have an endTime), fetched by id. Times are epoch ms, as numbers or strings */
|
package/src/providers/index.js
CHANGED
|
@@ -13,14 +13,16 @@ import * as tinyfish from './tinyfish.js';
|
|
|
13
13
|
* price what its sessions cost, in the unit of its quota (cost.js)
|
|
14
14
|
* billingDelayMs how long after a session has ended its quota reading shows it, at the latest
|
|
15
15
|
* minIdleTimeoutSec the shortest idle timeout it takes (default 0)
|
|
16
|
-
* and its own API base URLs, plan facts such as monthlyHours
|
|
16
|
+
* and its own API base URLs, plan facts such as monthlyHours or includedQuota (what the plan, or
|
|
17
|
+
* the free grant, puts in the quota when the provider does not say)
|
|
17
18
|
* createSession(settings, apiKey, { proxyCountry, idleTimeoutSec, maxDurationMs }) → { id, cdpUrl, egress }
|
|
18
19
|
* throws QuotaError (errors.js) when the provider refuses the session for the account's quota
|
|
19
20
|
* releaseSession(settings, apiKey, session)
|
|
20
21
|
* sessionUsage(settings, apiKey, [{ id, openedAt, closedAt }]) → [{ id, durationMs, proxyBytes }]
|
|
21
22
|
* the provider's record of those that have ended, or the ledger's timing where its API key cannot read one
|
|
22
|
-
* quota(settings, apiKey, history) → { remaining, unit, renewsAt? }
|
|
23
|
-
* what the account has left in the provider's unit,
|
|
23
|
+
* quota(settings, apiKey, history) → { remaining, unit, renewsAt?, total? }
|
|
24
|
+
* what the account has left in the provider's unit, when it renews by itself (ms), and what it holds when
|
|
25
|
+
* whole, as the provider reports it or the plan facts give it; history: the
|
|
24
26
|
* ledger's sessions that the reading shows (Ledger.history), for a provider that cannot count its usage
|
|
25
27
|
* Every function throws when it fails. Requests go through http.js, which throws HttpError for a status other
|
|
26
28
|
* than 2xx.
|
package/src/providers/kernel.js
CHANGED
|
@@ -13,7 +13,7 @@ import { utcMonth } from './month.js';
|
|
|
13
13
|
*/
|
|
14
14
|
export const defaults = {
|
|
15
15
|
apiBase: 'https://api.onkernel.com',
|
|
16
|
-
monthlyHours: 80, // headless browser hours a month that the free plan's credits pay for, rounded down
|
|
16
|
+
monthlyHours: 80, // headless browser hours a month that the free plan's $5 of credits pay for, rounded down
|
|
17
17
|
price: { perHour: 1 }, // the quota is in headless browser hours, billed by the second
|
|
18
18
|
billingDelayMs: 5 * 60_000, // seen within 10 s
|
|
19
19
|
};
|
|
@@ -56,7 +56,7 @@ export async function quota(settings, apiKey) {
|
|
|
56
56
|
usedMs += ofMonth.reduce((ms, s) => ms + (s.usage?.uptime_ms ?? 0), 0);
|
|
57
57
|
if (!ofMonth.length || r.headers.get('x-has-more') !== 'true') break;
|
|
58
58
|
}
|
|
59
|
-
return { remaining: settings.monthlyHours - usedMs / 3_600_000, unit: 'hours', renewsAt: month.next };
|
|
59
|
+
return { remaining: settings.monthlyHours - usedMs / 3_600_000, unit: 'hours', renewsAt: month.next, total: settings.monthlyHours };
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
/** Usage of the sessions among `sessions` that Kernel has deleted and finished billing, fetched by id */
|
|
@@ -39,7 +39,7 @@ export async function releaseSession() {}
|
|
|
39
39
|
export async function quota(settings, apiKey, history) {
|
|
40
40
|
const month = utcMonth();
|
|
41
41
|
const usedHours = history.endedSessionMsSince(month.start) / 3_600_000;
|
|
42
|
-
return { remaining: settings.monthlyHours - usedHours, unit: 'hours', renewsAt: month.next };
|
|
42
|
+
return { remaining: settings.monthlyHours - usedHours, unit: 'hours', renewsAt: month.next, total: settings.monthlyHours };
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/**
|
package/src/providers/steel.js
CHANGED
|
@@ -8,6 +8,7 @@ import { json } from './http.js';
|
|
|
8
8
|
export const defaults = {
|
|
9
9
|
apiBase: 'https://api.steel.dev/v1',
|
|
10
10
|
connectBase: 'wss://connect.steel.dev',
|
|
11
|
+
includedQuota: 30, // USD: the Launch plan's one-time credits, valid for 90 days (steel.dev/pricing, 2026-11)
|
|
11
12
|
price: { perHour: 0.1, stepSec: 60 }, // USD on the Launch plan, by the minute rounded up
|
|
12
13
|
billingDelayMs: 5 * 60_000, // seen within 11 s
|
|
13
14
|
};
|
|
@@ -32,7 +33,7 @@ export async function releaseSession(settings, apiKey, session) {
|
|
|
32
33
|
/** The account's balance in USD, as Steel's dashboard reads it (the endpoint is not in Steel's API docs) */
|
|
33
34
|
export async function quota(settings, apiKey) {
|
|
34
35
|
const { balanceCents } = await json(`${settings.apiBase}/credits/balance`, { headers: headers(apiKey) });
|
|
35
|
-
return { remaining: balanceCents / 100, unit: 'USD' };
|
|
36
|
+
return { remaining: balanceCents / 100, unit: 'USD', total: settings.includedQuota };
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
/** Usage of the sessions among `sessions` that have ended, fetched by id: the session list cannot be filtered by time */
|
|
@@ -10,6 +10,7 @@ import { json } from './http.js';
|
|
|
10
10
|
export const defaults = {
|
|
11
11
|
apiBase: 'https://api.browser.tinyfish.ai',
|
|
12
12
|
walletUrl: 'https://agent.tinyfish.ai/v1/wallet',
|
|
13
|
+
includedQuota: 8, // USD: what a new account's wallet starts with (tinyfish.ai/pricing, 2026-11)
|
|
13
14
|
price: { perHour: 0.12, stepSec: 60 }, // USD: $0.002 a minute, proxy included; its rounding is not documented
|
|
14
15
|
billingDelayMs: 5 * 60_000, // seen within 64 s
|
|
15
16
|
};
|
|
@@ -36,7 +37,7 @@ export async function releaseSession(settings, apiKey, session) {
|
|
|
36
37
|
/** The wallet's balance (https://docs.tinyfish.ai/wallet) */
|
|
37
38
|
export async function quota(settings, apiKey) {
|
|
38
39
|
const w = await json(settings.walletUrl, { headers: headers(apiKey) });
|
|
39
|
-
return { remaining: Number(w.available_balance), unit: w.currency };
|
|
40
|
+
return { remaining: Number(w.available_balance), unit: w.currency, total: settings.includedQuota };
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
/**
|
package/src/variables.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { parseEnv } from 'node:util';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The variables onebrowser reads its configuration from, the API keys (credentials.js) and the ledger's place
|
|
6
|
+
* (ledger-path.js): those set in `env`, over those of the env file at `envFile` if one is named, parsed as Node
|
|
7
|
+
* parses env files. A variable set but empty is unset, as onebrowser reads every variable, so it hides none of the
|
|
8
|
+
* file's.
|
|
9
|
+
*/
|
|
10
|
+
export function readVariables(env, envFile) {
|
|
11
|
+
if (envFile === undefined) return env;
|
|
12
|
+
let text;
|
|
13
|
+
try {
|
|
14
|
+
text = fs.readFileSync(envFile, 'utf8');
|
|
15
|
+
} catch (e) {
|
|
16
|
+
throw new Error(`cannot read the env file ${envFile}: ${e.message}`);
|
|
17
|
+
}
|
|
18
|
+
return { ...parseEnv(text), ...Object.fromEntries(Object.entries(env).filter(([, value]) => value)) };
|
|
19
|
+
}
|