@nodaro/shared 2.19.0 → 2.20.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.
@@ -28,7 +28,7 @@ import {
28
28
  getVideoAudioCapability,
29
29
  } from "./model-constants.js"
30
30
  import { isFlux2Model } from "./flux2-pricing.js"
31
- import { MODEL_CATALOG } from "./model-catalog.js"
31
+ import { MODEL_CATALOG, normalizeModelInput, type ModelInputAdjustment } from "./model-catalog.js"
32
32
 
33
33
  /**
34
34
  * Compute composite model identifier for variable credit pricing.
@@ -80,6 +80,90 @@ export function buildCreditModelIdentifier(
80
80
  return provider
81
81
  }
82
82
 
83
+ /**
84
+ * An image request's catalog-snapped parameters PLUS the credit identifier
85
+ * priced off them.
86
+ *
87
+ * `adjustments` is the disclosure channel: every lever this changed, why, and
88
+ * to what. Routes return it in the 200 body so a caller learns their value was
89
+ * corrected instead of silently getting something else.
90
+ */
91
+ export interface NormalizedImageGen {
92
+ /** Credit identifier, priced off the SNAPPED `resolution` / `quality`. */
93
+ identifier: string
94
+ /** The catalog model id the params were snapped against (post T2I→I2I swap). */
95
+ modelId: string
96
+ aspectRatio?: string
97
+ resolution?: string
98
+ quality?: string
99
+ /** Empty when the caller's values were already valid for `modelId`. */
100
+ adjustments: ModelInputAdjustment[]
101
+ }
102
+
103
+ /**
104
+ * Snap an image request's catalog-governed levers to a combination the model
105
+ * actually accepts, and price the credit identifier off the SNAPPED values.
106
+ *
107
+ * WHY THE SNAP LIVES HERE and not at the call site. Both image routes compute
108
+ * their credit identifier TWICE — once in the `creditGuard` preHandler (the
109
+ * CHECK) and once at the handler's `reserveCreditsForJob` (the DEBIT) — and a
110
+ * dedicated test asserts the two stay byte-identical (see
111
+ * `backend/src/routes/__tests__/generate-image.test.ts`, "CHECK === DEBIT").
112
+ * `resolution` and `quality` are pricing dimensions, so a snap applied at only
113
+ * one of those sites breaks that invariant, and `commit_credits` never collects
114
+ * an upward delta — the reserve IS the charge. Putting the snap inside the
115
+ * primitive makes CHECK, DEBIT and the workflow orchestrator agree by
116
+ * construction rather than by everyone remembering to call a normalizer.
117
+ *
118
+ * Inputs are typed `unknown` on purpose: the preHandler runs BEFORE Zod, so a
119
+ * caller can put a number or an object in `resolution`. Non-strings become
120
+ * `undefined` rather than reaching `normalizeModelInput` as a lie.
121
+ *
122
+ * Unknown model ids pass through untouched (same contract as
123
+ * `normalizeModelInput`) — the route's provider enum is the gate for those.
124
+ */
125
+ export function resolveNormalizedImageGen(opts: {
126
+ provider: string | undefined
127
+ aspectRatio?: unknown
128
+ quality?: unknown
129
+ resolution?: unknown
130
+ renderingSpeed?: unknown
131
+ refCount: number
132
+ swapToI2i?: boolean
133
+ }): NormalizedImageGen {
134
+ const str = (v: unknown): string | undefined =>
135
+ typeof v === "string" && v.length > 0 ? v : undefined
136
+
137
+ const provider = str(opts.provider) ?? "nano-banana"
138
+ // Same swap `resolveEffectiveProvider` applies in the route: refs attached to
139
+ // a bare T2I provider route the run to its i2i sibling, which has its OWN
140
+ // catalog entry and its own lever lists.
141
+ const modelId =
142
+ opts.swapToI2i && opts.refCount > 0 ? (T2I_TO_I2I_VARIANT[provider] ?? provider) : provider
143
+
144
+ const n = normalizeModelInput(modelId, {
145
+ aspectRatio: str(opts.aspectRatio),
146
+ resolution: str(opts.resolution),
147
+ quality: str(opts.quality),
148
+ })
149
+
150
+ return {
151
+ identifier: buildCreditModelIdentifier(
152
+ modelId,
153
+ n.quality,
154
+ n.resolution,
155
+ str(opts.renderingSpeed),
156
+ undefined,
157
+ opts.refCount,
158
+ ),
159
+ modelId,
160
+ aspectRatio: n.aspectRatio,
161
+ resolution: n.resolution,
162
+ quality: n.quality,
163
+ adjustments: n.adjustments,
164
+ }
165
+ }
166
+
83
167
  /**
84
168
  * Reference-aware image-generation credit identifier — the SINGLE source of
85
169
  * truth shared by the single-node routes (`/v1/generate-image`,
@@ -107,17 +191,7 @@ export function resolveImageGenCreditIdentifier(opts: {
107
191
  refCount: number
108
192
  swapToI2i?: boolean
109
193
  }): string {
110
- const provider = opts.provider || "nano-banana"
111
- const effectiveProvider =
112
- opts.swapToI2i && opts.refCount > 0 ? (T2I_TO_I2I_VARIANT[provider] ?? provider) : provider
113
- return buildCreditModelIdentifier(
114
- effectiveProvider,
115
- opts.quality,
116
- opts.resolution,
117
- opts.renderingSpeed,
118
- undefined,
119
- opts.refCount,
120
- )
194
+ return resolveNormalizedImageGen(opts).identifier
121
195
  }
122
196
 
123
197
  // T2V-specific credit overrides: some providers have different costs for T2V
@@ -365,3 +439,66 @@ export function buildMotionCreditModelIdentifier(
365
439
  const resSuffix = resolution === "1080p" ? ":1080p" : ""
366
440
  return `${base}${resSuffix}:${tier.suffix}`
367
441
  }
442
+
443
+ /**
444
+ * The Suno operations whose credit key depends on the model VERSION.
445
+ *
446
+ * `/v1/suno/generate`, `/cover` and `/extend` resolve their creditGuard
447
+ * identifier through `sunoCreditType(model, <operation>)` (routes/suno.ts
448
+ * :247-249, :335-337, :407-409). EVERY other Suno route charges a flat
449
+ * per-operation key and ignores the version the node carries — mashup (:648),
450
+ * add-instrumental (:852), add-vocals (:907), upload-extend (:1016),
451
+ * replace-section (:710), style-boost (:771), convert-wav (:962),
452
+ * lyrics (:482), music-video (:594), voice (:1218).
453
+ *
454
+ * Anything that QUOTES a Suno price — a dropdown row, a node badge, the
455
+ * workflow-credit estimator — must follow the same split, or it displays a
456
+ * credit key the route never charges.
457
+ */
458
+ export const SUNO_VERSION_PRICED_OPERATIONS = [
459
+ "suno-generate",
460
+ "suno-cover",
461
+ "suno-extend",
462
+ ] as const
463
+
464
+ /**
465
+ * The seven Suno operations that appear behind a select/dropdown UI (model
466
+ * pickers, node badges, the credit estimator) — the three version-priced
467
+ * operations above, plus the four flat-key operations exercised by
468
+ * `sunoCreditType`'s own test suite. A readonly tuple so later tasks (the
469
+ * model dropdowns, node badges) can iterate or count it without redeclaring
470
+ * the list and drifting from this one.
471
+ */
472
+ export const SUNO_SELECT_OPERATIONS = [
473
+ "suno-generate",
474
+ "suno-cover",
475
+ "suno-extend",
476
+ "suno-mashup",
477
+ "suno-add-instrumental",
478
+ "suno-add-vocals",
479
+ "suno-upload-extend",
480
+ ] as const
481
+
482
+ /**
483
+ * OUR Nodaro credit key for a Suno operation, given the model version and the
484
+ * operation (which is also the node type and the BullMQ job name).
485
+ *
486
+ * This is the single source of truth for the Suno pricing contract, shared by
487
+ * the reservation path (routes/suno.ts creditGuard), the egress seam
488
+ * (workers/handlers/suno.ts `modelKey`, which must match what was reserved),
489
+ * both workflow-credit estimators (ee/billing/credits.ts and the frontend
490
+ * config-panels/helpers.ts), the seven Suno model dropdowns and the three Suno
491
+ * node badges. It is a billing key — NEVER a KIE provider id — which is what
492
+ * satisfies the B3 egress invariant.
493
+ *
494
+ * V5_5 → "suno-v5_5", V5 → "suno-v5", any other version → the operation key.
495
+ * A non-version-priced operation is returned unchanged.
496
+ */
497
+ export function sunoCreditType(model: string | undefined, operation: string): string {
498
+ if (!(SUNO_VERSION_PRICED_OPERATIONS as readonly string[]).includes(operation)) {
499
+ return operation
500
+ }
501
+ if (model === "V5_5") return "suno-v5_5"
502
+ if (model === "V5") return "suno-v5"
503
+ return operation
504
+ }
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ export { usdToCredits, creditsToUsd, CREDIT_ROUNDING_RESOLUTION } from "./credit
18
18
  export {
19
19
  CREDIT_BASE_USD,
20
20
  IMAGE_PROMPT_MAX,
21
+ IMAGE_ASPECT_RATIO_VALUES,
21
22
  MAX_IMAGE_PROMPT_CHARS_BY_PROVIDER,
22
23
  getMaxImagePromptChars,
23
24
  PROMPT_HARD_CEILING,
@@ -35,6 +36,7 @@ export {
35
36
  getMaxSunoStyleChars,
36
37
  VIDEO_PROMPT_MAX,
37
38
  SUNO_TEXT_MAX,
39
+ SUNO_HARD_CEILING,
38
40
  NATIVE_NEGATIVE_PROMPT_MODELS,
39
41
  NATIVE_NEGATIVE_VIDEO_PROVIDERS,
40
42
  applyVideoNegativePrompt,
@@ -173,6 +175,7 @@ export { FEATURED_ENTITIES, getFeaturedEntities } from "./featured-entities.js"
173
175
  export type { FeaturedEntity } from "./featured-entities.js"
174
176
 
175
177
  export type {
178
+ ImageAspectRatio,
176
179
  ImageGenProvider,
177
180
  ImageMaskMode,
178
181
  ImageI2IProvider,
@@ -234,9 +237,14 @@ export type {
234
237
  export {
235
238
  buildCreditModelIdentifier,
236
239
  resolveImageGenCreditIdentifier,
240
+ resolveNormalizedImageGen,
237
241
  buildVideoCreditModelIdentifier,
238
242
  buildMotionCreditModelIdentifier,
243
+ sunoCreditType,
244
+ SUNO_VERSION_PRICED_OPERATIONS,
245
+ SUNO_SELECT_OPERATIONS,
239
246
  } from "./credit-identifiers.js"
247
+ export type { NormalizedImageGen } from "./credit-identifiers.js"
240
248
 
241
249
  export * from "./credit-estimators/index.js"
242
250
  export { extractVideoDurationFromNode } from "./video-duration.js"
@@ -13,6 +13,42 @@ export const CREDIT_BASE_USD = 0.002
13
13
  * schemas and the factory-preset guard test all read this. Prompt cap ONLY (not negativePrompt). */
14
14
  export const IMAGE_PROMPT_MAX = 5000
15
15
 
16
+ /**
17
+ * The ONE aspect-ratio vocabulary the image routes' Zod enums are built from —
18
+ * `/v1/generate-image`, `/v1/image-to-image` and `/v1/edit-image` all declare
19
+ * `z.enum(IMAGE_ASPECT_RATIO_VALUES)` instead of keeping three literal lists
20
+ * that drift.
21
+ *
22
+ * It is the UNION of every ratio any `kind: "image"` catalog entry declares, so
23
+ * a value the picker offers can never 400 at the route. That gap has shipped
24
+ * twice — Wan 2.7's ultra-wide `8:1`/`1:8` (fixed on generate-image only) and
25
+ * Nano Banana 2 Lite's banner `4:1`/`1:4` (still open until this tuple landed).
26
+ * `packages/shared/src/__tests__/image-pricing-catalog-coverage.test.ts` fails
27
+ * the build if a new model declares a ratio this tuple is missing.
28
+ *
29
+ * This enum is a VOCABULARY BOUND, not a per-model gate: it only stops a
30
+ * free-form string from reaching a provider as `image_size`. The per-model gate
31
+ * is the catalog snap (`resolveNormalizedImageGen` → `normalizeModelInput`),
32
+ * which CORRECTS an unsupported ratio and discloses it in the response's
33
+ * `adjustments` — never rejects. So widening this tuple is always safe: it
34
+ * defers a rejection to the correcting snap, which is the desired behaviour.
35
+ *
36
+ * `auto` is model-specific (GPT Image 2 / Nano Banana 2 Lite treat it as
37
+ * "native"); it lives here for the same reason as the rest — the snap drops or
38
+ * replaces it for models that do not declare it.
39
+ */
40
+ export const IMAGE_ASPECT_RATIO_VALUES = [
41
+ "auto",
42
+ "1:1", "16:9", "9:16", "4:3", "3:4",
43
+ "3:2", "2:3", "5:4", "4:5", "21:9",
44
+ // Ultra-wide / ultra-tall banner ratios: Wan 2.7 + Wan 2.7 Pro (8:1, 1:8)
45
+ // and Nano Banana 2 Lite (4:1, 1:4, 8:1, 1:8).
46
+ "4:1", "1:4", "8:1", "1:8",
47
+ ] as const
48
+
49
+ /** A ratio string the image routes accept (pre-snap vocabulary, not a per-model guarantee). */
50
+ export type ImageAspectRatio = typeof IMAGE_ASPECT_RATIO_VALUES[number]
51
+
16
52
  /**
17
53
  * Per-provider maximum ASSEMBLED image-prompt length (chars), VERIFIED against
18
54
  * each model's official docs.kie.ai schema (2026-06). Providers absent here use
@@ -75,15 +111,37 @@ export const VIDEO_PROMPT_MAX = 8000
75
111
 
76
112
  /**
77
113
  * Suno prompt / lyrics / content ceiling — the LARGEST any Suno version accepts
78
- * in custom mode (V4.5 / V4.5PLUS / V4.5ALL / V5 / V5.5 = 5000). The route Zod
79
- * uses this as a generous ceiling; the handler clamps to the per-version cap via
80
- * {@link getMaxSunoPromptChars} (V4/V3.5 = 3000, non-custom = 500). Shared with
81
- * the editor `maxLength` / counter (warn-don't-block at the per-version cap).
82
- * `style` and `title` have their own caps ({@link getMaxSunoStyleChars} /
83
- * {@link SUNO_TITLE_MAX}).
114
+ * in custom mode (V4.5 / V4.5PLUS / V4.5ALL / V5 / V5.5 = 5000). NOT the route
115
+ * Zod bound — the routes bind at {@link SUNO_HARD_CEILING} and clamp to the
116
+ * per-version cap via {@link getMaxSunoPromptChars} (3000 non-custom for every
117
+ * version; in custom mode 3000 for V4/V3.5, 5000 for V4.5+/V5) before the job
118
+ * is built. This constant is shared with the editor `maxLength` / counter
119
+ * (warn-don't-block at the per-version cap). `style` and `title` have their
120
+ * own caps ({@link getMaxSunoStyleChars} / {@link SUNO_TITLE_MAX}).
84
121
  */
85
122
  export const SUNO_TEXT_MAX = 5000
86
123
 
124
+ /**
125
+ * Absolute ceiling for every Suno text field on the route Zod schemas
126
+ * (`prompt`, `lyrics`, `fullLyrics`, `content`, `userPrompt`).
127
+ *
128
+ * WARN-DON'T-BLOCK. The per-version caps ({@link getMaxSunoPromptChars} /
129
+ * {@link getMaxSunoStyleChars}) do the real work — the routes CLAMP to them
130
+ * before building the job. The Zod bound only exists to stop abuse, so it must
131
+ * stay generous: until 2026-09 the routes bounded these fields at
132
+ * {@link SUNO_TEXT_MAX} (5000), which meant a programmatically-set prompt (an
133
+ * agent, an app run, a FieldMapping) was hard-REJECTED with a 400 before the
134
+ * clamp could trim it — three app-report rows on 2026-08-19..31.
135
+ *
136
+ * DISTINCT FROM {@link PROMPT_HARD_CEILING} on purpose: that one is an
137
+ * image/video budget pinned to MAX_IMAGE/VIDEO_PROMPT_CHARS_BY_PROVIDER by its
138
+ * own drift guard. Coupling them would let one modality's limits move the
139
+ * other's. This one has a single invariant, enforced in
140
+ * `__tests__/prompt-length-limits.test.ts`: it MUST stay >= every value
141
+ * getMaxSunoPromptChars / getMaxSunoStyleChars can return.
142
+ */
143
+ export const SUNO_HARD_CEILING = 30000
144
+
87
145
  /**
88
146
  * Absolute ceiling for the `prompt` / `negativePrompt` fields on the image and
89
147
  * video routes' Zod schemas. The PER-MODEL limits below (and the editor warning)
@@ -238,9 +296,11 @@ export function getMaxTtsChars(provider: string | undefined): number {
238
296
  }
239
297
 
240
298
  /**
241
- * Suno per-version field caps (from docs.kie.ai/suno-api/generate-music). The old
242
- * flat {@link SUNO_TEXT_MAX} (3000) was simultaneously too low for V4.5+/V5
243
- * prompts (5000) and too high for `style` (1000) and `title` (80).
299
+ * Suno per-version field caps (from docs.kie.ai/suno-api/generate-music). The
300
+ * original flat 3000 cap was simultaneously too low for V4.5+/V5 prompts
301
+ * (5000) and too high for `style` (1000) and `title` (80). ({@link
302
+ * SUNO_TEXT_MAX} is 5000 today — the largest per-version prompt cap; the
303
+ * route Zod bound is {@link SUNO_HARD_CEILING}.)
244
304
  * - prompt / lyrics: 3000 in non-custom mode (all versions); in custom mode
245
305
  * 3000 for V4/V3.5 and 5000 for V4.5 / V4.5PLUS / V4.5ALL / V5 / V5.5.
246
306
  * - style: 200 for V4/V3.5, 1000 for V4.5+.
@@ -30,10 +30,18 @@ import { normalizeModelInput, type ModelInputAdjustment } from "./model-catalog.
30
30
  * through mode-dependent defaults (`"adaptive"` for Seedance/Hailuo, duration
31
31
  * composites tied to pricing) that this flat normalizer would flatten wrongly;
32
32
  * they get their own pass once those defaults are catalog-derived too.
33
+ *
34
+ * `modify-image` carries the same provider/aspectRatio/resolution/quality trio
35
+ * as `image-to-image` (it routes through the same worker), and `edit-image`
36
+ * carries provider + aspectRatio. `edit-image`'s `targetResolution` is an
37
+ * UPSCALE target, a different field this module never reads — so listing the
38
+ * type here heals its ratio without touching what it is priced on.
33
39
  */
34
40
  export const MODEL_PARAM_NODE_TYPES: ReadonlySet<string> = new Set([
35
41
  "generate-image",
36
42
  "image-to-image",
43
+ "modify-image",
44
+ "edit-image",
37
45
  ])
38
46
 
39
47
  export interface NodeParamAdjustment extends ModelInputAdjustment {
@@ -56,6 +56,16 @@ export type GrantedAccess = (typeof GRANTED_ACCESS)[number]
56
56
  export const SUBMISSION_STATUSES = ["submitted", "in_review", "returned", "approved"] as const
57
57
  export type SubmissionStatus = (typeof SUBMISSION_STATUSES)[number]
58
58
 
59
+ /**
60
+ * How a usage report is bucketed. `workspace` is org-scope only; `none`
61
+ * = flat rows. A runtime list, like `ORG_ROLES`, so the plugin route's Zod is
62
+ * `z.enum(USAGE_GROUP_BYS)` and the migration guard asserts the SQL
63
+ * `NOT IN (...)` list equals this minus `none` — one vocabulary, never three
64
+ * hand-copies that drift.
65
+ */
66
+ export const USAGE_GROUP_BYS = ["workspace", "member", "model", "day", "none"] as const
67
+ export type UsageGroupBy = (typeof USAGE_GROUP_BYS)[number]
68
+
59
69
  /**
60
70
  * Error codes the organization endpoints add to the standard envelope
61
71
  * (`{ error: { code, message } }`). Clients dispatch on the code, never on
@@ -80,6 +90,8 @@ export const ORG_ERROR_CODES = [
80
90
  "domain_not_allowed",
81
91
  "already_started",
82
92
  "collab_unavailable",
93
+ // A CSV usage export whose write-ahead audit row could not be written (503).
94
+ "audit_unavailable",
83
95
  // Organization, workspace and membership endpoints.
84
96
  "terms_required",
85
97
  "not_org_member",
@@ -4,6 +4,7 @@ import type {
4
4
  OrgRole,
5
5
  OrgSettings,
6
6
  OrgStatus,
7
+ UsageGroupBy,
7
8
  WorkspaceRole,
8
9
  WorkspaceSettings,
9
10
  } from "./types.js"
@@ -218,3 +219,116 @@ export interface OrgPage<T> {
218
219
  data: T[]
219
220
  nextCursor: string | null
220
221
  }
222
+
223
+ /**
224
+ * Usage reporting. What `GET /v1/orgs/:id/usage` and
225
+ * `GET /v1/workspaces/:id/usage` return. No cost/USD field appears anywhere —
226
+ * a report shows CREDITS a class or team spent, never the platform's own rates
227
+ * (pricing-leak class, guarded by organizations-types.test.ts and the
228
+ * migration guard).
229
+ */
230
+
231
+ /** One bucket of a usage report. Exactly one of workspace/member/model/day is set. */
232
+ export interface UsageReportRow {
233
+ key: string
234
+ workspace: { id: string; name: string | null; slug: string | null; archived: boolean } | null
235
+ member: { userId: string; displayName: string | null; email: string | null } | null
236
+ model: string | null
237
+ /** `YYYY-MM-DD` in the report's `tz`. */
238
+ day: string | null
239
+ runCount: number
240
+ appRunCount: number
241
+ /** Settled where known, the held reservation otherwise. = settledCredits + inFlightCredits. */
242
+ credits: number
243
+ settledCredits: number
244
+ inFlightCredits: number
245
+ inFlightRuns: number
246
+ }
247
+
248
+ /**
249
+ * A platform-absorbed line for one workspace, split by ORIGIN (never attributed
250
+ * to a member). Two ledgers share the `org_usage_variance` source: a
251
+ * `metered_overrun` (a metered run's overrun beyond the budget — it HAS a
252
+ * settled usage_logs counterpart) and an `app_markup` shortfall (an
253
+ * approved-app markup the budget could not cover — it has NO usage_logs row).
254
+ * `other` is a future/unrecognised origin.
255
+ */
256
+ export interface UsageVarianceRow {
257
+ workspace: { id: string; name: string | null; slug: string | null } | null
258
+ kind: "metered_overrun" | "app_markup" | "other"
259
+ credits: number
260
+ rowCount: number
261
+ }
262
+
263
+ /**
264
+ * Totals over the WHOLE window — every usage_logs row in [from, to] after the
265
+ * scope / userId / workspaceId narrowing — never over the returned `rows`.
266
+ * Unaffected by `truncated`; equals a `groupBy=day` report's column-wise sum.
267
+ */
268
+ export interface UsageReportTotals {
269
+ runCount: number
270
+ credits: number
271
+ settledCredits: number
272
+ inFlightCredits: number
273
+ /** Metered-overrun variance in the window — a run's overrun the platform absorbed. */
274
+ platformAbsorbedCredits: number
275
+ /** Approved-app markup shortfall the platform absorbed. It has NO usage_logs run, so it is not in the figures above. */
276
+ appMarkupAbsorbedCredits: number
277
+ /**
278
+ * settledCredits − platformAbsorbedCredits: the METERED settlement that
279
+ * reached the workspace budget(s). App markup charged to a budget (migration
280
+ * 352) is not a usage_logs row and is not included here; when a markup
281
+ * shortfall is absorbed this figure under-reports and may go negative — it is
282
+ * NOT `workspace_budgets.spent_credits`.
283
+ */
284
+ chargedToBudget: number
285
+ }
286
+
287
+ export interface UsageReport {
288
+ scope: "org" | "workspace"
289
+ scopeId: string
290
+ from: string
291
+ to: string
292
+ tz: string
293
+ groupBy: Exclude<UsageGroupBy, "none">
294
+ /** Present when a member's self-view or an admin's `?userId=` narrowed the report. */
295
+ userId: string | null
296
+ /** Present when an org report was narrowed to one workspace. */
297
+ workspaceId: string | null
298
+ rows: UsageReportRow[]
299
+ variance: UsageVarianceRow[]
300
+ totals: UsageReportTotals
301
+ /**
302
+ * True when more than 5000 buckets existed and the tail of `rows` was dropped
303
+ * — narrow the window. Only `rows` is incomplete; `totals` and `variance`
304
+ * cover the whole window regardless.
305
+ */
306
+ truncated: boolean
307
+ }
308
+
309
+ /** One usage_logs row as an organization sees it. No cost fields, ever. */
310
+ export interface UsageLogEntry {
311
+ id: string
312
+ createdAt: string
313
+ workspace: { id: string; name: string | null; slug: string | null } | null
314
+ member: { userId: string; displayName: string | null; email: string | null } | null
315
+ jobId: string | null
316
+ model: string
317
+ status: "reserved" | "committed"
318
+ creditsReserved: number
319
+ creditsSettled: number | null
320
+ credits: number
321
+ isAppRun: boolean
322
+ }
323
+
324
+ /** Query parameters shared by both usage routes (dates inclusive, IANA tz). */
325
+ export interface UsageQuery {
326
+ from?: string
327
+ to?: string
328
+ tz?: string
329
+ groupBy?: UsageGroupBy
330
+ workspaceId?: string
331
+ userId?: string
332
+ cursor?: string
333
+ limit?: number
334
+ }
@@ -633,3 +633,48 @@ export function inferMusicVideo(analysis: {
633
633
  }),
634
634
  )
635
635
  }
636
+
637
+ // ---------------------------------------------------------------------------
638
+ // The analysis as a BRIEF — the compact projection an LLM is handed
639
+ // ---------------------------------------------------------------------------
640
+
641
+ /** Top-level keys the analyzer derives AFTER the model's pass (merge
642
+ * diagnostics, folded cast looks). A reader drafting FROM the analysis — a
643
+ * production plan, a script — needs none of them. */
644
+ const DERIVED_ANALYSIS_TOP_KEYS: ReadonlySet<string> = new Set(["warnings", "variationFolds"])
645
+
646
+ /** Per-scene keys the validator computes from `visual` and the slot list. */
647
+ const DERIVED_ANALYSIS_SCENE_KEYS: ReadonlySet<string> = new Set(["visualResolved", "slotRefs", "oversized"])
648
+
649
+ const asRecord = (v: unknown): Record<string, unknown> | null =>
650
+ typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null
651
+
652
+ /**
653
+ * The analysis with its server-derived fields removed — the form an LLM is
654
+ * handed when the analysis IS the brief (Nodaro Studio's Director and its
655
+ * job-id loader). Drops `warnings` and `variationFolds` at the top and
656
+ * `visualResolved`, `slotRefs`, `oversized` on every scene; keeps
657
+ * `refImageUrl` (a downstream cast image) and everything else. Never mutates;
658
+ * a non-object input comes back as-is.
659
+ *
660
+ * ONE strip list, shared by the worker that composes the brief server-side
661
+ * (`llm-structured` jobs with a `videoUrl`) and the client that loads a
662
+ * finished analysis into a textarea, so the two can never drift.
663
+ */
664
+ export function stripDerivedAnalysisFields(json: unknown): unknown {
665
+ const doc = asRecord(json)
666
+ if (!doc) return json
667
+ const out: Record<string, unknown> = {}
668
+ for (const [key, value] of Object.entries(doc)) {
669
+ if (DERIVED_ANALYSIS_TOP_KEYS.has(key)) continue
670
+ out[key] =
671
+ key === "scenes" && Array.isArray(value)
672
+ ? value.map((scene) => {
673
+ const s = asRecord(scene)
674
+ if (!s) return scene
675
+ return Object.fromEntries(Object.entries(s).filter(([k]) => !DERIVED_ANALYSIS_SCENE_KEYS.has(k)))
676
+ })
677
+ : value
678
+ }
679
+ return out
680
+ }