@noodleseed/one 0.148.0 → 0.149.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.
Files changed (59) hide show
  1. package/dist/commands/assistant-ops.d.ts.map +1 -1
  2. package/dist/commands/assistant-ops.js +34 -0
  3. package/dist/commands/assistant-ops.js.map +1 -1
  4. package/dist/commands/assistant-surface-ops.d.ts +14 -1
  5. package/dist/commands/assistant-surface-ops.d.ts.map +1 -1
  6. package/dist/commands/assistant-surface-ops.js +33 -2
  7. package/dist/commands/assistant-surface-ops.js.map +1 -1
  8. package/dist/dev-local-runtime.d.ts +1 -0
  9. package/dist/dev-local-runtime.d.ts.map +1 -1
  10. package/dist/dev-local-runtime.js +3 -0
  11. package/dist/dev-local-runtime.js.map +1 -1
  12. package/node_modules/@noodle-borg/admission-limits/dist/counter-store.d.ts +20 -0
  13. package/node_modules/@noodle-borg/admission-limits/dist/counter-store.js +12 -0
  14. package/node_modules/@noodle-borg/admission-limits/dist/envelope.d.ts +34 -2
  15. package/node_modules/@noodle-borg/admission-limits/dist/envelope.js +35 -11
  16. package/node_modules/@noodle-borg/admission-limits/dist/in-memory-counter-store.d.ts +1 -0
  17. package/node_modules/@noodle-borg/admission-limits/dist/in-memory-counter-store.js +14 -1
  18. package/node_modules/@noodle-borg/admission-limits/dist/portable.d.ts +1 -0
  19. package/node_modules/@noodle-borg/admission-limits/dist/portable.js +1 -0
  20. package/node_modules/@noodle-borg/admission-limits/dist/visitor-bucket.d.ts +20 -0
  21. package/node_modules/@noodle-borg/admission-limits/dist/visitor-bucket.js +30 -0
  22. package/node_modules/@noodle-borg/agent-kit/dist/skill-tool-design-ref.js +4 -0
  23. package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
  24. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-guide.d.ts +12 -0
  25. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-guide.js +15 -0
  26. package/node_modules/@noodle-borg/assistant-gateway/dist/embed-operator-view.d.ts +71 -0
  27. package/node_modules/@noodle-borg/assistant-gateway/dist/embed-operator-view.js +59 -0
  28. package/node_modules/@noodle-borg/assistant-gateway/dist/embed-store.d.ts +27 -0
  29. package/node_modules/@noodle-borg/assistant-gateway/dist/embed-store.js +35 -0
  30. package/node_modules/@noodle-borg/assistant-gateway/dist/in-memory-embed-store.js +6 -0
  31. package/node_modules/@noodle-borg/assistant-gateway/dist/managed-spend.d.ts +80 -0
  32. package/node_modules/@noodle-borg/assistant-gateway/dist/managed-spend.js +186 -0
  33. package/node_modules/@noodle-borg/assistant-gateway/dist/model-request.d.ts +16 -0
  34. package/node_modules/@noodle-borg/assistant-gateway/dist/portable.d.ts +2 -0
  35. package/node_modules/@noodle-borg/assistant-gateway/dist/portable.js +2 -0
  36. package/node_modules/@noodle-borg/assistant-gateway/dist/public-session.d.ts +14 -1
  37. package/node_modules/@noodle-borg/assistant-gateway/dist/public-session.js +23 -6
  38. package/node_modules/@noodle-borg/assistant-gateway/dist/public-turn.js +22 -0
  39. package/node_modules/@noodle-borg/assistant-gateway/dist/surface-budget.d.ts +15 -1
  40. package/node_modules/@noodle-borg/assistant-gateway/dist/surface-budget.js +14 -0
  41. package/node_modules/@noodle-borg/assistant-gateway/package.json +1 -1
  42. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-assistant.js +15 -1
  43. package/node_modules/@noodle-borg/module/dist/request-analytics.d.ts +19 -2
  44. package/node_modules/@noodle-borg/module/dist/request-analytics.js +15 -2
  45. package/node_modules/@noodle-borg/observability/dist/assistant-usage.js +68 -10
  46. package/node_modules/@noodle-borg/observability/dist/index.js +1 -0
  47. package/node_modules/@noodle-borg/observability/dist/request-event-query.js +48 -0
  48. package/node_modules/@noodle-borg/observability/dist/request-events-postgres.js +13 -2
  49. package/node_modules/@noodle-borg/observability/dist/request-events.js +3 -0
  50. package/node_modules/@noodle-borg/service/dist/routes/analytics.js +5 -29
  51. package/node_modules/@noodle-borg/service/dist/routes/assistant-agent.js +57 -29
  52. package/node_modules/@noodle-borg/service/dist/routes/assistant-embeds.js +26 -38
  53. package/node_modules/@noodle-borg/service/dist/routes/assistant-model-binding.js +1 -4
  54. package/node_modules/@noodle-borg/service/dist/routes/assistant-public-session.js +7 -3
  55. package/node_modules/@noodle-borg/service/dist/routes/assistant.js +14 -8
  56. package/node_modules/@noodle-borg/service/package.json +1 -1
  57. package/node_modules/@noodle-borg/transport-http/dist/request-capture.js +2 -0
  58. package/node_modules/@noodleseed/assistant/package.json +1 -1
  59. package/package.json +2 -2
