@tangle-network/agent-app 0.16.1 → 0.19.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.
@@ -0,0 +1,123 @@
1
+ // src/model-resolution/index.ts
2
+ function canonicalModelId(model) {
3
+ if (model.id.includes("/")) return model.id;
4
+ const provider = model._provider ?? model.provider;
5
+ return provider ? `${provider}/${model.id}` : model.id;
6
+ }
7
+ function createChatModelResolution(defaults) {
8
+ const DEFAULT_ROUTER_MODEL = defaults.routerModel;
9
+ const DEFAULT_SANDBOX_OPENAI_MODEL = defaults.sandboxOpenaiModel;
10
+ const DEFAULT_ROUTER_BASE_URL = defaults.routerBaseUrl;
11
+ const allowlist = new Set(
12
+ [
13
+ DEFAULT_ROUTER_MODEL,
14
+ DEFAULT_SANDBOX_OPENAI_MODEL,
15
+ ...defaults.extraAllowlist ?? []
16
+ ].filter((model) => typeof model === "string" && model.length > 0)
17
+ );
18
+ function resolveChatModel({
19
+ requestedModel,
20
+ backend,
21
+ env = process.env
22
+ }) {
23
+ const selectedModel = cleanModelId(requestedModel);
24
+ if (selectedModel) return { backend, model: selectedModel, source: "request" };
25
+ if (backend === "router") {
26
+ const routerModel = cleanModelId(env.MODEL_NAME);
27
+ return {
28
+ backend,
29
+ model: routerModel ?? DEFAULT_ROUTER_MODEL,
30
+ source: routerModel ? "env:MODEL_NAME" : "default"
31
+ };
32
+ }
33
+ const sandboxModel = cleanModelId(env.MODEL_NAME);
34
+ if (sandboxModel) return { backend, model: sandboxModel, source: "env:MODEL_NAME" };
35
+ const modelProvider = env.MODEL_PROVIDER ?? (env.TANGLE_API_KEY ? "openai-compat" : env.OPENAI_API_KEY ? "openai" : void 0);
36
+ if (modelProvider === "openai" || modelProvider === "openai-compat") {
37
+ return { backend, model: DEFAULT_SANDBOX_OPENAI_MODEL, source: "default" };
38
+ }
39
+ return { backend, source: "sandbox-default" };
40
+ }
41
+ async function validateChatModelId(modelId, {
42
+ routerBaseUrl = DEFAULT_ROUTER_BASE_URL,
43
+ loadModels
44
+ }) {
45
+ const cleaned = cleanModelId(modelId);
46
+ if (!cleaned) {
47
+ return { succeeded: false, error: "Model id must be a non-empty string." };
48
+ }
49
+ if (!isWellFormedModelId(cleaned)) {
50
+ return { succeeded: false, error: `Model id is malformed: ${cleaned}` };
51
+ }
52
+ if (allowlist.has(cleaned)) {
53
+ return { succeeded: true, value: cleaned };
54
+ }
55
+ if (typeof routerBaseUrl !== "string" || routerBaseUrl.length === 0) {
56
+ return { succeeded: false, error: "Router base URL is required to validate against the catalog." };
57
+ }
58
+ let catalog;
59
+ try {
60
+ catalog = await loadModels(routerBaseUrl);
61
+ } catch (err) {
62
+ const message = err instanceof Error ? err.message : String(err);
63
+ return { succeeded: false, error: `Could not validate model catalog: ${message}` };
64
+ }
65
+ const ids = new Set(catalog.flatMap(catalogIdsForModel));
66
+ if (ids.has(cleaned)) {
67
+ return { succeeded: true, value: cleaned };
68
+ }
69
+ if (!cleaned.includes("/")) {
70
+ const canonicalBySuffix = /* @__PURE__ */ new Map();
71
+ for (const model of catalog) {
72
+ const canonical = canonicalModelIdOrUndefined(model);
73
+ if (!canonical || !canonical.includes("/")) continue;
74
+ const suffix = canonical.split("/").slice(1).join("/");
75
+ const entries = canonicalBySuffix.get(suffix);
76
+ if (entries) entries.push(canonical);
77
+ else canonicalBySuffix.set(suffix, [canonical]);
78
+ }
79
+ const matches = canonicalBySuffix.get(cleaned);
80
+ const only = matches && matches.length === 1 ? matches[0] : void 0;
81
+ if (only) {
82
+ return { succeeded: true, value: only };
83
+ }
84
+ }
85
+ return { succeeded: false, error: `Model is not available: ${cleaned}` };
86
+ }
87
+ return {
88
+ resolveChatModel,
89
+ validateChatModelId,
90
+ DEFAULT_ROUTER_MODEL,
91
+ DEFAULT_SANDBOX_OPENAI_MODEL,
92
+ DEFAULT_ROUTER_BASE_URL
93
+ };
94
+ }
95
+ function cleanModelId(value) {
96
+ if (typeof value !== "string") return void 0;
97
+ const trimmed = value.trim();
98
+ return trimmed.length > 0 ? trimmed : void 0;
99
+ }
100
+ function isWellFormedModelId(modelId) {
101
+ if (modelId.length > 200) return false;
102
+ return /^[A-Za-z0-9._/@:-]+$/.test(modelId);
103
+ }
104
+ function catalogIdsForModel(model) {
105
+ const ids = /* @__PURE__ */ new Set();
106
+ if (typeof model.id === "string" && model.id.trim()) ids.add(model.id.trim());
107
+ if (typeof model.id === "string" && model.id.trim() && !model.id.includes("/")) {
108
+ const canonical = canonicalModelId(model);
109
+ if (canonical.includes("/")) ids.add(canonical);
110
+ }
111
+ return [...ids];
112
+ }
113
+ function canonicalModelIdOrUndefined(model) {
114
+ if (typeof model.id !== "string" || !model.id.trim()) return void 0;
115
+ return canonicalModelId(model);
116
+ }
117
+ export {
118
+ catalogIdsForModel,
119
+ cleanModelId,
120
+ createChatModelResolution,
121
+ isWellFormedModelId
122
+ };
123
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/model-resolution/index.ts"],"sourcesContent":["/**\n * Chat-time model resolution: a precedence resolver and a fail-closed catalog\n * validator that sit on top of a product's boot-time model config.\n *\n * `resolveChatModel` picks the model id for a chat turn by precedence:\n * request id > env MODEL_NAME > provider default > sandbox default.\n *\n * `validateChatModelId` is the fail-closed gate: it returns a typed outcome and\n * accepts an id only if it is in the constructed allowlist OR served by the live\n * router catalog (loaded through an injected boundary). A bare id with no\n * provider prefix resolves to its canonical id only when the suffix is unique\n * across the catalog, so an ambiguous suffix is rejected rather than silently\n * assigned a provider.\n *\n * The product injects one value — `modelDefaults` — and supplies the catalog\n * loader per call. `ModelInfo` is the router /v1/models wire shape and\n * `canonicalModelId` the bare->prefixed id helper, both defined locally so this\n * engine module carries no UI-package coupling.\n */\n\n/** The router /v1/models entry shape this module reads. Minimal on purpose. */\nexport interface ModelInfo {\n id: string\n name?: string\n _provider?: string\n provider?: string\n}\n\n/** Canonical (provider-prefixed) id for a catalog entry: pass through an id that\n * already carries a provider, else prefix the entry's provider when present. */\nfunction canonicalModelId(model: ModelInfo): string {\n if (model.id.includes('/')) return model.id\n const provider = model._provider ?? model.provider\n return provider ? `${provider}/${model.id}` : model.id\n}\n\n/** Which execution path the chat turn runs on. Product-supplied per turn. */\nexport type ChatBackend = 'router' | 'sandbox'\n\nexport type ChatModelSource =\n | 'request'\n | 'env:MODEL_NAME'\n | 'default'\n | 'sandbox-default'\n\nexport interface ResolvedChatModel {\n backend: ChatBackend\n model?: string\n source: ChatModelSource\n}\n\nexport interface ChatModelValidationSuccess {\n succeeded: true\n value: string\n}\n\nexport interface ChatModelValidationFailure {\n succeeded: false\n error: string\n}\n\nexport type ChatModelValidationResult = ChatModelValidationSuccess | ChatModelValidationFailure\n\n/** The catalog-fetch boundary: maps a router base URL to the raw model list. */\nexport type LoadModels = (routerBaseUrl: string) => Promise<ModelInfo[]>\n\n/**\n * The single product-injected seam.\n *\n * - `routerModel` / `sandboxOpenaiModel`: the two `DEFAULT_*` ids used by the\n * precedence ladder and seeded into the allowlist.\n * - `routerBaseUrl`: catalog endpoint base; overridable per validate call.\n * - `extraAllowlist`: additional ids accepted without a catalog round-trip.\n */\nexport interface ChatModelDefaults {\n routerModel: string\n sandboxOpenaiModel: string\n routerBaseUrl?: string\n extraAllowlist?: string[]\n}\n\nexport interface ResolveChatModelOptions {\n requestedModel?: string\n backend: ChatBackend\n /** Env to read (defaults to process.env). Inject for non-node runtimes. */\n env?: Record<string, string | undefined>\n}\n\nexport interface ValidateChatModelIdOptions {\n routerBaseUrl?: string\n /** Catalog loader. No default body is baked in; the consumer supplies it. */\n loadModels: LoadModels\n}\n\nexport interface ChatModelResolution {\n resolveChatModel: (options: ResolveChatModelOptions) => ResolvedChatModel\n validateChatModelId: (\n modelId: unknown,\n options: ValidateChatModelIdOptions,\n ) => Promise<ChatModelValidationResult>\n DEFAULT_ROUTER_MODEL: string\n DEFAULT_SANDBOX_OPENAI_MODEL: string\n DEFAULT_ROUTER_BASE_URL?: string\n}\n\nexport function createChatModelResolution(defaults: ChatModelDefaults): ChatModelResolution {\n const DEFAULT_ROUTER_MODEL = defaults.routerModel\n const DEFAULT_SANDBOX_OPENAI_MODEL = defaults.sandboxOpenaiModel\n const DEFAULT_ROUTER_BASE_URL = defaults.routerBaseUrl\n\n const allowlist = new Set(\n [\n DEFAULT_ROUTER_MODEL,\n DEFAULT_SANDBOX_OPENAI_MODEL,\n ...(defaults.extraAllowlist ?? []),\n ].filter((model): model is string => typeof model === 'string' && model.length > 0),\n )\n\n function resolveChatModel({\n requestedModel,\n backend,\n env = process.env,\n }: ResolveChatModelOptions): ResolvedChatModel {\n const selectedModel = cleanModelId(requestedModel)\n if (selectedModel) return { backend, model: selectedModel, source: 'request' }\n\n if (backend === 'router') {\n const routerModel = cleanModelId(env.MODEL_NAME)\n return {\n backend,\n model: routerModel ?? DEFAULT_ROUTER_MODEL,\n source: routerModel ? 'env:MODEL_NAME' : 'default',\n }\n }\n\n const sandboxModel = cleanModelId(env.MODEL_NAME)\n if (sandboxModel) return { backend, model: sandboxModel, source: 'env:MODEL_NAME' }\n\n const modelProvider = env.MODEL_PROVIDER\n ?? (env.TANGLE_API_KEY ? 'openai-compat' : env.OPENAI_API_KEY ? 'openai' : undefined)\n if (modelProvider === 'openai' || modelProvider === 'openai-compat') {\n return { backend, model: DEFAULT_SANDBOX_OPENAI_MODEL, source: 'default' }\n }\n\n return { backend, source: 'sandbox-default' }\n }\n\n async function validateChatModelId(\n modelId: unknown,\n {\n routerBaseUrl = DEFAULT_ROUTER_BASE_URL,\n loadModels,\n }: ValidateChatModelIdOptions,\n ): Promise<ChatModelValidationResult> {\n const cleaned = cleanModelId(modelId)\n if (!cleaned) {\n return { succeeded: false, error: 'Model id must be a non-empty string.' }\n }\n if (!isWellFormedModelId(cleaned)) {\n return { succeeded: false, error: `Model id is malformed: ${cleaned}` }\n }\n if (allowlist.has(cleaned)) {\n return { succeeded: true, value: cleaned }\n }\n if (typeof routerBaseUrl !== 'string' || routerBaseUrl.length === 0) {\n return { succeeded: false, error: 'Router base URL is required to validate against the catalog.' }\n }\n\n let catalog: ModelInfo[]\n try {\n catalog = await loadModels(routerBaseUrl)\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err)\n return { succeeded: false, error: `Could not validate model catalog: ${message}` }\n }\n\n // Exact match against any id the catalog serves (canonical or bare).\n const ids = new Set(catalog.flatMap(catalogIdsForModel))\n if (ids.has(cleaned)) {\n return { succeeded: true, value: cleaned }\n }\n\n // A bare request id (no provider prefix) may name a model the catalog only\n // serves under a provider-prefixed id (e.g. request \"gpt-5\" -> catalog\n // \"openai/gpt-5\"). Resolve it to the canonical id the router serves, but only\n // when the bare suffix is unique across the catalog -- an ambiguous suffix\n // (e.g. \"openai/x\" vs \"vertex/x\") stays rejected so we never silently pick a\n // provider for the caller.\n if (!cleaned.includes('/')) {\n const canonicalBySuffix = new Map<string, string[]>()\n for (const model of catalog) {\n const canonical = canonicalModelIdOrUndefined(model)\n if (!canonical || !canonical.includes('/')) continue\n const suffix = canonical.split('/').slice(1).join('/')\n const entries = canonicalBySuffix.get(suffix)\n if (entries) entries.push(canonical)\n else canonicalBySuffix.set(suffix, [canonical])\n }\n const matches = canonicalBySuffix.get(cleaned)\n const only = matches && matches.length === 1 ? matches[0] : undefined\n if (only) {\n return { succeeded: true, value: only }\n }\n }\n\n return { succeeded: false, error: `Model is not available: ${cleaned}` }\n }\n\n return {\n resolveChatModel,\n validateChatModelId,\n DEFAULT_ROUTER_MODEL,\n DEFAULT_SANDBOX_OPENAI_MODEL,\n DEFAULT_ROUTER_BASE_URL,\n }\n}\n\nexport function cleanModelId(value: unknown): string | undefined {\n if (typeof value !== 'string') return undefined\n const trimmed = value.trim()\n return trimmed.length > 0 ? trimmed : undefined\n}\n\nexport function isWellFormedModelId(modelId: string): boolean {\n if (modelId.length > 200) return false\n return /^[A-Za-z0-9._/@:-]+$/.test(modelId)\n}\n\nexport function catalogIdsForModel(model: ModelInfo): string[] {\n const ids = new Set<string>()\n if (typeof model.id === 'string' && model.id.trim()) ids.add(model.id.trim())\n\n if (typeof model.id === 'string' && model.id.trim() && !model.id.includes('/')) {\n const canonical = canonicalModelId(model)\n if (canonical.includes('/')) ids.add(canonical)\n }\n\n // The bare suffix of a provider-prefixed id (e.g. \"openai/gpt-5\" -> \"gpt-5\")\n // is NOT added here: a bare request id resolves to its canonical id only\n // through the uniqueness-gated path in validateChatModelId, so an ambiguous\n // suffix never slips through as an exact match.\n return [...ids]\n}\n\n/** The canonical id for a catalog entry, or undefined when the entry has no id. */\nfunction canonicalModelIdOrUndefined(model: ModelInfo): string | undefined {\n if (typeof model.id !== 'string' || !model.id.trim()) return undefined\n return canonicalModelId(model)\n}\n"],"mappings":"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","names":[]}
@@ -325,6 +325,30 @@ interface PlatformUsageProductRow {
325
325
  totalSpent: number;
326
326
  count: number;
327
327
  }
328
+ /** Lifecycle of a per-product seat subscription, mirroring the Stripe states
329
+ * the platform persists. 'none' = the user has never held this seat. */
330
+ type SeatStatus = 'none' | 'active' | 'trialing' | 'past_due' | 'canceled';
331
+ /**
332
+ * Per-product entitlement snapshot from the platform — the single read that
333
+ * tells a product whether to show its workspace or the seat paywall. Shape
334
+ * matches `GET /v1/billing/product-entitlement?product=<id>`.
335
+ *
336
+ * `hasSeat` and `onFreeTier` are computed platform-side from the raw seat row
337
+ * + cumulative spend so the gate is identical across all five products:
338
+ * - `hasSeat` — an active/trialing seat whose period has not lapsed.
339
+ * - `onFreeTier` — no active seat AND cumulative spend below the free cap
340
+ * ($2 / 200¢ lifetime). Keys off lifetime spend, not wallet
341
+ * balance, so a router top-up never re-opens free access.
342
+ */
343
+ interface ProductEntitlement {
344
+ seatStatus: SeatStatus;
345
+ /** ISO timestamp the active seat's paid period runs until; null when none. */
346
+ currentPeriodEnd: string | null;
347
+ /** Cumulative inference spend across the whole suite, in dollars. */
348
+ lifetimeSpentUsd: number;
349
+ hasSeat: boolean;
350
+ onFreeTier: boolean;
351
+ }
328
352
  interface PlatformBillingHttp {
329
353
  /** GET /v1/plans/current (user bearer). */
330
354
  getSubscription(userApiKey: string): Promise<PlatformSubscriptionInfo>;
@@ -332,6 +356,8 @@ interface PlatformBillingHttp {
332
356
  getBalance(userApiKey: string): Promise<PlatformBalanceSnapshot>;
333
357
  /** GET /v1/billing/usage (user bearer). */
334
358
  getUsageByProduct(userApiKey: string): Promise<PlatformUsageProductRow[]>;
359
+ /** GET /v1/billing/product-entitlement?product=<id> (user bearer). */
360
+ getProductEntitlement(userApiKey: string, productId: string): Promise<ProductEntitlement>;
335
361
  /** POST /v1/billing/deduct (service token). */
336
362
  deduct(input: {
337
363
  platformUserId: string;
@@ -342,8 +368,17 @@ interface PlatformBillingHttp {
342
368
  }): Promise<void>;
343
369
  /** Absolute URL of the platform's billing-management surface. */
344
370
  billingUrl(): string;
371
+ /** Absolute URL of the $100/mo seat checkout for `productId`. */
372
+ seatCheckoutUrl(productId: string): string;
345
373
  }
346
374
  declare function createPlatformBillingHttp(opts: PlatformBillingHttpOptions): PlatformBillingHttp;
375
+ /**
376
+ * Platform Stripe checkout URL for a product's $100/mo seat. One shared price
377
+ * carries `metadata.productId`; the platform distinguishes the product from
378
+ * the `product` query param (not five distinct prices). Mirrors the
379
+ * `billingUrl()` shape — a deterministic platform-rooted URL, no network call.
380
+ */
381
+ declare function seatCheckoutUrl(baseUrl: string, productId: string): string;
347
382
  interface TangleTierPolicy {
348
383
  concurrency: number;
349
384
  overageAllowed: boolean;
@@ -364,6 +399,41 @@ interface TangleTierState {
364
399
  * on the billable path choose their posture explicitly.
365
400
  */
366
401
  declare function readTangleTierState(http: PlatformBillingHttp, userApiKey: string | null | undefined, policy?: Record<TanglePlanTier, TangleTierPolicy>): Promise<TangleTierState>;
402
+ /** Lifetime free-tier cap: $2 (200¢) cumulative inference spend, expressed in
403
+ * dollars. Free product access ends once cumulative spend crosses this. */
404
+ declare const FREE_TIER_SPEND_CAP_USD = 2;
405
+ /**
406
+ * Default name of the per-app feature flag gating seat billing. While OFF the
407
+ * entitlement read is skipped and access fails OPEN (entitled) so nothing
408
+ * changes live until a product flips the flag.
409
+ */
410
+ declare const DEFAULT_SEAT_BILLING_ENABLED_ENV_VAR = "SEAT_BILLING_ENABLED";
411
+ interface SeatBillingFlagOptions {
412
+ env?: Record<string, string | undefined>;
413
+ /** Override the flag name; default {@link DEFAULT_SEAT_BILLING_ENABLED_ENV_VAR}. */
414
+ flagEnvVar?: string;
415
+ }
416
+ /**
417
+ * Seat billing is OFF unless the flag is explicitly truthy ('true'/'1'/'on'/
418
+ * 'enabled'). Default OFF — pre-rollout, the paywall never engages. Returns
419
+ * false when no env is available (browser bundles) so the client stays
420
+ * fail-open there too.
421
+ */
422
+ declare function isSeatBillingEnabled(opts?: SeatBillingFlagOptions): boolean;
423
+ /**
424
+ * Read a user's entitlement for one product. Fails OPEN: an absent key,
425
+ * disabled flag, or unreachable seat endpoint all return a permissive snapshot
426
+ * (`hasSeat: true`) so consumers never break pre-rollout. The platform owns the
427
+ * `hasSeat`/`onFreeTier` computation; this client only transports + degrades
428
+ * safely.
429
+ *
430
+ * @param flag — pass {@link isSeatBillingEnabled} (or your own boolean) so the
431
+ * product owns when the gate engages. When false, no network call is made.
432
+ */
433
+ declare function getProductEntitlement(http: Pick<PlatformBillingHttp, 'getProductEntitlement'>, userApiKey: string | null | undefined, productId: string, flag?: boolean): Promise<ProductEntitlement>;
434
+ /** Entitled = holds an active seat OR is still inside the free tier. The one
435
+ * predicate all five products gate on. */
436
+ declare function isProductEntitled(ent: ProductEntitlement): boolean;
367
437
  interface PlatformIdentityStore {
368
438
  resolveIdentity(userId: string): Promise<PlatformIdentity | null>;
369
439
  }
@@ -429,4 +499,4 @@ interface AssertBillableBalanceOptions {
429
499
  */
430
500
  declare function assertBillableBalance(state: BillableBalanceState, opts?: AssertBillableBalanceOptions): void;
431
501
 
432
- export { type AdminGuardOptions, type AssertBillableBalanceOptions, type AuthGuard, type AuthGuardOptions, type BetterAuthSessionCookieMinterOptions, type BetterAuthSessionCookieSource, type BillableBalanceState, DEFAULT_TANGLE_TIER_POLICY, type HubClientLike, type HubProxyContext, type HubProxyRouteArgs, type HubProxyRoutes, type PlatformBalanceSnapshot, type PlatformBillingHttp, PlatformBillingHttpError, type PlatformBillingHttpOptions, type PlatformIdentityStore, type PlatformSubscriptionInfo, type PlatformUsageProductRow, type SsoStateConfig, TangleBearerMissingError, type TanglePlanTier, type TangleSsoAccountStore, type TangleSsoAuthClient, type TangleSsoExchangeResult, type TangleSsoHandlerOptions, type TangleSsoHandlers, type TangleSsoSessionCookieArgs, TangleSsoUserCreateError, type TangleTierPolicy, type TangleTierState, assertBillableBalance, createAdminGuard, createAuthGuard, createBetterAuthSessionCookieMinter, createHubProxyRoutes, createPlatformBillingHttp, createSignedSsoState, createTanglePlatformBillingClient, createTangleSsoHandlers, isPlatformBillingHttpError, isPlatformHubErrorLike, isTangleBearerMissingError, normalizeTanglePlanTier, parseAdminEmails, readTangleTierState, signSessionCookieValue, verifySignedSsoState };
502
+ export { type AdminGuardOptions, type AssertBillableBalanceOptions, type AuthGuard, type AuthGuardOptions, type BetterAuthSessionCookieMinterOptions, type BetterAuthSessionCookieSource, type BillableBalanceState, DEFAULT_SEAT_BILLING_ENABLED_ENV_VAR, DEFAULT_TANGLE_TIER_POLICY, FREE_TIER_SPEND_CAP_USD, type HubClientLike, type HubProxyContext, type HubProxyRouteArgs, type HubProxyRoutes, type PlatformBalanceSnapshot, type PlatformBillingHttp, PlatformBillingHttpError, type PlatformBillingHttpOptions, type PlatformIdentityStore, type PlatformSubscriptionInfo, type PlatformUsageProductRow, type ProductEntitlement, type SeatBillingFlagOptions, type SeatStatus, type SsoStateConfig, TangleBearerMissingError, type TanglePlanTier, type TangleSsoAccountStore, type TangleSsoAuthClient, type TangleSsoExchangeResult, type TangleSsoHandlerOptions, type TangleSsoHandlers, type TangleSsoSessionCookieArgs, TangleSsoUserCreateError, type TangleTierPolicy, type TangleTierState, assertBillableBalance, createAdminGuard, createAuthGuard, createBetterAuthSessionCookieMinter, createHubProxyRoutes, createPlatformBillingHttp, createSignedSsoState, createTanglePlatformBillingClient, createTangleSsoHandlers, getProductEntitlement, isPlatformBillingHttpError, isPlatformHubErrorLike, isProductEntitled, isSeatBillingEnabled, isTangleBearerMissingError, normalizeTanglePlanTier, parseAdminEmails, readTangleTierState, seatCheckoutUrl, signSessionCookieValue, verifySignedSsoState };
@@ -376,6 +376,20 @@ function createPlatformBillingHttp(opts) {
376
376
  count: row.count ?? 0
377
377
  }));
378
378
  },
379
+ async getProductEntitlement(userApiKey, productId) {
380
+ const slug = encodeURIComponent(productId);
381
+ const body = await userRead(userApiKey, `/v1/billing/product-entitlement?product=${slug}`);
382
+ const data = body.data ?? {};
383
+ const hasSeat = data.hasSeat === true;
384
+ return {
385
+ seatStatus: data.seatStatus ?? "none",
386
+ currentPeriodEnd: data.currentPeriodEnd ?? null,
387
+ lifetimeSpentUsd: data.lifetimeSpentUsd ?? 0,
388
+ hasSeat,
389
+ // Free access only when there is no seat AND the platform says so.
390
+ onFreeTier: !hasSeat && data.onFreeTier === true
391
+ };
392
+ },
379
393
  async deduct(input) {
380
394
  const headers = new Headers();
381
395
  headers.set("Authorization", `Bearer ${resolveServiceToken()}`);
@@ -395,9 +409,16 @@ function createPlatformBillingHttp(opts) {
395
409
  },
396
410
  billingUrl() {
397
411
  return `${baseUrl}/app/billing`;
412
+ },
413
+ seatCheckoutUrl(productId) {
414
+ return seatCheckoutUrl(baseUrl, productId);
398
415
  }
399
416
  };
400
417
  }
418
+ function seatCheckoutUrl(baseUrl, productId) {
419
+ const root = baseUrl.replace(/\/+$/, "");
420
+ return `${root}/app/billing/seat/checkout?product=${encodeURIComponent(productId)}`;
421
+ }
401
422
  var DEFAULT_TANGLE_TIER_POLICY = {
402
423
  free: { concurrency: 1, overageAllowed: false },
403
424
  pro: { concurrency: Number.POSITIVE_INFINITY, overageAllowed: true },
@@ -425,6 +446,34 @@ async function readTangleTierState(http, userApiKey, policy = DEFAULT_TANGLE_TIE
425
446
  ...policy[subscription.tier]
426
447
  };
427
448
  }
449
+ var FREE_TIER_SPEND_CAP_USD = 2;
450
+ var DEFAULT_SEAT_BILLING_ENABLED_ENV_VAR = "SEAT_BILLING_ENABLED";
451
+ function isSeatBillingEnabled(opts = {}) {
452
+ const env = opts.env ?? (typeof process !== "undefined" ? process.env : void 0);
453
+ if (!env) return false;
454
+ const flag = env[opts.flagEnvVar ?? DEFAULT_SEAT_BILLING_ENABLED_ENV_VAR]?.trim().toLowerCase();
455
+ return flag === "true" || flag === "1" || flag === "on" || flag === "enabled";
456
+ }
457
+ async function getProductEntitlement(http, userApiKey, productId, flag = true) {
458
+ if (!flag || !userApiKey) return failOpenEntitlement();
459
+ try {
460
+ return await http.getProductEntitlement(userApiKey, productId);
461
+ } catch {
462
+ return failOpenEntitlement();
463
+ }
464
+ }
465
+ function failOpenEntitlement() {
466
+ return {
467
+ seatStatus: "active",
468
+ currentPeriodEnd: null,
469
+ lifetimeSpentUsd: 0,
470
+ hasSeat: true,
471
+ onFreeTier: false
472
+ };
473
+ }
474
+ function isProductEntitled(ent) {
475
+ return ent.hasSeat || ent.onFreeTier;
476
+ }
428
477
  function createTanglePlatformBillingClient(http, identity) {
429
478
  return {
430
479
  resolveIdentity: (userId) => identity.resolveIdentity(userId),
@@ -484,7 +533,9 @@ function assertBillableBalance(state, opts = {}) {
484
533
  );
485
534
  }
486
535
  export {
536
+ DEFAULT_SEAT_BILLING_ENABLED_ENV_VAR,
487
537
  DEFAULT_TANGLE_TIER_POLICY,
538
+ FREE_TIER_SPEND_CAP_USD,
488
539
  PlatformBillingHttpError,
489
540
  TangleBearerMissingError,
490
541
  TangleSsoUserCreateError,
@@ -497,12 +548,16 @@ export {
497
548
  createSignedSsoState,
498
549
  createTanglePlatformBillingClient,
499
550
  createTangleSsoHandlers,
551
+ getProductEntitlement,
500
552
  isPlatformBillingHttpError,
501
553
  isPlatformHubErrorLike,
554
+ isProductEntitled,
555
+ isSeatBillingEnabled,
502
556
  isTangleBearerMissingError,
503
557
  normalizeTanglePlanTier,
504
558
  parseAdminEmails,
505
559
  readTangleTierState,
560
+ seatCheckoutUrl,
506
561
  signSessionCookieValue,
507
562
  verifySignedSsoState
508
563
  };