@oxygen-agent/cli 1.1010.650 → 1.1010.721
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 +1 -1
- package/dist/command-manifest.js +1 -1
- package/dist/inbox-needs-reply-notice.d.ts +12 -0
- package/dist/inbox-needs-reply-notice.js +51 -0
- package/dist/index.js +186 -45
- package/dist/skills.js +48 -22
- package/node_modules/@oxygen/shared/dist/billing-anchors.d.ts +33 -2
- package/node_modules/@oxygen/shared/dist/billing-anchors.js +67 -2
- package/node_modules/@oxygen/shared/dist/billing.d.ts +63 -9
- package/node_modules/@oxygen/shared/dist/billing.js +96 -14
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +11 -1
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +4 -4
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +4 -4
- package/node_modules/@oxygen/shared/dist/email-hard-bounce.d.ts +25 -0
- package/node_modules/@oxygen/shared/dist/email-hard-bounce.js +27 -0
- package/node_modules/@oxygen/shared/dist/feature-gates.d.ts +6 -1
- package/node_modules/@oxygen/shared/dist/feature-gates.js +7 -1
- package/node_modules/@oxygen/shared/dist/index.d.ts +2 -1
- package/node_modules/@oxygen/shared/dist/index.js +2 -1
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +114 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +150 -0
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +19 -2
- package/node_modules/@oxygen/shared/dist/plan-band.d.ts +118 -0
- package/node_modules/@oxygen/shared/dist/plan-band.js +147 -0
- package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +131 -120
- package/node_modules/@oxygen/shared/dist/plan-limits.js +80 -71
- package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +79 -14
- package/node_modules/@oxygen/shared/dist/pricing-sheet.js +61 -12
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +4 -3
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +9 -3
- package/node_modules/@oxygen/shared/dist/process-resource.d.ts +4 -0
- package/node_modules/@oxygen/shared/dist/process-resource.js +25 -0
- package/node_modules/@oxygen/shared/dist/repricing.d.ts +130 -0
- package/node_modules/@oxygen/shared/dist/repricing.js +320 -0
- package/node_modules/@oxygen/shared/dist/sending-limits.d.ts +32 -0
- package/node_modules/@oxygen/shared/dist/sending-limits.js +49 -0
- package/node_modules/@oxygen/shared/dist/sequence-failures.js +4 -1
- package/node_modules/@oxygen/shared/dist/stripe-price-catalog.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/stripe-price-catalog.js +58 -1
- package/node_modules/@oxygen/shared/dist/table-capacity.d.ts +39 -10
- package/node_modules/@oxygen/shared/dist/table-capacity.js +68 -4
- package/node_modules/@oxygen/shared/dist/telemetry.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/telemetry.js +36 -2
- package/node_modules/@oxygen/shared/dist/trace-context.d.ts +29 -0
- package/node_modules/@oxygen/shared/dist/trace-context.js +88 -0
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
- package/package.json +1 -1
- package/node_modules/@oxygen/shared/dist/email-warmup-readiness.d.ts +0 -64
- package/node_modules/@oxygen/shared/dist/email-warmup-readiness.js +0 -90
|
@@ -247,4 +247,118 @@ export declare function truncateLinkedInInviteNote(note: string, limit: number):
|
|
|
247
247
|
truncated: boolean;
|
|
248
248
|
originalLength: number;
|
|
249
249
|
};
|
|
250
|
+
export type LinkedInSenderLimits = {
|
|
251
|
+
invites_per_day: number;
|
|
252
|
+
invites_per_week: number;
|
|
253
|
+
messages_per_day: number;
|
|
254
|
+
inmails_per_day: number;
|
|
255
|
+
/**
|
|
256
|
+
* InMails in a rolling 30 days (today and the 29 before it, in the account's
|
|
257
|
+
* day). Checked under the same sender row lock as the weekly invite cap, so
|
|
258
|
+
* concurrent claims cannot both pass a stale sum.
|
|
259
|
+
*/
|
|
260
|
+
inmails_per_month: number;
|
|
261
|
+
profile_views_per_day: number;
|
|
262
|
+
/**
|
|
263
|
+
* Invisible profile and company lookups (`users_get` with notify:false,
|
|
264
|
+
* `company_get`). The member is never notified, so these are reads, but
|
|
265
|
+
* Unipile relays LinkedIn's ~100 profile retrievals a day for them.
|
|
266
|
+
*/
|
|
267
|
+
profile_lookups_per_day: number;
|
|
268
|
+
/** Skill endorsements. Used to share the follow cap. */
|
|
269
|
+
endorsements_per_day: number;
|
|
270
|
+
/**
|
|
271
|
+
* Sent-invitation withdrawals (the withdraw action itself, not the reads of
|
|
272
|
+
* the sent-invite list). LinkedIn blocks re-inviting a withdrawn member for
|
|
273
|
+
* three weeks; see the re-invite guard.
|
|
274
|
+
*/
|
|
275
|
+
withdrawals_per_day: number;
|
|
276
|
+
/** Posts to the member's own feed, from every caller. Timing-exempt, never uncapped. */
|
|
277
|
+
posts_per_day: number;
|
|
278
|
+
follows_per_day: number;
|
|
279
|
+
likes_per_day: number;
|
|
280
|
+
comments_per_day: number;
|
|
281
|
+
total_actions_per_day: number;
|
|
282
|
+
min_action_spacing_seconds: number;
|
|
283
|
+
action_spacing_jitter_seconds: number;
|
|
284
|
+
interactive_min_spacing_seconds: number;
|
|
285
|
+
interactive_spacing_jitter_seconds: number;
|
|
286
|
+
/**
|
|
287
|
+
* Backstop on the interactive lane: how many human-paced sends an account may
|
|
288
|
+
* make in a rolling hour.
|
|
289
|
+
*
|
|
290
|
+
* Mostly redundant with messages_per_day (whose maximum is 40), and that is
|
|
291
|
+
* deliberate — it bounds BURST SHAPE rather than volume. Volume is already
|
|
292
|
+
* capped; this stops the whole daily allowance being spent in four minutes if
|
|
293
|
+
* the interactive classification is ever wrong, which is the one failure mode
|
|
294
|
+
* the daily cap cannot see.
|
|
295
|
+
*/
|
|
296
|
+
interactive_sends_per_hour: number;
|
|
297
|
+
relations_reads_per_day: number;
|
|
298
|
+
messages_reads_per_day: number;
|
|
299
|
+
searches_per_day: number;
|
|
300
|
+
sales_nav_search_results_per_day: number;
|
|
301
|
+
api_reads_per_day: number;
|
|
302
|
+
total_reads_per_day: number;
|
|
303
|
+
relations_ingest_reads_per_day: number;
|
|
304
|
+
api_ingest_reads_per_day: number;
|
|
305
|
+
messages_ingest_reads_per_day: number;
|
|
306
|
+
/**
|
|
307
|
+
* Network-capture read budgets: the two-phase allowance for enumerating an
|
|
308
|
+
* account's OWN 1st-degree connections and followers into a workspace table.
|
|
309
|
+
*
|
|
310
|
+
* Two keys, not one, because Unipile's own guidance for the relations list is
|
|
311
|
+
* explicitly two-phase: a heavier initial sync, then "retrieving the first page
|
|
312
|
+
* only a few times a day with randomly spaced intervals". One flat number would
|
|
313
|
+
* either make the first walk take weeks or leave a steady-state drip reading far
|
|
314
|
+
* more than it needs forever. Both still meter as `relations_ingest_read` against
|
|
315
|
+
* one shared daily counter, so an account cannot spend both budgets at once.
|
|
316
|
+
*/
|
|
317
|
+
network_backfill_reads_per_day: number;
|
|
318
|
+
network_delta_reads_per_day: number;
|
|
319
|
+
};
|
|
320
|
+
/**
|
|
321
|
+
* Safe defaults for LinkedIn action limits.
|
|
322
|
+
*
|
|
323
|
+
* PROVENANCE, because these numbers get copied and should not be laundered into
|
|
324
|
+
* authority they do not have:
|
|
325
|
+
*
|
|
326
|
+
* - The 20-25/day connection-request range and the 40/day message posture come
|
|
327
|
+
* from third-party operator playbooks describing HeyReach-style outreach, NOT
|
|
328
|
+
* from HeyReach. HeyReach's own sending-limits article publishes no numbers at
|
|
329
|
+
* all — it only says you configure per-action daily limits and that it "will
|
|
330
|
+
* always try to reach those limits". An earlier version of this comment cited
|
|
331
|
+
* HeyReach directly; that attribution was wrong.
|
|
332
|
+
* - The READ budgets (relations/api/messages, ingest and network capture) are
|
|
333
|
+
* anchored on Unipile's documented per-route recommendations, which relay
|
|
334
|
+
* LinkedIn's own limits: ~80-100 invitations/day, ~100 profile retrievals/day,
|
|
335
|
+
* and 100/day per account for "all other routes" — the bucket users_relations
|
|
336
|
+
* and users_followers fall into. Unipile enforces none of it; cadence is ours
|
|
337
|
+
* to choose, and exceeding returns 429/500 and can disconnect the account.
|
|
338
|
+
* See developer.unipile.com/docs/provider-limits-and-restrictions.
|
|
339
|
+
*
|
|
340
|
+
* The weekly invite and 200-total-action guards stay enforced independently.
|
|
341
|
+
* Users can lower limits per account, but never raise them above
|
|
342
|
+
* LINKEDIN_SENDER_LIMIT_MAXIMUMS.
|
|
343
|
+
*/
|
|
344
|
+
export declare const LINKEDIN_SENDER_LIMIT_DEFAULTS: LinkedInSenderLimits;
|
|
345
|
+
export declare const LINKEDIN_SENDER_LIMIT_MAXIMUMS: LinkedInSenderLimits;
|
|
346
|
+
/**
|
|
347
|
+
* LinkedIn refuses a new invitation to a member whose earlier invitation from
|
|
348
|
+
* the same account was withdrawn, for about three weeks. OXYGEN records every
|
|
349
|
+
* withdrawal it makes and refuses the re-invite itself for this long, so the
|
|
350
|
+
* refusal is ours (with a reset time) instead of a provider 422.
|
|
351
|
+
*/
|
|
352
|
+
export declare const LINKEDIN_REINVITE_COOLDOWN_DAYS = 21;
|
|
353
|
+
/**
|
|
354
|
+
* Whether a refused invitation is LinkedIn's re-invite cooldown: Unipile answers
|
|
355
|
+
* HTTP 422 with the `cannot_resend_yet` error type. Anchored on the type, with
|
|
356
|
+
* the message as a fallback for the provider shapes that carry the type only in
|
|
357
|
+
* the sentence. Pure and total.
|
|
358
|
+
*/
|
|
359
|
+
export declare function isLinkedInReinviteCooldownRefusal(input: {
|
|
360
|
+
status: number | null;
|
|
361
|
+
providerType: string | null;
|
|
362
|
+
providerMessage: string | null;
|
|
363
|
+
}): boolean;
|
|
250
364
|
export {};
|
|
@@ -196,3 +196,153 @@ export function truncateLinkedInInviteNote(note, limit) {
|
|
|
196
196
|
const kept = cut === 0 ? head : head.slice(0, cut);
|
|
197
197
|
return { note: kept.join("").trimEnd(), truncated: true, originalLength };
|
|
198
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* Safe defaults for LinkedIn action limits.
|
|
201
|
+
*
|
|
202
|
+
* PROVENANCE, because these numbers get copied and should not be laundered into
|
|
203
|
+
* authority they do not have:
|
|
204
|
+
*
|
|
205
|
+
* - The 20-25/day connection-request range and the 40/day message posture come
|
|
206
|
+
* from third-party operator playbooks describing HeyReach-style outreach, NOT
|
|
207
|
+
* from HeyReach. HeyReach's own sending-limits article publishes no numbers at
|
|
208
|
+
* all — it only says you configure per-action daily limits and that it "will
|
|
209
|
+
* always try to reach those limits". An earlier version of this comment cited
|
|
210
|
+
* HeyReach directly; that attribution was wrong.
|
|
211
|
+
* - The READ budgets (relations/api/messages, ingest and network capture) are
|
|
212
|
+
* anchored on Unipile's documented per-route recommendations, which relay
|
|
213
|
+
* LinkedIn's own limits: ~80-100 invitations/day, ~100 profile retrievals/day,
|
|
214
|
+
* and 100/day per account for "all other routes" — the bucket users_relations
|
|
215
|
+
* and users_followers fall into. Unipile enforces none of it; cadence is ours
|
|
216
|
+
* to choose, and exceeding returns 429/500 and can disconnect the account.
|
|
217
|
+
* See developer.unipile.com/docs/provider-limits-and-restrictions.
|
|
218
|
+
*
|
|
219
|
+
* The weekly invite and 200-total-action guards stay enforced independently.
|
|
220
|
+
* Users can lower limits per account, but never raise them above
|
|
221
|
+
* LINKEDIN_SENDER_LIMIT_MAXIMUMS.
|
|
222
|
+
*/
|
|
223
|
+
export const LINKEDIN_SENDER_LIMIT_DEFAULTS = {
|
|
224
|
+
invites_per_day: 20,
|
|
225
|
+
invites_per_week: 100,
|
|
226
|
+
messages_per_day: 40,
|
|
227
|
+
inmails_per_day: 10,
|
|
228
|
+
// Competitors cap open-profile InMail near 800/month; 400 leaves headroom.
|
|
229
|
+
inmails_per_month: 400,
|
|
230
|
+
profile_views_per_day: 50,
|
|
231
|
+
profile_lookups_per_day: 80,
|
|
232
|
+
endorsements_per_day: 5,
|
|
233
|
+
withdrawals_per_day: 10,
|
|
234
|
+
// Matches the order of magnitude of a busy human poster; the publishing
|
|
235
|
+
// scheduler's own 25-per-24h ceiling stays a second guard.
|
|
236
|
+
posts_per_day: 10,
|
|
237
|
+
follows_per_day: 20,
|
|
238
|
+
likes_per_day: 20,
|
|
239
|
+
comments_per_day: 5,
|
|
240
|
+
total_actions_per_day: 150,
|
|
241
|
+
min_action_spacing_seconds: 300,
|
|
242
|
+
action_spacing_jitter_seconds: 300,
|
|
243
|
+
// 1s, no jitter: a person working their own inbox should never meet this gate.
|
|
244
|
+
// It exists only to stop a genuine burst — a stuck key, a runaway client — from
|
|
245
|
+
// firing an account's whole hour in a second, and to keep the per-account row
|
|
246
|
+
// lock (and with it the hourly counter) in play. Jitter is 0 deliberately: the
|
|
247
|
+
// variance in human sending is the human, and adding 0-5s on top only makes the
|
|
248
|
+
// product feel slow. Non-zero spacing is still required — see the fast-path note
|
|
249
|
+
// in reserveLinkedInSenderActionSlot.
|
|
250
|
+
interactive_min_spacing_seconds: 1,
|
|
251
|
+
interactive_spacing_jitter_seconds: 0,
|
|
252
|
+
// The primary bound on human sending now that it no longer claims the daily
|
|
253
|
+
// message cap. 200/hour is ~3/minute sustained: never felt by someone answering
|
|
254
|
+
// their inbox, and still a hard ceiling if the interactive classification is
|
|
255
|
+
// ever wrong.
|
|
256
|
+
interactive_sends_per_hour: 200,
|
|
257
|
+
// Conservative read budgets: roughly human-speed usage. Bulk scraping
|
|
258
|
+
// (full contact-list exports, whole-inbox pulls, search harvesting) is
|
|
259
|
+
// impossible by default — the pattern that gets accounts banned.
|
|
260
|
+
relations_reads_per_day: 20,
|
|
261
|
+
messages_reads_per_day: 200,
|
|
262
|
+
searches_per_day: 20,
|
|
263
|
+
sales_nav_search_results_per_day: 1000,
|
|
264
|
+
api_reads_per_day: 200,
|
|
265
|
+
total_reads_per_day: 1000,
|
|
266
|
+
// Conservative ingestion drips: a large network/history import spreads over
|
|
267
|
+
// many days rather than bursting, and stays well within total_reads_per_day.
|
|
268
|
+
// History backfill is the slowest drip (full thread walks are the most
|
|
269
|
+
// ban-sensitive bulk read), so its default is the tightest of the three.
|
|
270
|
+
relations_ingest_reads_per_day: 15,
|
|
271
|
+
api_ingest_reads_per_day: 20,
|
|
272
|
+
messages_ingest_reads_per_day: 10,
|
|
273
|
+
// Network capture, phase 1: 40% of Unipile's documented ~100/day recommendation
|
|
274
|
+
// for these routes. At 50 members a page that is ~2,000 people/day, so a typical
|
|
275
|
+
// network lands in days rather than weeks while leaving most of the account's
|
|
276
|
+
// daily read headroom unused.
|
|
277
|
+
network_backfill_reads_per_day: 40,
|
|
278
|
+
// Phase 2, once the walk is exhausted: Unipile's "first page a few times a day".
|
|
279
|
+
// New connections and followers arrive at the TOP of both listings, so a handful
|
|
280
|
+
// of first-page reads catches them all; the drip re-walks deeper only if it finds
|
|
281
|
+
// a full page of new people.
|
|
282
|
+
network_delta_reads_per_day: 4,
|
|
283
|
+
};
|
|
284
|
+
export const LINKEDIN_SENDER_LIMIT_MAXIMUMS = {
|
|
285
|
+
invites_per_day: 30,
|
|
286
|
+
invites_per_week: 150,
|
|
287
|
+
messages_per_day: 40,
|
|
288
|
+
// Lowered 40 → 25 and 150 → 100 (2026-09-26, founder decision "add missing
|
|
289
|
+
// caps + tighten"): operator playbooks put InMail at ≤25/day and profile views
|
|
290
|
+
// at 50-100. Stored overrides above these clamp at read time.
|
|
291
|
+
inmails_per_day: 25,
|
|
292
|
+
inmails_per_month: 800,
|
|
293
|
+
profile_views_per_day: 100,
|
|
294
|
+
// Unipile relays ~100 profile retrievals a day as LinkedIn's own ceiling.
|
|
295
|
+
profile_lookups_per_day: 100,
|
|
296
|
+
endorsements_per_day: 10,
|
|
297
|
+
withdrawals_per_day: 20,
|
|
298
|
+
posts_per_day: 25,
|
|
299
|
+
follows_per_day: 50,
|
|
300
|
+
likes_per_day: 50,
|
|
301
|
+
// Lowered 50 → 20 (2026-09-25): public comments are the most visible automated
|
|
302
|
+
// signal an account emits, and the one LinkedIn's inauthentic-engagement
|
|
303
|
+
// enforcement keys on. Stored overrides above this clamp at read time.
|
|
304
|
+
comments_per_day: 20,
|
|
305
|
+
total_actions_per_day: 200,
|
|
306
|
+
min_action_spacing_seconds: 3600,
|
|
307
|
+
action_spacing_jitter_seconds: 1800,
|
|
308
|
+
interactive_min_spacing_seconds: 3600,
|
|
309
|
+
interactive_spacing_jitter_seconds: 1800,
|
|
310
|
+
// Lowered 1000 → 300 (2026-09-25): 5/minute sustained is still far above
|
|
311
|
+
// anyone answering their own inbox, and bounds a misclassified bulk send.
|
|
312
|
+
interactive_sends_per_hour: 300,
|
|
313
|
+
relations_reads_per_day: 100,
|
|
314
|
+
messages_reads_per_day: 600,
|
|
315
|
+
searches_per_day: 100,
|
|
316
|
+
sales_nav_search_results_per_day: 1000,
|
|
317
|
+
api_reads_per_day: 1000,
|
|
318
|
+
total_reads_per_day: 2000,
|
|
319
|
+
relations_ingest_reads_per_day: 100,
|
|
320
|
+
api_ingest_reads_per_day: 200,
|
|
321
|
+
messages_ingest_reads_per_day: 200,
|
|
322
|
+
// 100/day is Unipile's own documented recommendation for "all other routes",
|
|
323
|
+
// which is the bucket users_relations and users_followers fall into. Our hard
|
|
324
|
+
// maximum is exactly the vendor's number and must not be raised above it.
|
|
325
|
+
network_backfill_reads_per_day: 100,
|
|
326
|
+
network_delta_reads_per_day: 20,
|
|
327
|
+
};
|
|
328
|
+
// ===== Re-invite cooldown =====
|
|
329
|
+
/**
|
|
330
|
+
* LinkedIn refuses a new invitation to a member whose earlier invitation from
|
|
331
|
+
* the same account was withdrawn, for about three weeks. OXYGEN records every
|
|
332
|
+
* withdrawal it makes and refuses the re-invite itself for this long, so the
|
|
333
|
+
* refusal is ours (with a reset time) instead of a provider 422.
|
|
334
|
+
*/
|
|
335
|
+
export const LINKEDIN_REINVITE_COOLDOWN_DAYS = 21;
|
|
336
|
+
/**
|
|
337
|
+
* Whether a refused invitation is LinkedIn's re-invite cooldown: Unipile answers
|
|
338
|
+
* HTTP 422 with the `cannot_resend_yet` error type. Anchored on the type, with
|
|
339
|
+
* the message as a fallback for the provider shapes that carry the type only in
|
|
340
|
+
* the sentence. Pure and total.
|
|
341
|
+
*/
|
|
342
|
+
export function isLinkedInReinviteCooldownRefusal(input) {
|
|
343
|
+
if (input.status !== 422)
|
|
344
|
+
return false;
|
|
345
|
+
if (typeof input.providerType === "string" && /(?:^|\/)cannot_resend_yet$/u.test(input.providerType.trim()))
|
|
346
|
+
return true;
|
|
347
|
+
return typeof input.providerMessage === "string" && /\bcannot_resend_yet\b/u.test(input.providerMessage);
|
|
348
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { log } from "./log.js";
|
|
2
2
|
import { resolveDeployEnv } from "./deploy-env.js";
|
|
3
|
+
import { processResourceIdentity } from "./process-resource.js";
|
|
4
|
+
import { OXYGEN_VERSION } from "./version.js";
|
|
3
5
|
import { sanitizeLogFields } from "./redaction.js";
|
|
4
6
|
import { trace } from "@opentelemetry/api";
|
|
5
7
|
import { operationalLogSnapshot } from "./operational-telemetry.js";
|
|
@@ -44,6 +46,7 @@ async function rejectedRecords(response, batchLength) {
|
|
|
44
46
|
reader.releaseLock();
|
|
45
47
|
}
|
|
46
48
|
}
|
|
49
|
+
const OTEL_TRACE_ID = /^[a-f\d]{32}$/i;
|
|
47
50
|
// OTLP severity numbers (logs data model): the base of each 4-value band.
|
|
48
51
|
const SEVERITY_NUMBERS = { debug: 5, info: 9, warn: 13, error: 17 };
|
|
49
52
|
// Set by log() on every record; they become the OTLP record's own fields rather
|
|
@@ -197,6 +200,10 @@ export function createOtlpLogSink(options) {
|
|
|
197
200
|
?? process.env.NODE_ENV
|
|
198
201
|
?? null,
|
|
199
202
|
"deployment.sha": process.env.OXYGEN_GIT_SHA ?? process.env.VERCEL_GIT_COMMIT_SHA,
|
|
203
|
+
"service.version": OXYGEN_VERSION,
|
|
204
|
+
// host.name + service.instance.id: which box and which process emitted a
|
|
205
|
+
// line, the same identity the trace/metric resources now carry.
|
|
206
|
+
...processResourceIdentity(),
|
|
200
207
|
...resourceOverrides,
|
|
201
208
|
});
|
|
202
209
|
const queue = [];
|
|
@@ -277,9 +284,19 @@ export function createOtlpLogSink(options) {
|
|
|
277
284
|
droppedOverflow += 1;
|
|
278
285
|
}
|
|
279
286
|
const activeSpan = trace.getActiveSpan()?.spanContext();
|
|
287
|
+
// A request correlation id that is not an OTel trace id (the CLI's UUID
|
|
288
|
+
// `x-oxygen-trace-id`) cannot be the record's traceId, but it is the one key
|
|
289
|
+
// a customer can quote; keep it as an attribute instead of discarding it,
|
|
290
|
+
// and let the active span supply the real trace/span ids.
|
|
291
|
+
const requestTraceId = typeof record.trace_id === "string" && record.trace_id !== ""
|
|
292
|
+
&& !OTEL_TRACE_ID.test(record.trace_id) ? record.trace_id : null;
|
|
293
|
+
const { trace_id: _requestTraceId, ...withoutRequestTraceId } = record;
|
|
294
|
+
const base = requestTraceId
|
|
295
|
+
? { ...withoutRequestTraceId, "oxygen.request_trace_id": requestTraceId }
|
|
296
|
+
: record;
|
|
280
297
|
queue.push(operationalLogSnapshot(sanitizeLogFields({
|
|
281
|
-
...
|
|
282
|
-
...(activeSpan && (!record.trace_id || record.trace_id === activeSpan.traceId)
|
|
298
|
+
...base,
|
|
299
|
+
...(activeSpan && (requestTraceId || !record.trace_id || record.trace_id === activeSpan.traceId)
|
|
283
300
|
? { trace_id: activeSpan.traceId, span_id: activeSpan.spanId }
|
|
284
301
|
: {}),
|
|
285
302
|
})));
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { type LimitsTier } from "./plan-limits.js";
|
|
2
|
+
import type { RepricingOptions } from "./repricing.js";
|
|
3
|
+
/**
|
|
4
|
+
* The seven plan bands of the 2026-09 repricing (spec § 4, slice S06): free
|
|
5
|
+
* plus one band per Oxygen plan size. They sit BESIDE the five-rung
|
|
6
|
+
* `LimitsTier`, which cannot tell $49 from $99 or $199 from $499, so any limit
|
|
7
|
+
* that differs between those sizes (storage, the per-delivery spend default)
|
|
8
|
+
* is resolved from the band instead of the rung.
|
|
9
|
+
*
|
|
10
|
+
* A band never changes a limit by itself. Until a limit is re-keyed onto the
|
|
11
|
+
* band, it keeps reading its rung, and `PLAN_BAND_LIMITS_TIER` says which rung
|
|
12
|
+
* that is.
|
|
13
|
+
*/
|
|
14
|
+
export type PlanBand = "free" | "49" | "99" | "199" | "499" | "999" | "1999";
|
|
15
|
+
export declare const PLAN_BAND_ORDER: readonly PlanBand[];
|
|
16
|
+
/** The limits rung each band enforces at today. */
|
|
17
|
+
export declare const PLAN_BAND_LIMITS_TIER: Readonly<Record<PlanBand, LimitsTier>>;
|
|
18
|
+
/**
|
|
19
|
+
* PROPOSED (P-57, repricing spec § 4): a grandfathered or legacy plan takes the
|
|
20
|
+
* band of the limits rung it enforces at today, so no grandfathered customer
|
|
21
|
+
* loses a storage or rate limit. The pro rung spans $199–$499, whose columns
|
|
22
|
+
* differ only in the per-delivery spend default, which grandfathered plans
|
|
23
|
+
* resolve from their own monthly credits instead (P-62); it maps to the lower
|
|
24
|
+
* band. Enterprise enforces at the scale rung and therefore takes the top band.
|
|
25
|
+
*/
|
|
26
|
+
export declare const LEGACY_PLAN_BAND_BY_LIMITS_TIER: Readonly<Record<LimitsTier, PlanBand>>;
|
|
27
|
+
/**
|
|
28
|
+
* Map a raw plan-tier string (subscription `tier`, metadata tier, legacy plan
|
|
29
|
+
* key, or an Oxygen plan key like `oxygen_499`) onto its band. Unknown or absent
|
|
30
|
+
* tiers land on the unentitled "free" band, the same fail-closed rule as
|
|
31
|
+
* `limitsTierForPlanTier`.
|
|
32
|
+
*/
|
|
33
|
+
export declare function planBandForPlanTier(tier: string | null | undefined): PlanBand;
|
|
34
|
+
/**
|
|
35
|
+
* The band of an org whose limits rung is already resolved (no second lookup).
|
|
36
|
+
* An unentitled org keeps no plan tier, so it takes the free band.
|
|
37
|
+
*/
|
|
38
|
+
export declare function planBandForLimitsResolution(resolution: {
|
|
39
|
+
planTier: string | null;
|
|
40
|
+
entitled: boolean;
|
|
41
|
+
}): PlanBand;
|
|
42
|
+
/** The band's monthly price, read from the Oxygen plan it names; null for free. */
|
|
43
|
+
export declare function planBandMonthlyPriceCents(band: PlanBand): number | null;
|
|
44
|
+
export declare function planBandLabel(band: PlanBand): string;
|
|
45
|
+
/**
|
|
46
|
+
* Request rates, per minute unless named otherwise. The totals bound every
|
|
47
|
+
* `/api/cli/*` call; the operation buckets bind first for the calls a script
|
|
48
|
+
* makes most (row reads and writes, live tool and AI calls, dry runs, bulk
|
|
49
|
+
* imports). A 429 names the bucket it hit in `error.details.bucket`: the
|
|
50
|
+
* totals are `cli.requests`, the others `tenant_read.requests`,
|
|
51
|
+
* `tenant_write.requests`, `tool_live.requests` / `ai_live.requests`,
|
|
52
|
+
* `tool_dry_run.requests` and `bulk_import.requests`.
|
|
53
|
+
*/
|
|
54
|
+
export type ApiRateLimitsReport = {
|
|
55
|
+
org_requests_per_minute: number;
|
|
56
|
+
key_requests_per_minute: number;
|
|
57
|
+
tenant_read_requests_per_key_per_minute: number;
|
|
58
|
+
tenant_write_requests_per_key_per_minute: number;
|
|
59
|
+
live_action_requests_per_key_per_minute: number;
|
|
60
|
+
live_action_requests_per_org_per_hour: number;
|
|
61
|
+
tool_dry_run_requests_per_key_per_minute: number;
|
|
62
|
+
bulk_import_tables_per_org_per_hour: number;
|
|
63
|
+
/**
|
|
64
|
+
* Row budgets shared by every key in the workspace: each row a request reads
|
|
65
|
+
* or writes is one unit (`tenant_read.units`, `tenant_write.units`). The
|
|
66
|
+
* window is an hour on free and a day on paid plans.
|
|
67
|
+
*/
|
|
68
|
+
rows_read_per_org: {
|
|
69
|
+
rows: number;
|
|
70
|
+
window_seconds: number;
|
|
71
|
+
};
|
|
72
|
+
rows_written_per_org: {
|
|
73
|
+
rows: number;
|
|
74
|
+
window_seconds: number;
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
/** The API rates a limits rung enforces, read from `PLAN_LIMITS`. */
|
|
78
|
+
export declare function describeApiRateLimits(tier: LimitsTier): ApiRateLimitsReport;
|
|
79
|
+
/**
|
|
80
|
+
* The limits in force today for one band, as the `/api/cli/limits` contract
|
|
81
|
+
* reports them. Every value is read from the constant that enforces it; nothing
|
|
82
|
+
* here is a second copy. Monthly credits are the Oxygen plan's grant (free has
|
|
83
|
+
* none, so its org-daily guard does not apply).
|
|
84
|
+
*/
|
|
85
|
+
export type PlanBandLimitsReport = {
|
|
86
|
+
band: PlanBand;
|
|
87
|
+
label: string;
|
|
88
|
+
monthly_price_usd: number | null;
|
|
89
|
+
monthly_credits: number | null;
|
|
90
|
+
limits_tier: LimitsTier;
|
|
91
|
+
api: ApiRateLimitsReport;
|
|
92
|
+
storage: {
|
|
93
|
+
table_row_limit: number;
|
|
94
|
+
workspace_row_limit: number;
|
|
95
|
+
workspace_database_warning_bytes: number;
|
|
96
|
+
workspace_database_limit_bytes: number;
|
|
97
|
+
/** Equal to rows per Table (PROPOSED P-60): one file can fill an empty Table. */
|
|
98
|
+
import_max_rows_per_file: number;
|
|
99
|
+
import_max_file_bytes: number;
|
|
100
|
+
};
|
|
101
|
+
spend: {
|
|
102
|
+
trigger_run_credit_ceiling: number | null;
|
|
103
|
+
auto_run_batch_credit_ceiling: number | null;
|
|
104
|
+
agent_run_max_total_credits: number;
|
|
105
|
+
agent_run_max_inference_credits: number;
|
|
106
|
+
org_daily_guard_warn_credits: number | null;
|
|
107
|
+
org_daily_guard_block_credits: number | null;
|
|
108
|
+
byok_column_run_max_rows: number | null;
|
|
109
|
+
byok_provider_daily_calls: number | null;
|
|
110
|
+
};
|
|
111
|
+
signals: {
|
|
112
|
+
max_events: number;
|
|
113
|
+
max_window_days: number;
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
export declare function describePlanBandLimits(band: PlanBand, options?: RepricingOptions): PlanBandLimitsReport;
|
|
117
|
+
/** Every band's limits in force today, smallest first. */
|
|
118
|
+
export declare function describePlanBandLadder(options?: RepricingOptions): PlanBandLimitsReport[];
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { resolveBasePricingPlan } from "./billing.js";
|
|
2
|
+
import { planLimitsForTier, limitsTierForPlanTier } from "./plan-limits.js";
|
|
3
|
+
import { DEFAULT_AUTO_RUN_BATCH_CREDIT_CEILING, DEFAULT_BYOK_COLUMN_RUN_MAX_ROWS, DEFAULT_BYOK_PROVIDER_DAILY_CALL_CAP, DEFAULT_TRIGGER_RUN_CREDIT_CEILING, resolveOrgDailySpendGuard, } from "./spend-safety.js";
|
|
4
|
+
import { resolveWorkspaceTableCapacity } from "./table-capacity.js";
|
|
5
|
+
export const PLAN_BAND_ORDER = [
|
|
6
|
+
"free",
|
|
7
|
+
"49",
|
|
8
|
+
"99",
|
|
9
|
+
"199",
|
|
10
|
+
"499",
|
|
11
|
+
"999",
|
|
12
|
+
"1999",
|
|
13
|
+
];
|
|
14
|
+
/** The limits rung each band enforces at today. */
|
|
15
|
+
export const PLAN_BAND_LIMITS_TIER = Object.freeze({
|
|
16
|
+
free: "free",
|
|
17
|
+
"49": "starter",
|
|
18
|
+
"99": "starter",
|
|
19
|
+
"199": "pro",
|
|
20
|
+
"499": "pro",
|
|
21
|
+
"999": "team",
|
|
22
|
+
"1999": "scale",
|
|
23
|
+
});
|
|
24
|
+
/**
|
|
25
|
+
* PROPOSED (P-57, repricing spec § 4): a grandfathered or legacy plan takes the
|
|
26
|
+
* band of the limits rung it enforces at today, so no grandfathered customer
|
|
27
|
+
* loses a storage or rate limit. The pro rung spans $199–$499, whose columns
|
|
28
|
+
* differ only in the per-delivery spend default, which grandfathered plans
|
|
29
|
+
* resolve from their own monthly credits instead (P-62); it maps to the lower
|
|
30
|
+
* band. Enterprise enforces at the scale rung and therefore takes the top band.
|
|
31
|
+
*/
|
|
32
|
+
export const LEGACY_PLAN_BAND_BY_LIMITS_TIER = Object.freeze({
|
|
33
|
+
free: "free",
|
|
34
|
+
starter: "99",
|
|
35
|
+
pro: "199",
|
|
36
|
+
team: "999",
|
|
37
|
+
scale: "1999",
|
|
38
|
+
});
|
|
39
|
+
/**
|
|
40
|
+
* Map a raw plan-tier string (subscription `tier`, metadata tier, legacy plan
|
|
41
|
+
* key, or an Oxygen plan key like `oxygen_499`) onto its band. Unknown or absent
|
|
42
|
+
* tiers land on the unentitled "free" band, the same fail-closed rule as
|
|
43
|
+
* `limitsTierForPlanTier`.
|
|
44
|
+
*/
|
|
45
|
+
export function planBandForPlanTier(tier) {
|
|
46
|
+
const plan = resolveBasePricingPlan(tier);
|
|
47
|
+
if (!plan || plan.tier === "free")
|
|
48
|
+
return "free";
|
|
49
|
+
if (plan.tier === "oxygen")
|
|
50
|
+
return oxygenPlanBand(plan.monthlyPriceCents);
|
|
51
|
+
return LEGACY_PLAN_BAND_BY_LIMITS_TIER[limitsTierForPlanTier(tier)];
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The band of an org whose limits rung is already resolved (no second lookup).
|
|
55
|
+
* An unentitled org keeps no plan tier, so it takes the free band.
|
|
56
|
+
*/
|
|
57
|
+
export function planBandForLimitsResolution(resolution) {
|
|
58
|
+
return resolution.entitled ? planBandForPlanTier(resolution.planTier) : "free";
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* An Oxygen plan sits on the largest size at or below its price. A plan with no
|
|
62
|
+
* resolvable price takes the entry size, the same rung `limitsTierForPlanTier`
|
|
63
|
+
* gives it.
|
|
64
|
+
*/
|
|
65
|
+
function oxygenPlanBand(monthlyPriceCents) {
|
|
66
|
+
if (monthlyPriceCents === null)
|
|
67
|
+
return "49";
|
|
68
|
+
let band = "49";
|
|
69
|
+
for (const candidate of PLAN_BAND_ORDER) {
|
|
70
|
+
const price = planBandMonthlyPriceCents(candidate);
|
|
71
|
+
if (price !== null && price <= monthlyPriceCents)
|
|
72
|
+
band = candidate;
|
|
73
|
+
}
|
|
74
|
+
return band;
|
|
75
|
+
}
|
|
76
|
+
/** The band's monthly price, read from the Oxygen plan it names; null for free. */
|
|
77
|
+
export function planBandMonthlyPriceCents(band) {
|
|
78
|
+
if (band === "free")
|
|
79
|
+
return null;
|
|
80
|
+
return resolveBasePricingPlan(`oxygen_${band}`)?.monthlyPriceCents ?? null;
|
|
81
|
+
}
|
|
82
|
+
export function planBandLabel(band) {
|
|
83
|
+
return band === "free" ? "Free" : `Oxygen $${Number(band).toLocaleString("en-US")}`;
|
|
84
|
+
}
|
|
85
|
+
/** The API rates a limits rung enforces, read from `PLAN_LIMITS`. */
|
|
86
|
+
export function describeApiRateLimits(tier) {
|
|
87
|
+
const cli = planLimitsForTier(tier).cli;
|
|
88
|
+
return {
|
|
89
|
+
org_requests_per_minute: cli.global.orgRequestsPerMinute,
|
|
90
|
+
key_requests_per_minute: cli.global.keyRequestsPerMinute,
|
|
91
|
+
tenant_read_requests_per_key_per_minute: cli.tenantRead.requestsPerMinute,
|
|
92
|
+
tenant_write_requests_per_key_per_minute: cli.tenantWrite.requestsPerMinute,
|
|
93
|
+
// ai_live and tool_live are separate buckets with equal limits at every rung.
|
|
94
|
+
live_action_requests_per_key_per_minute: cli.toolLive.requestsPerMinute,
|
|
95
|
+
// Both org windows are hourly on every rung (pinned by plan-band.test.ts).
|
|
96
|
+
live_action_requests_per_org_per_hour: cli.toolLive.orgRequests.limit,
|
|
97
|
+
tool_dry_run_requests_per_key_per_minute: cli.toolDryRun.requestsPerMinute,
|
|
98
|
+
bulk_import_tables_per_org_per_hour: cli.bulkImport.orgRequests.limit,
|
|
99
|
+
rows_read_per_org: { rows: cli.tenantRead.orgUnits.limit, window_seconds: cli.tenantRead.orgUnits.windowSeconds },
|
|
100
|
+
rows_written_per_org: { rows: cli.tenantWrite.orgUnits.limit, window_seconds: cli.tenantWrite.orgUnits.windowSeconds },
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
export function describePlanBandLimits(band, options = {}) {
|
|
104
|
+
const limitsTier = PLAN_BAND_LIMITS_TIER[band];
|
|
105
|
+
const storage = resolveWorkspaceTableCapacity(band, options);
|
|
106
|
+
const limits = planLimitsForTier(limitsTier);
|
|
107
|
+
const monthlyPriceCents = planBandMonthlyPriceCents(band);
|
|
108
|
+
const monthlyCredits = band === "free"
|
|
109
|
+
? null
|
|
110
|
+
: resolveBasePricingPlan(`oxygen_${band}`)?.monthlyCredits ?? null;
|
|
111
|
+
const dailyGuard = resolveOrgDailySpendGuard(monthlyCredits);
|
|
112
|
+
return {
|
|
113
|
+
band,
|
|
114
|
+
label: planBandLabel(band),
|
|
115
|
+
monthly_price_usd: monthlyPriceCents === null ? null : monthlyPriceCents / 100,
|
|
116
|
+
monthly_credits: monthlyCredits,
|
|
117
|
+
limits_tier: limitsTier,
|
|
118
|
+
api: describeApiRateLimits(limitsTier),
|
|
119
|
+
storage: {
|
|
120
|
+
table_row_limit: storage.tableRowLimit,
|
|
121
|
+
workspace_row_limit: storage.workspaceRowLimit,
|
|
122
|
+
workspace_database_warning_bytes: storage.workspaceDatabaseWarningBytes,
|
|
123
|
+
workspace_database_limit_bytes: storage.workspaceDatabaseLimitBytes,
|
|
124
|
+
import_max_rows_per_file: storage.tableRowLimit,
|
|
125
|
+
import_max_file_bytes: limits.import.maxFileBytes,
|
|
126
|
+
},
|
|
127
|
+
spend: {
|
|
128
|
+
// Bands are Oxygen plan sizes, so their spend tier is their limits rung.
|
|
129
|
+
trigger_run_credit_ceiling: DEFAULT_TRIGGER_RUN_CREDIT_CEILING[limitsTier],
|
|
130
|
+
auto_run_batch_credit_ceiling: DEFAULT_AUTO_RUN_BATCH_CREDIT_CEILING[limitsTier],
|
|
131
|
+
agent_run_max_total_credits: limits.agents.maxTotalCredits,
|
|
132
|
+
agent_run_max_inference_credits: limits.agents.maxInferenceCredits,
|
|
133
|
+
org_daily_guard_warn_credits: dailyGuard?.warnCredits ?? null,
|
|
134
|
+
org_daily_guard_block_credits: dailyGuard?.blockCredits ?? null,
|
|
135
|
+
byok_column_run_max_rows: DEFAULT_BYOK_COLUMN_RUN_MAX_ROWS[limitsTier],
|
|
136
|
+
byok_provider_daily_calls: DEFAULT_BYOK_PROVIDER_DAILY_CALL_CAP[limitsTier],
|
|
137
|
+
},
|
|
138
|
+
signals: {
|
|
139
|
+
max_events: limits.signals.maxEvents,
|
|
140
|
+
max_window_days: limits.signals.maxWindowDays,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/** Every band's limits in force today, smallest first. */
|
|
145
|
+
export function describePlanBandLadder(options = {}) {
|
|
146
|
+
return PLAN_BAND_ORDER.map((band) => describePlanBandLimits(band, options));
|
|
147
|
+
}
|