@@ -31,6 +31,21 @@ export function selectAssistantModelTools(artifact, caller, options = {}) {
31
31
  hasValidSessionVisibility(tool, options.usedToolNames) &&
32
32
  matchesLatestMessageConstraint(tool, options.latestMessage));
33
33
  }
34
+ /**
35
+ * What to tell a model that called a tool this turn does not offer.
36
+ *
37
+ * The alternative — ending the turn on `invalid_model_tool_call` — is a security-correct rejection
38
+ * with a dishonest failure mode: the widget renders it as "temporarily unavailable" beside a
39
+ * Reconnect button that mints a whole new session, so a visitor who asked for something reasonable
40
+ * gets silence and the surface pays a session for it. A `role:'tool'` result costs one model step
41
+ * and gets the visitor a real answer instead. It names the tool because the model supplied that
42
+ * name in the first place, and says nothing about why the tool is absent, which is the part a
43
+ * visitor is not entitled to.
44
+ */
45
+ export function assistantOmittedToolResult(name) {
46
+ return (`The tool "${name}" is not available in this conversation. Do not call it again. ` +
47
+ 'Answer the person directly in your own words, and say plainly if you cannot help with this.');
48
+ }
34
49
  /** Whether an already-selected tool requires the first model step to call it. */
35
50
  export function assistantModelToolRequiredWhenVisible(tool) {
36
51
  return tool.annotations?.[REQUIRED_WHEN_VISIBLE] === true;
@@ -0,0 +1,71 @@
1
+ import type { AdmissionEnvelope } from '@noodle-borg/admission-limits/portable';
2
+ import type { PublicEmbedRecord } from './embed-store.js';
3
+ import type { PublicSurface } from './public-surface.js';
4
+ /**
5
+ * The operator's view of one public surface: what it is, and what it may spend.
6
+ *
7
+ * Origins, capabilities, and surfaceMode come from the *active* deployment rather than the embed
8
+ * row, so what an operator reads here is what a browser will actually be held to at mint time. The
9
+ * row keeps its provisioning-time mode by design — `ensure` never overwrites a live row.
10
+ */
11
+ export interface AssistantEmbedOperatorView {
12
+ readonly embedId: string;
13
+ readonly surfaceMode: string;
14
+ readonly origins: readonly string[];
15
+ readonly capabilities: readonly string[];
16
+ readonly turnsPerDay: number;
17
+ readonly mintsPerDay: number;
18
+ /** True when the caps are the deployment defaults rather than something an operator chose. */
19
+ readonly budgetIsDefault: boolean;
20
+ readonly turnsToday: number;
21
+ readonly mintsToday: number;
22
+ readonly createdAt: string;
23
+ /**
24
+ * Present only when Noodle is funding this surface's model. It exists so an operator can tell
25
+ * platform-imposed degradation from a bug in their own app, which is the one thing they cannot
26
+ * work out from the caps above — those look normal right up to the moment the ladder closes them.
27
+ */
28
+ readonly managedSpend?: AssistantManagedSpendView;
29
+ }
30
+ export interface AssistantManagedSpendView {
31
+ /** `normal`, `approaching`, `near`, `limited`, or `closed`. */
32
+ readonly state: string;
33
+ /**
34
+ * Turns left today at the current rung, not a percentage. The spend consume is all-or-nothing, so
35
+ * a costly turn is refused while a cheap one still passes at the tail of the day — a percentage
36
+ * would look like it stopped early.
37
+ */
38
+ readonly turnsRemaining: number;
39
+ /** One plain sentence about what a visitor experiences right now. */
40
+ readonly visitors: string;
41
+ }
42
+ /**
43
+ * Project a tenant's spend into what an operator needs to read.
44
+ *
45
+ * Takes the raw peek rather than a rung so the caller owns only the read: which rung that is, and
46
+ * what a turn costs there, are the ladder's business and stay in one place.
47
+ */
48
+ export declare function assistantManagedSpendView(input: {
49
+ readonly spend: {
50
+ readonly allowance: number;
51
+ };
52
+ readonly unitsUsed: number;
53
+ }): AssistantManagedSpendView;
54
+ /**
55
+ * Project one embed row into that view. Pure on purpose: the caller owns the reads (active
56
+ * deployment, counter peeks) and this owns the shape, so the projection stays testable without a
57
+ * registry or a counter store.
58
+ */
59
+ export declare function assistantEmbedOperatorView(input: {
60
+ readonly record: PublicEmbedRecord;
61
+ readonly surface: PublicSurface | undefined;
62
+ readonly envelope: AdmissionEnvelope;
63
+ /** Usage without consuming. The keys are this package's own convention, so it forms them here. */
64
+ readonly peek: (key: string) => Promise<number>;
65
+ /** Present only when Noodle funds this surface's model. */
66
+ readonly spend?: {
67
+ readonly key: string;
68
+ readonly allowance: number;
69
+ };
70
+ }): Promise<AssistantEmbedOperatorView>;
71
+ //# sourceMappingURL=embed-operator-view.d.ts.map
@@ -0,0 +1,59 @@
1
+ import { managedSpendRung, managedSpendUnitWeight } from './managed-spend.js';
2
+ const SPEND_STATES = [
3
+ { state: 'normal', visitors: 'Full answers, full tools.' },
4
+ { state: 'approaching', visitors: 'Shorter answers. Nothing else changes.' },
5
+ {
6
+ state: 'near',
7
+ visitors: 'Shorter answers, and conversations restart after 8 turns.',
8
+ },
9
+ { state: 'limited', visitors: 'Short answers from knowledge only; tools are paused.' },
10
+ {
11
+ state: 'closed',
12
+ visitors: 'Paused until 00:00 UTC. Connect your own model key to keep serving.',
13
+ },
14
+ ];
15
+ /**
16
+ * Project a tenant's spend into what an operator needs to read.
17
+ *
18
+ * Takes the raw peek rather than a rung so the caller owns only the read: which rung that is, and
19
+ * what a turn costs there, are the ladder's business and stay in one place.
20
+ */
21
+ export function assistantManagedSpendView(input) {
22
+ const rungAt = managedSpendRung(input.unitsUsed, input.spend.allowance);
23
+ const perTurn = managedSpendUnitWeight(rungAt);
24
+ const remaining = Math.max(0, input.spend.allowance - input.unitsUsed);
25
+ const rung = Math.min(rungAt, SPEND_STATES.length - 1);
26
+ return {
27
+ ...SPEND_STATES[rung],
28
+ turnsRemaining: perTurn > 0 ? Math.floor(remaining / perTurn) : 0,
29
+ };
30
+ }
31
+ /**
32
+ * Project one embed row into that view. Pure on purpose: the caller owns the reads (active
33
+ * deployment, counter peeks) and this owns the shape, so the projection stays testable without a
34
+ * registry or a counter store.
35
+ */
36
+ export async function assistantEmbedOperatorView(input) {
37
+ const { record, surface, envelope, spend } = input;
38
+ return {
39
+ embedId: record.embedId,
40
+ surfaceMode: surface?.mode ?? record.surfaceMode,
41
+ origins: surface?.origins ?? [],
42
+ capabilities: surface?.capabilities.map((entry) => entry.name) ?? [],
43
+ turnsPerDay: envelope.turnsPerDay,
44
+ mintsPerDay: envelope.mintsPerDay,
45
+ budgetIsDefault: record.turnsPerDay === undefined && record.mintsPerDay === undefined,
46
+ turnsToday: await input.peek(`turns:${record.embedId}`),
47
+ mintsToday: await input.peek(`mints:${record.embedId}`),
48
+ createdAt: record.createdAt.toISOString(),
49
+ ...(spend === undefined
50
+ ? {}
51
+ : {
52
+ managedSpend: assistantManagedSpendView({
53
+ spend,
54
+ unitsUsed: await input.peek(spend.key),
55
+ }),
56
+ }),
57
+ };
58
+ }
59
+ //# sourceMappingURL=embed-operator-view.js.map
@@ -31,6 +31,13 @@ export interface PublicEmbedRecord {
31
31
  */
32
32
  readonly turnsPerDay?: number;
33
33
  readonly mintsPerDay?: number;
34
+ /**
35
+ * Operator overrides for the per-address abuse bounds. Rarely needed — tier 4 handles fairness and
36
+ * these only have to stop a single machine — but an operator who knows their own traffic shape
37
+ * (an internal tool behind one proxy, say) can lower them, and lowering is all anyone may do.
38
+ */
39
+ readonly mintsPerAddressHour?: number;
40
+ readonly turnsPerAddressHour?: number;
34
41
  }
35
42
  /** Project the non-secret embed coordinate onto the shared assistant tenant shape. */
36
43
  export declare function publicEmbedTenant(embed: PublicEmbedRecord): TenantRef;
@@ -38,6 +45,8 @@ export declare function publicEmbedTenant(embed: PublicEmbedRecord): TenantRef;
38
45
  export interface PublicEmbedBudget {
39
46
  readonly turnsPerDay?: number;
40
47
  readonly mintsPerDay?: number;
48
+ readonly mintsPerAddressHour?: number;
49
+ readonly turnsPerAddressHour?: number;
41
50
  }
42
51
  export interface PublicEmbedStore {
43
52
  /**
@@ -73,4 +82,22 @@ export interface PublicEmbedStore {
73
82
  export declare const PUBLIC_EMBED_ID_PREFIX = "pub_";
74
83
  export declare function isPublicEmbedId(value: string): boolean;
75
84
  import type { TenantRef } from './tenant-ref.js';
85
+ /**
86
+ * Validate an operator's requested caps.
87
+ *
88
+ * Lives with the budget type rather than with the route that receives it: the rules are about what a
89
+ * cap may be, not about HTTP, and a second caller must not be able to reach the store with a shape
90
+ * this would have rejected.
91
+ */
92
+ export type BudgetField = 'turnsPerDay' | 'mintsPerDay' | 'mintsPerAddressHour' | 'turnsPerAddressHour';
93
+ type BudgetValue = Partial<Record<BudgetField, number>>;
94
+ type ParsedBudget = {
95
+ readonly ok: true;
96
+ readonly value: BudgetValue;
97
+ } | {
98
+ readonly ok: false;
99
+ readonly error: string;
100
+ };
101
+ export declare function parsePublicEmbedBudget(body: unknown): ParsedBudget;
102
+ export {};
76
103
  //# sourceMappingURL=embed-store.d.ts.map
@@ -23,4 +23,39 @@ export const PUBLIC_EMBED_ID_PREFIX = 'pub_';
23
23
  export function isPublicEmbedId(value) {
24
24
  return new RegExp(`^${PUBLIC_EMBED_ID_PREFIX}[0-9a-z]{20,32}$`).test(value);
25
25
  }
26
+ const BUDGET_FIELDS = [
27
+ 'turnsPerDay',
28
+ 'mintsPerDay',
29
+ 'mintsPerAddressHour',
30
+ 'turnsPerAddressHour',
31
+ ];
32
+ export function parsePublicEmbedBudget(body) {
33
+ // A JSON body of `null` is an object to `typeof` and reads as a crash to a property access, so it
34
+ // is checked by name: an operator sending nonsense deserves the 400 this returns, not the 500 an
35
+ // uncaught `TypeError` would become.
36
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) {
37
+ return { ok: false, error: 'body must be a JSON object' };
38
+ }
39
+ const supplied = body;
40
+ const value = {};
41
+ for (const [name, raw] of BUDGET_FIELDS.map((field) => [field, supplied[field]])) {
42
+ if (raw === undefined)
43
+ continue;
44
+ // Zero is valid and is the point; anything negative or fractional is a mistake worth saying out
45
+ // loud rather than silently flooring, because an operator setting a cap is being deliberate.
46
+ // `isSafeInteger` rather than `isInteger`: past 2^53 a JSON number has already lost the value
47
+ // the operator typed, and it reaches a `::bigint` column that would store the rounded one.
48
+ if (typeof raw !== 'number' || !Number.isSafeInteger(raw) || raw < 0) {
49
+ return { ok: false, error: `"${name}" must be a non-negative integer` };
50
+ }
51
+ value[name] = raw;
52
+ }
53
+ if (Object.keys(value).length === 0) {
54
+ return {
55
+ ok: false,
56
+ error: `set at least one of ${BUDGET_FIELDS.map((f) => `"${f}"`).join(', ')}`,
57
+ };
58
+ }
59
+ return { ok: true, value };
60
+ }
26
61
  //# sourceMappingURL=embed-store.js.map
@@ -39,6 +39,12 @@ export class InMemoryPublicEmbedStore {
39
39
  ...record,
40
40
  ...(budget.turnsPerDay !== undefined ? { turnsPerDay: budget.turnsPerDay } : {}),
41
41
  ...(budget.mintsPerDay !== undefined ? { mintsPerDay: budget.mintsPerDay } : {}),
42
+ ...(budget.mintsPerAddressHour !== undefined
43
+ ? { mintsPerAddressHour: budget.mintsPerAddressHour }
44
+ : {}),
45
+ ...(budget.turnsPerAddressHour !== undefined
46
+ ? { turnsPerAddressHour: budget.turnsPerAddressHour }
47
+ : {}),
42
48
  };
43
49
  this.#byId.set(embedId, updated);
44
50
  return updated;
@@ -0,0 +1,80 @@
1
+ import type { AdmissionEnvelope } from '@noodle-borg/admission-limits/portable';
2
+ import type { AssistantModelRequestPolicy } from './model-request.js';
3
+ import type { SurfaceBudgetBounds } from './surface-budget.js';
4
+ /**
5
+ * Cost breakers for the sponsored managed-assistant beta (ADR 0213 amendment §3).
6
+ *
7
+ * Noodle pays for `noodle-managed` inference and charges nothing for it, so aggregate spend is a
8
+ * platform expense with no customer-side limit standing behind it. Per-surface daily caps bound one
9
+ * surface; they do not bound how many surfaces enrol. This is the missing ceiling, expressed as a
10
+ * ladder rather than a switch: a tenant approaching its allowance gets shorter answers before it gets
11
+ * a closed door, and the door itself is the kill switch that already exists.
12
+ *
13
+ * Two things make it small. Every rung is expressed through `SurfaceBudgetBounds` and
14
+ * `AssistantModelRequestPolicy`, which the resolver already returns and admission already reads live
15
+ * per turn — so degradation applies immediately and reverses on its own when the UTC day rolls, with
16
+ * no unwind path to write. And the unit is one delivered bounded turn, which is the billable unit ADR
17
+ * 0213 §12 already defines.
18
+ */
19
+ /**
20
+ * Billable tokens per charged unit. Prompt and completion are counted together and completion is
21
+ * weighted, because output costs several times input on every provider we have measured.
22
+ */
23
+ export declare const MANAGED_SPEND_UNIT_TOKENS = 4000;
24
+ export interface ManagedSpendPolicy {
25
+ readonly requestPolicy: AssistantModelRequestPolicy;
26
+ readonly admission: SurfaceBudgetBounds;
27
+ }
28
+ /**
29
+ * Which rung a tenant is on.
30
+ *
31
+ * An absent or nonsensical allowance means "unbudgeted", never "exhausted". Failing closed on a typo
32
+ * would take a customer's surface dark for a configuration mistake, and the per-surface daily cap
33
+ * still bounds what that costs.
34
+ */
35
+ export declare function managedSpendRung(unitsUsed: number, allowance: number): number;
36
+ export declare function managedSpendPolicy(rung: number): ManagedSpendPolicy;
37
+ /**
38
+ * Units one turn costs at this rung: its worst case under the rung's own policy, in whole units.
39
+ *
40
+ * Prompt-inclusive on purpose. `maxCompletionTokens` says nothing about how large a prompt the agent
41
+ * loop may resend on each step, and the prompt is where an unbounded turn actually spends.
42
+ */
43
+ export declare function managedSpendUnitWeight(rung: number): number;
44
+ /**
45
+ * The unit ledger for a day's worth of ordinary turns.
46
+ *
47
+ * Units are worst-case, so a turn is charged what its rung's policy *permits* rather than what it
48
+ * happened to use. That is deliberate — the alternative under-charges exactly the adversary the cap
49
+ * exists for — but it means the number is only meaningful denominated in turns. An allowance is
50
+ * therefore configured as "this many sponsored turns a day" and converted here, which is also the
51
+ * form the operator surface reports.
52
+ *
53
+ * The conservatism is uniform within a rung, so it does not distort when the ladder trips; across
54
+ * rungs it is the intended effect, since a degraded turn genuinely costs less and the same allowance
55
+ * stretches further.
56
+ */
57
+ export declare function managedSpendAllowanceForTurns(turnsPerDay: number): number;
58
+ /**
59
+ * Is the ledger telling the truth?
60
+ *
61
+ * A unit weight is a claim about what a turn's own rung permits. If the rung's `requestPolicy` does
62
+ * not actually bound what it says, the claim is fiction and the whole cap is decorative — and the
63
+ * failure is silent, because a cheap-looking ledger is exactly what an unbounded turn produces.
64
+ *
65
+ * So telemetry checks the counter rather than replacing it. Observed billable tokens per delivered
66
+ * turn must stay under what rung 0 permits, since rung 0 is the most permissive rung any turn could
67
+ * have run at. Exceeding it is not a budgeting question; it means an enforcer is not enforcing.
68
+ */
69
+ export declare function managedSpendDrift(observed: {
70
+ readonly deliveredTurns: number;
71
+ readonly promptTokens: number;
72
+ readonly completionTokens: number;
73
+ }): {
74
+ readonly observedPerTurn: number;
75
+ readonly permittedPerTurn: number;
76
+ readonly exceeded: boolean;
77
+ };
78
+ /** Convenience for readers that only care whether a tenant is being held back at all. */
79
+ export declare function isManagedSpendDegraded(envelope: Pick<AdmissionEnvelope, 'turnsPerDay'>): boolean;
80
+ //# sourceMappingURL=managed-spend.d.ts.map
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Cost breakers for the sponsored managed-assistant beta (ADR 0213 amendment §3).
3
+ *
4
+ * Noodle pays for `noodle-managed` inference and charges nothing for it, so aggregate spend is a
5
+ * platform expense with no customer-side limit standing behind it. Per-surface daily caps bound one
6
+ * surface; they do not bound how many surfaces enrol. This is the missing ceiling, expressed as a
7
+ * ladder rather than a switch: a tenant approaching its allowance gets shorter answers before it gets
8
+ * a closed door, and the door itself is the kill switch that already exists.
9
+ *
10
+ * Two things make it small. Every rung is expressed through `SurfaceBudgetBounds` and
11
+ * `AssistantModelRequestPolicy`, which the resolver already returns and admission already reads live
12
+ * per turn — so degradation applies immediately and reverses on its own when the UTC day rolls, with
13
+ * no unwind path to write. And the unit is one delivered bounded turn, which is the billable unit ADR
14
+ * 0213 §12 already defines.
15
+ */
16
+ /**
17
+ * Billable tokens per charged unit. Prompt and completion are counted together and completion is
18
+ * weighted, because output costs several times input on every provider we have measured.
19
+ */
20
+ export const MANAGED_SPEND_UNIT_TOKENS = 4_000;
21
+ const OUTPUT_WEIGHT = 8;
22
+ const BYTES_PER_TOKEN = 4;
23
+ /**
24
+ * The rungs, tightest-claim-first.
25
+ *
26
+ * Rung 0 is not "today's policy": a weight is only honest if the rung's own policy enforces it, and
27
+ * an unbounded prompt makes the worst case forty times the typical turn. Bounding the request keeps
28
+ * the ledger true. These numbers are the starting point and are expected to move once measured P99
29
+ * prompt sizes are read off the live surface — that measurement is a gate on raising the ceilings,
30
+ * not on shipping the breaker.
31
+ */
32
+ const RUNGS = [
33
+ {
34
+ requestPolicy: {
35
+ maxRequestBytes: 32 * 1_024,
36
+ maxCompletionTokens: 1_500,
37
+ maxTokensPerTurn: 3_000,
38
+ maxModelStepsPerTurn: 6,
39
+ maxToolCallsPerTurn: 8,
40
+ },
41
+ admission: {
42
+ defaults: { turnsPerSession: 40, turnsPerDay: 20_000, mintsPerDay: 5_000 },
43
+ ceiling: { turnsPerDay: 20_000, mintsPerDay: 5_000 },
44
+ },
45
+ },
46
+ {
47
+ // Shorter answers. Nothing is announced: telling a stranger on a customer's marketing site that
48
+ // it is on a reduced tier harms the customer and helps no one, and a shorter answer explains
49
+ // itself.
50
+ requestPolicy: {
51
+ maxRequestBytes: 24 * 1_024,
52
+ maxCompletionTokens: 900,
53
+ maxTokensPerTurn: 1_800,
54
+ maxModelStepsPerTurn: 4,
55
+ maxToolCallsPerTurn: 6,
56
+ },
57
+ admission: {
58
+ defaults: { turnsPerSession: 40, turnsPerDay: 20_000, mintsPerDay: 5_000 },
59
+ ceiling: { turnsPerDay: 20_000, mintsPerDay: 5_000 },
60
+ },
61
+ },
62
+ {
63
+ // Shorter conversations. This does not lower a turn's worst case — `maxRequestBytes` already caps
64
+ // that — it lowers the typical prompt by cutting history growth, which stretches the remaining
65
+ // allowance without shortening any individual answer. The visitor meets
66
+ // `session_turn_budget_exhausted`, which the widget already renders as its existing "new
67
+ // conversation" affordance.
68
+ requestPolicy: {
69
+ maxRequestBytes: 16 * 1_024,
70
+ maxCompletionTokens: 700,
71
+ maxTokensPerTurn: 1_400,
72
+ maxModelStepsPerTurn: 3,
73
+ maxToolCallsPerTurn: 4,
74
+ },
75
+ admission: {
76
+ defaults: { turnsPerSession: 8, turnsPerDay: 20_000, mintsPerDay: 5_000 },
77
+ ceiling: { turnsPerDay: 20_000, mintsPerDay: 5_000 },
78
+ },
79
+ },
80
+ {
81
+ // No tools. `projectAssistantGuide` projects the per-turn tool set, so the guide never promises a
82
+ // tool that is not offered; the assistant simply answers from knowledge and its own words.
83
+ requestPolicy: {
84
+ maxRequestBytes: 12 * 1_024,
85
+ maxCompletionTokens: 400,
86
+ maxTokensPerTurn: 800,
87
+ maxModelStepsPerTurn: 1,
88
+ maxToolCallsPerTurn: 0,
89
+ },
90
+ admission: {
91
+ defaults: { turnsPerSession: 8, turnsPerDay: 20_000, mintsPerDay: 5_000 },
92
+ ceiling: { turnsPerDay: 20_000, mintsPerDay: 5_000 },
93
+ },
94
+ },
95
+ {
96
+ // At cap. Deliberately the *existing* kill switch and not a new failure mode: zero ceilings refuse
97
+ // at admission with `daily_turn_budget_exhausted`, a code every published widget already treats as
98
+ // final. A new code would be read as retryable by every widget predating it, and they would hammer
99
+ // the endpoint this cap exists to protect.
100
+ requestPolicy: {
101
+ maxRequestBytes: 12 * 1_024,
102
+ maxCompletionTokens: 400,
103
+ maxTokensPerTurn: 800,
104
+ maxModelStepsPerTurn: 1,
105
+ maxToolCallsPerTurn: 0,
106
+ },
107
+ admission: {
108
+ defaults: { turnsPerSession: 8, turnsPerDay: 0, mintsPerDay: 0 },
109
+ ceiling: { turnsPerDay: 0, mintsPerDay: 0 },
110
+ },
111
+ },
112
+ ];
113
+ /** Fractions of the allowance at which each rung begins. */
114
+ const THRESHOLDS = [0.6, 0.8, 0.95, 1];
115
+ /**
116
+ * Which rung a tenant is on.
117
+ *
118
+ * An absent or nonsensical allowance means "unbudgeted", never "exhausted". Failing closed on a typo
119
+ * would take a customer's surface dark for a configuration mistake, and the per-surface daily cap
120
+ * still bounds what that costs.
121
+ */
122
+ export function managedSpendRung(unitsUsed, allowance) {
123
+ if (!Number.isFinite(allowance) || allowance <= 0)
124
+ return 0;
125
+ const share = Math.max(0, unitsUsed) / allowance;
126
+ return THRESHOLDS.filter((threshold) => share >= threshold).length;
127
+ }
128
+ export function managedSpendPolicy(rung) {
129
+ return RUNGS[Math.min(Math.max(Math.trunc(rung), 0), RUNGS.length - 1)];
130
+ }
131
+ /**
132
+ * Units one turn costs at this rung: its worst case under the rung's own policy, in whole units.
133
+ *
134
+ * Prompt-inclusive on purpose. `maxCompletionTokens` says nothing about how large a prompt the agent
135
+ * loop may resend on each step, and the prompt is where an unbounded turn actually spends.
136
+ */
137
+ export function managedSpendUnitWeight(rung) {
138
+ const { requestPolicy } = managedSpendPolicy(rung);
139
+ const prompt = ((requestPolicy.maxModelStepsPerTurn ?? 1) * (requestPolicy.maxRequestBytes ?? 0)) /
140
+ BYTES_PER_TOKEN;
141
+ const output = OUTPUT_WEIGHT * (requestPolicy.maxTokensPerTurn ?? 0);
142
+ return Math.max(1, Math.ceil((prompt + output) / MANAGED_SPEND_UNIT_TOKENS));
143
+ }
144
+ /**
145
+ * The unit ledger for a day's worth of ordinary turns.
146
+ *
147
+ * Units are worst-case, so a turn is charged what its rung's policy *permits* rather than what it
148
+ * happened to use. That is deliberate — the alternative under-charges exactly the adversary the cap
149
+ * exists for — but it means the number is only meaningful denominated in turns. An allowance is
150
+ * therefore configured as "this many sponsored turns a day" and converted here, which is also the
151
+ * form the operator surface reports.
152
+ *
153
+ * The conservatism is uniform within a rung, so it does not distort when the ladder trips; across
154
+ * rungs it is the intended effect, since a degraded turn genuinely costs less and the same allowance
155
+ * stretches further.
156
+ */
157
+ export function managedSpendAllowanceForTurns(turnsPerDay) {
158
+ if (!Number.isFinite(turnsPerDay) || turnsPerDay <= 0)
159
+ return 0;
160
+ return Math.ceil(turnsPerDay) * managedSpendUnitWeight(0);
161
+ }
162
+ /**
163
+ * Is the ledger telling the truth?
164
+ *
165
+ * A unit weight is a claim about what a turn's own rung permits. If the rung's `requestPolicy` does
166
+ * not actually bound what it says, the claim is fiction and the whole cap is decorative — and the
167
+ * failure is silent, because a cheap-looking ledger is exactly what an unbounded turn produces.
168
+ *
169
+ * So telemetry checks the counter rather than replacing it. Observed billable tokens per delivered
170
+ * turn must stay under what rung 0 permits, since rung 0 is the most permissive rung any turn could
171
+ * have run at. Exceeding it is not a budgeting question; it means an enforcer is not enforcing.
172
+ */
173
+ export function managedSpendDrift(observed) {
174
+ const permittedPerTurn = managedSpendUnitWeight(0) * MANAGED_SPEND_UNIT_TOKENS;
175
+ if (observed.deliveredTurns <= 0) {
176
+ return { observedPerTurn: 0, permittedPerTurn, exceeded: false };
177
+ }
178
+ const billable = observed.promptTokens + OUTPUT_WEIGHT * observed.completionTokens;
179
+ const observedPerTurn = Math.round(billable / observed.deliveredTurns);
180
+ return { observedPerTurn, permittedPerTurn, exceeded: observedPerTurn > permittedPerTurn };
181
+ }
182
+ /** Convenience for readers that only care whether a tenant is being held back at all. */
183
+ export function isManagedSpendDegraded(envelope) {
184
+ return envelope.turnsPerDay === 0;
185
+ }
186
+ //# sourceMappingURL=managed-spend.js.map
@@ -5,6 +5,12 @@ export interface AssistantModelRequestPolicy {
5
5
  readonly maxCompletionTokens?: number;
6
6
  readonly maxTokensPerTurn?: number;
7
7
  readonly maxRequestBytes?: number;
8
+ /**
9
+ * Per-tenant agent-loop bounds. The deployment-wide admission envelope still wins whenever it is
10
+ * lower — these narrow a turn for one tenant, they never widen it for anyone.
11
+ */
12
+ readonly maxModelStepsPerTurn?: number;
13
+ readonly maxToolCallsPerTurn?: number;
8
14
  readonly timeoutMs?: number;
9
15
  readonly maxTurnMs?: number;
10
16
  /** Trusted operator-selected OpenAI-compatible request extensions. */
@@ -26,6 +32,16 @@ interface ResolvedAssistantModelBase {
26
32
  readonly turnsPerDay: number;
27
33
  readonly mintsPerDay: number;
28
34
  };
35
+ /**
36
+ * Platform spend accounting, present only when someone other than the customer is paying. Passed
37
+ * through to `SurfaceBudgetBounds` unchanged, which is why the shape is declared identically
38
+ * here rather than imported — the two contracts stay independent by design.
39
+ */
40
+ readonly spend?: {
41
+ readonly key: string;
42
+ readonly units: number;
43
+ readonly allowance: number;
44
+ };
29
45
  };
30
46
  }
