@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.
- package/dist/index.cjs +99 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +854 -557
- package/dist/index.d.ts +854 -557
- package/dist/index.js +92 -12
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/credit-identifiers.test.ts +133 -0
- package/src/__tests__/image-pricing-catalog-coverage.test.ts +139 -0
- package/src/__tests__/normalize-node-params.test.ts +36 -0
- package/src/__tests__/organizations-types.test.ts +29 -1
- package/src/__tests__/prompt-length-limits.test.ts +36 -0
- package/src/__tests__/suno-credit-type.test.ts +54 -0
- package/src/__tests__/video-analysis-brief.test.ts +61 -0
- package/src/credit-identifiers.ts +149 -12
- package/src/index.ts +8 -0
- package/src/model-constants.ts +69 -9
- package/src/normalize-node-params.ts +8 -0
- package/src/organizations/types.ts +12 -0
- package/src/organizations/views.ts +114 -0
- package/src/video-analysis.ts +45 -0
|
@@ -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
|
-
|
|
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"
|
package/src/model-constants.ts
CHANGED
|
@@ -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).
|
|
79
|
-
*
|
|
80
|
-
* {@link getMaxSunoPromptChars} (
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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
|
|
242
|
-
* flat
|
|
243
|
-
*
|
|
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
|
+
}
|
package/src/video-analysis.ts
CHANGED
|
@@ -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
|
+
}
|