31
47
  export type ResolvedAssistantModel = ResolvedAssistantModelBase & ({
@@ -17,11 +17,13 @@ export * from './assistant-transcript.js';
17
17
  export * from './assistant-view-availability.js';
18
18
  export * from './elevation.js';
19
19
  export * from './elevation-store.js';
20
+ export * from './embed-operator-view.js';
20
21
  export * from './embed-script.js';
21
22
  export * from './embed-store.js';
22
23
  export * from './in-memory-assistant-appearance-store.js';
23
24
  export * from './in-memory-elevation-store.js';
24
25
  export * from './in-memory-embed-store.js';
26
+ export * from './managed-spend.js';
25
27
  export * from './public-configuration.js';
26
28
  export * from './public-session.js';
27
29
  export * from './public-surface.js';
@@ -17,11 +17,13 @@ export * from './assistant-transcript.js';
17
17
  export * from './assistant-view-availability.js';
18
18
  export * from './elevation.js';
19
19
  export * from './elevation-store.js';
20
+ export * from './embed-operator-view.js';
20
21
  export * from './embed-script.js';
21
22
  export * from './embed-store.js';
22
23
  export * from './in-memory-assistant-appearance-store.js';
23
24
  export * from './in-memory-elevation-store.js';
24
25
  export * from './in-memory-embed-store.js';
26
+ export * from './managed-spend.js';
25
27
  export * from './public-configuration.js';
26
28
  export * from './public-session.js';
27
29
  export * from './public-surface.js';
@@ -1,4 +1,4 @@
1
- import type { AdmissionEnvelope, DailyCounterStore } from '@noodle-borg/admission-limits/portable';
1
+ import { type AdmissionEnvelope, type DailyCounterStore } from '@noodle-borg/admission-limits/portable';
2
2
  import { type PublicEmbedRecord, type PublicEmbedStore } from './embed-store.js';
3
3
  import type { PublicSurface } from './public-surface.js';
4
4
  import { type SurfaceBudgetBounds } from './surface-budget.js';
@@ -43,6 +43,13 @@ export interface PublicSessionRequest {
43
43
  * be parsed, in which case the surface tier alone applies: fairness degrades, solvency does not.
44
44
  */
45
45
  readonly addressBucket?: string | undefined;
46
+ /**
47
+ * The browser's own visitor identifier (admission tier 4), unvalidated exactly like `embedId`:
48
+ * it arrives from the request body, so the decision owns its parse rather than the transport.
49
+ * Client-supplied and therefore rotatable, so it bounds fairness and never abuse — the address
50
+ * tier underneath is the one that cannot be rotated.
51
+ */
52
+ readonly visitorId?: unknown;
46
53
  }
47
54
  export type PublicSessionResult = {
48
55
  readonly ok: true;
@@ -54,6 +61,12 @@ export type PublicSessionResult = {
54
61
  readonly status: number;
55
62
  readonly code: string;
56
63
  readonly message: string;
64
+ /**
65
+ * The surface this refusal belongs to, once it is known. Present for every refusal an operator
66
+ * needs to see — out of budget, address at its ceiling, origin off the live allowlist — and
67
+ * absent for the ones that cannot be attributed to a tenant at all, which are prober noise.
68
+ */
69
+ readonly embed?: PublicEmbedRecord;
57
70
  };
58
71
  export declare function mintPublicSession(request: PublicSessionRequest, envelope: AdmissionEnvelope, ports: PublicSessionPorts): Promise<PublicSessionResult>;
59
72
  //# sourceMappingURL=public-session.d.ts.map