@slatesvideo/shared 0.5.0 → 0.5.1

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.
@@ -15,23 +15,23 @@ export interface SlatesUserInfo {
15
15
  name: string;
16
16
  license_status: string;
17
17
  tier: string;
18
- credit_balance_cents: number;
19
- credit_balance_dollars: string;
18
+ credit_balance?: number;
19
+ credit_balance_cents?: number;
20
20
  };
21
21
  scopes: string[];
22
22
  }
23
23
  export interface CreditsBalance {
24
24
  success: boolean;
25
- credit_balance_cents: number;
26
- credit_balance_dollars: string;
25
+ credit_balance?: number;
26
+ credit_balance_cents?: number;
27
27
  }
28
28
  export interface ModelRegistryResponse {
29
29
  success: boolean;
30
- credit_markup: number;
30
+ credit_markup?: number;
31
31
  models: Array<{
32
32
  model: string;
33
- cost_cents: number;
34
- cost_dollars: string;
33
+ cost_credits?: number;
34
+ cost_cents?: number;
35
35
  }>;
36
36
  }
37
37
  //# sourceMappingURL=cloud.d.ts.map
@@ -28,6 +28,29 @@ function ok(data, text) {
28
28
  data,
29
29
  };
30
30
  }
31
+ // ── Credits (2026-07-07 re-denomination) ────────────────────────
32
+ // Costs are ABSTRACT CREDITS now, not dollars. The API's model registry
33
+ // returns `cost_credits` (with `cost_cents` kept as a legacy alias carrying
34
+ // the same credit value); balances the same. Read via creditCost(); display
35
+ // via fmtCredits(). The confirm gate fires above CONFIRM_CREDITS (≈ the old
36
+ // $0.50 gate at the 3¢/credit peg).
37
+ const CONFIRM_CREDITS = 17;
38
+ const CENTS_PER_CREDIT = 3; // peg: 1 credit = 3¢ billed = 2¢ COGS (mirror of slates-api)
39
+ function creditCost(m) {
40
+ if (!m)
41
+ return 0;
42
+ return m.cost_credits ?? m.cost_cents ?? 0;
43
+ }
44
+ function fmtCredits(credits) {
45
+ return `${Math.round(credits).toLocaleString('en-US')} credits`;
46
+ }
47
+ /** Convert a legacy desktop dollar estimate (COGS × markup) to billed credits,
48
+ * mirroring the server's toCredits(round(dollars × 100)). The desktop's
49
+ * generations.cost column still stores dollars, so status reads convert. */
50
+ function creditsFromDollars(dollars) {
51
+ const cents = Math.round(dollars * 100);
52
+ return cents <= 0 ? 0 : Math.max(1, Math.ceil(cents / CENTS_PER_CREDIT));
53
+ }
31
54
  // Shared describe-text for the background flag on every generate_* op.
32
55
  const BACKGROUND_DESCRIBE = 'Submit and return immediately with generationId(s) instead of blocking until the file is saved. ' +
33
56
  'Poll with slates_get_generation_status. Recommended for video (1-5 min renders).';
@@ -71,16 +94,17 @@ export const getMe = {
71
94
  };
72
95
  export const getCreditBalance = {
73
96
  id: 'slates_get_credit_balance',
74
- description: 'Current Slates credit balance, in cents and dollars. Call before any generation that costs credits.',
97
+ description: 'Current Slates credit balance (abstract credits never expire). Call before any generation that costs credits.',
75
98
  input: z.object({}).strict(),
76
99
  async run(_input, ctx) {
77
100
  const r = await ctx.cloud().get('/api/agent/credits/balance');
78
- return ok(r);
101
+ const credits = r.credit_balance ?? r.credit_balance_cents ?? 0;
102
+ return ok({ success: r.success, credit_balance: credits }, `Balance: ${fmtCredits(credits)}.`);
79
103
  },
80
104
  };
81
105
  export const listAvailableModels = {
82
106
  id: 'slates_list_available_models',
83
- description: 'Registry of generation model cost keys with per-call credit cost in cents, as a compact "key cents" table. Optional `filter` substring (e.g. "kling" or "nano-banana") keeps the result small — prefer it. For a single known model, slates_estimate_generation_cost is cheaper still.',
107
+ description: 'Registry of generation model cost keys with per-call credit cost, as a compact "key credits" table. Optional `filter` substring (e.g. "kling" or "nano-banana") keeps the result small — prefer it. For a single known model, slates_estimate_generation_cost is cheaper still.',
84
108
  input: z.object({
85
109
  filter: z.string().optional().describe('Substring match on the model key, e.g. "kling-v3" or "seedance"'),
86
110
  }),
@@ -91,21 +115,21 @@ export const listAvailableModels = {
91
115
  const q = input.filter.toLowerCase();
92
116
  models = models.filter((m) => m.model.toLowerCase().includes(q));
93
117
  }
94
- // Compact text table — "key cents" lines are ~5x denser than the raw
118
+ // Compact text table — "key credits" lines are ~5x denser than the raw
95
119
  // JSON registry (the full pretty-printed dump was an ~8k-token leak).
96
- const table = models.map((m) => `${m.model} ${m.cost_cents}`).join('\n');
120
+ const table = models.map((m) => `${m.model} ${creditCost(m)}`).join('\n');
97
121
  return {
98
- text: `${models.length} COST keys (cents per generation)${input.filter ? ` matching "${input.filter}"` : ''}. ` +
122
+ text: `${models.length} COST keys (credits per generation)${input.filter ? ` matching "${input.filter}"` : ''}. ` +
99
123
  `NOTE: these are billing keys for cost lookup ONLY — the \`model\` param on slates_generate_video takes a BASE id ` +
100
124
  `(kling-v3.0-std | kling-v3.0-pro | kling-v3.0-omni | seedance-2 | veo-3.1-fast | veo-3.1-standard) with duration/videoResolution as separate params:\n` +
101
125
  table,
102
- data: { count: models.length, credit_markup: r.credit_markup },
126
+ data: { count: models.length },
103
127
  };
104
128
  },
105
129
  };
106
130
  export const estimateGenerationCost = {
107
131
  id: 'slates_estimate_generation_cost',
108
- description: 'Pre-flight cost estimate. Call before any generate_* op so the user sees "this will cost N credits" up front. Takes the SAME base model ids as the generate ops (video: "seedance-2" + duration + videoResolution; image: "nano-banana-2" + resolution) — exact registry cost keys also work. Pairs with the >$0.50 confirm gate.',
132
+ description: 'Pre-flight cost estimate. Call before any generate_* op so the user sees "this will cost N credits" up front. Takes the SAME base model ids as the generate ops (video: "seedance-2" + duration + videoResolution; image: "nano-banana-2" + resolution) — exact registry cost keys also work. Pairs with the confirm gate.',
109
133
  input: z.object({
110
134
  model: z.string().describe('Base model id as passed to the generate op (e.g. "seedance-2", "kling-v3.0-std", "nano-banana-2") or an exact registry cost key ("nano-banana-2-2k", "seedance-2-1080p-8s")'),
111
135
  quantity: z.number().int().min(1).max(10).optional().describe('Number of generations (default 1)'),
@@ -119,7 +143,7 @@ export const estimateGenerationCost = {
119
143
  }),
120
144
  async run(input, ctx) {
121
145
  const registry = await ctx.cloud().get('/api/agent/models');
122
- const byKey = new Map(registry.models.map((m) => [m.model, m.cost_cents]));
146
+ const byKey = new Map(registry.models.map((m) => [m.model, creditCost(m)]));
123
147
  // 1) exact registry cost key
124
148
  let key = byKey.has(input.model) ? input.model : null;
125
149
  // 2) image base id + resolution (+ quality for gpt-image-2)
@@ -164,21 +188,20 @@ export const estimateGenerationCost = {
164
188
  });
165
189
  }
166
190
  }
167
- const cents = key != null ? byKey.get(key) : undefined;
168
- if (key == null || cents == null) {
191
+ const perCredits = key != null ? byKey.get(key) : undefined;
192
+ if (key == null || perCredits == null) {
169
193
  throw new Error(`Unknown model: ${input.model}. Pass a base id (${VIDEO_MODELS.join(' | ')} | nano-banana-2 | flux-2-max | seedream-5-lite) plus duration/resolution params, or use slates_list_available_models with a filter.`);
170
194
  }
171
195
  const qty = input.quantity ?? 1;
172
- const totalCents = cents * qty;
196
+ const totalCredits = perCredits * qty;
173
197
  return ok({
174
198
  model: input.model,
175
199
  cost_key: key,
176
200
  quantity: qty,
177
- cost_per_cents: cents,
178
- total_cents: totalCents,
179
- total_dollars: (totalCents / 100).toFixed(2),
180
- requires_confirm: totalCents > 50,
181
- });
201
+ cost_per_credits: perCredits,
202
+ total_credits: totalCredits,
203
+ requires_confirm: totalCredits > CONFIRM_CREDITS,
204
+ }, `${input.model}${qty > 1 ? ` ×${qty}` : ''}: ${fmtCredits(totalCredits)} (${key}).`);
182
205
  },
183
206
  };
184
207
  // ── Projects ────────────────────────────────────────────────────
@@ -573,7 +596,7 @@ export const generateCharacterSheets = {
573
596
  };
574
597
  export const generateEnvironmentPlate = {
575
598
  id: 'slates_generate_environment_plate',
576
- description: "Generate an environment's single clean establishing plate (Nano Banana 2, 2K) from an optional base image, and bind it as the environment's reference. THE real environment-building workflow — call right after slates_create_environment. Afterward, pass the plate asset id as environmentAssetIds to slates_generate_video (or referenceAssetIds to slates_generate_image) so the location stays consistent across shots. Cost ~$0.18.",
599
+ description: "Generate an environment's single clean establishing plate (Nano Banana 2, 2K) from an optional base image, and bind it as the environment's reference. THE real environment-building workflow — call right after slates_create_environment. Afterward, pass the plate asset id as environmentAssetIds to slates_generate_video (or referenceAssetIds to slates_generate_image) so the location stays consistent across shots. Cost ~6 credits.",
577
600
  input: z.object({
578
601
  environmentId: z.string().uuid(),
579
602
  projectId: z.string().uuid(),
@@ -765,7 +788,7 @@ export const generateImage = {
765
788
  referenceImageUrls: z.array(z.string().url()).max(14).optional().describe('Headless (no-projectId) nano-banana-2 only: up to 14 ref URLs. For projectId runs, upload refs with slates_upload_reference_image first. Always label each image\'s role in the prompt text.'),
766
789
  referenceAssetIds: z.array(z.string()).max(14).optional().describe("Project assets to use as reference/ingredient images — asset UUIDs or badge codes (\"IMG-A8\"); codes resolve against the project at call time. Requires projectId. For nano-banana-2 up to 14 refs; FLUX/Seedream route to their edit endpoints with lower per-model caps. Label each reference's role in the prompt text."),
767
790
  background: z.boolean().optional().describe(BACKGROUND_DESCRIBE),
768
- confirm: z.boolean().optional().describe('Set true to bypass the >$0.50 cost confirm gate.'),
791
+ confirm: z.boolean().optional().describe('Set true to bypass the confirm gate.'),
769
792
  }),
770
793
  async run(input, ctx) {
771
794
  // Clarification gate: aspectRatio + resolution must be deliberate.
@@ -852,26 +875,26 @@ export const generateImage = {
852
875
  const entry = registry.models.find((m) => m.model === costKey);
853
876
  if (!entry)
854
877
  throw new Error(`Model not in registry: ${costKey}`);
855
- const totalCents = entry.cost_cents * (input.count ?? 1);
878
+ const totalCents = creditCost(entry) * (input.count ?? 1);
856
879
  // Confirm gate. Fires on cost > $0.50, AND (look-first, mirroring
857
880
  // slates_generate_video) whenever reference assets are involved
858
881
  // regardless of cost — the LLM must see what it's referencing before
859
882
  // committing spend.
860
- if ((totalCents > 50 || referenceAssetIds.length > 0) && !input.confirm) {
883
+ if ((totalCents > CONFIRM_CREDITS || referenceAssetIds.length > 0) && !input.confirm) {
861
884
  if (referenceAssetIds.length === 0) {
862
885
  return ok({
863
886
  requires_confirm: true,
864
887
  model: costKey,
865
888
  estimated_cents: totalCents,
866
- estimated_dollars: (totalCents / 100).toFixed(2),
867
- message: 'Cost exceeds $0.50. Re-call with confirm=true to proceed, or pick a smaller resolution / count.',
889
+ estimated_credits: totalCents,
890
+ message: `Cost exceeds ${CONFIRM_CREDITS} credits. Re-call with confirm=true to proceed, or pick a smaller resolution / count.`,
868
891
  });
869
892
  }
870
893
  const previews = await previewAssets(ctx, referenceAssetIds.map((id) => ({ id, type: 'image', role: 'reference' })));
871
894
  const refLines = previews.map((p) => ` - ${p.role}: ${p.ref}`).join('\n');
872
895
  return {
873
896
  text: `Pre-flight for ${imageModel} (${costKey}): ` +
874
- `$${(totalCents / 100).toFixed(2)} (${totalCents}¢).` +
897
+ `${fmtCredits(totalCents)}.` +
875
898
  `\n\nReference images attached above:\n${refLines}\n\n` +
876
899
  `Review them against your prompt — every reference's role must be labeled in the prompt text. ` +
877
900
  `If the references suggest a different composition / style than the current prompt captures, REVISE the prompt before confirming. ` +
@@ -883,7 +906,7 @@ export const generateImage = {
883
906
  model: imageModel,
884
907
  variant: costKey,
885
908
  estimated_cents: totalCents,
886
- estimated_dollars: (totalCents / 100).toFixed(2),
909
+ estimated_credits: totalCents,
887
910
  references: previews.map((p) => ({
888
911
  role: p.role,
889
912
  ref: p.ref,
@@ -931,7 +954,7 @@ export const generateImage = {
931
954
  costKey,
932
955
  projectId: input.projectId,
933
956
  cost_cents: totalCents,
934
- cost_dollars: (totalCents / 100).toFixed(2),
957
+ cost_credits: totalCents,
935
958
  }, refEcho);
936
959
  }
937
960
  const assetList = result.assets
@@ -957,7 +980,7 @@ export const generateImage = {
957
980
  `The saved assets are in data.assets — re-generate only the missing count, don't redo the whole batch. ` +
958
981
  `Prompt: "${input.prompt.slice(0, 60)}${input.prompt.length > 60 ? '...' : ''}"`
959
982
  : `Generated ${assetList.length} image(s) into project ${input.projectId} ` +
960
- `for $${(totalCents / 100).toFixed(2)} (${totalCents}¢). ` +
983
+ `for ${fmtCredits(totalCents)}. ` +
961
984
  `Prompt: "${input.prompt.slice(0, 60)}${input.prompt.length > 60 ? '...' : ''}"` +
962
985
  (refEcho ? ` ${refEcho}` : ''),
963
986
  images,
@@ -968,7 +991,7 @@ export const generateImage = {
968
991
  aspectRatio: input.aspectRatio,
969
992
  resolution,
970
993
  cost_cents: totalCents,
971
- cost_dollars: (totalCents / 100).toFixed(2),
994
+ cost_credits: totalCents,
972
995
  // Compact refs only — the full rows (prompt/settings/paths) were a
973
996
  // multi-KB leak per generation and everything needed downstream is
974
997
  // the id/code/label.
@@ -1027,7 +1050,7 @@ export const generateImage = {
1027
1050
  images.push({ data: buf.toString('base64'), mimeType: mt });
1028
1051
  }
1029
1052
  return {
1030
- text: `Generated ${urls.length} image(s) for $${(totalCents / 100).toFixed(2)} (${totalCents}¢). ` +
1053
+ text: `Generated ${urls.length} image(s) for ${fmtCredits(totalCents)}. ` +
1031
1054
  `Prompt: "${input.prompt.slice(0, 60)}${input.prompt.length > 60 ? '...' : ''}"`,
1032
1055
  images,
1033
1056
  data: {
@@ -1037,7 +1060,7 @@ export const generateImage = {
1037
1060
  aspectRatio: input.aspectRatio,
1038
1061
  resolution,
1039
1062
  cost_cents: totalCents,
1040
- cost_dollars: (totalCents / 100).toFixed(2),
1063
+ cost_credits: totalCents,
1041
1064
  },
1042
1065
  };
1043
1066
  },
@@ -1076,7 +1099,7 @@ export const editImage = {
1076
1099
  resolution: z.enum(['1k', '2k', '3k', '4k']).optional().describe('3k (1440p class) is gpt-image-2 only; nano-banana-2-lite is 1k only.'),
1077
1100
  quality: z.enum(['medium', 'high']).optional().describe('gpt-image-2 only — quality tier (default medium).'),
1078
1101
  aspectRatio: z.string().optional(),
1079
- confirm: z.boolean().optional().describe('Set true to bypass the >$0.50 cost confirm gate.'),
1102
+ confirm: z.boolean().optional().describe('Set true to bypass the confirm gate.'),
1080
1103
  background: z.boolean().optional().describe(BACKGROUND_DESCRIBE),
1081
1104
  }),
1082
1105
  async run(input, ctx) {
@@ -1100,16 +1123,16 @@ export const editImage = {
1100
1123
  const entry = registry.models.find((m) => m.model === costKey);
1101
1124
  if (!entry)
1102
1125
  throw new Error(`Model not in registry: ${costKey}`);
1103
- const totalCents = entry.cost_cents;
1104
- if (totalCents > 50 && !input.confirm) {
1126
+ const totalCents = creditCost(entry);
1127
+ if (totalCents > CONFIRM_CREDITS && !input.confirm) {
1105
1128
  const sourceRef = await lookupAssetRef(desktop, input.sourceAssetId);
1106
1129
  return ok({
1107
1130
  requires_confirm: true,
1108
1131
  variant: costKey,
1109
1132
  estimated_cents: totalCents,
1110
- estimated_dollars: (totalCents / 100).toFixed(2),
1133
+ estimated_credits: totalCents,
1111
1134
  source_ref: sourceRef,
1112
- message: `Cost: $${(totalCents / 100).toFixed(2)} to edit ${sourceRef} with ${editModel} (${costKey}). ` +
1135
+ message: `Cost: ${fmtCredits(totalCents)} to edit ${sourceRef} with ${editModel} (${costKey}). ` +
1113
1136
  `Re-call with confirm=true after the user explicitly OKs the spend. ` +
1114
1137
  `When discussing with the user, refer to the source by its code (matches the gallery badge).`,
1115
1138
  });
@@ -1135,7 +1158,7 @@ export const editImage = {
1135
1158
  projectId: input.projectId,
1136
1159
  sourceAssetId: input.sourceAssetId,
1137
1160
  cost_cents: totalCents,
1138
- cost_dollars: (totalCents / 100).toFixed(2),
1161
+ cost_credits: totalCents,
1139
1162
  });
1140
1163
  }
1141
1164
  // Inline the edited result so the LLM sees whether the surgery landed —
@@ -1153,7 +1176,7 @@ export const editImage = {
1153
1176
  }
1154
1177
  return {
1155
1178
  text: `Edited image saved as a new asset in project ${input.projectId} ` +
1156
- `for $${(totalCents / 100).toFixed(2)} (${totalCents}¢) via ${editModel}. ` +
1179
+ `for ${fmtCredits(totalCents)} via ${editModel}. ` +
1157
1180
  `Edit: "${input.prompt.slice(0, 60)}${input.prompt.length > 60 ? '...' : ''}"`,
1158
1181
  images,
1159
1182
  data: {
@@ -1163,7 +1186,7 @@ export const editImage = {
1163
1186
  sourceAssetId: input.sourceAssetId,
1164
1187
  resolution,
1165
1188
  cost_cents: totalCents,
1166
- cost_dollars: (totalCents / 100).toFixed(2),
1189
+ cost_credits: totalCents,
1167
1190
  asset: result.asset,
1168
1191
  generationId: result.generationId,
1169
1192
  },
@@ -1345,7 +1368,7 @@ export const generateVideo = {
1345
1368
  realFaceConsent: z.boolean().optional().describe('MANDATORY with seedanceRealFace: set true ONLY after the user has explicitly confirmed they hold the rights/consent to this person\'s likeness and it doesn\'t impersonate or misrepresent them. The generation is refused without it. Public figures/celebrities fail on every route.'),
1346
1369
  negativePrompt: z.string().optional(),
1347
1370
  background: z.boolean().optional().describe(BACKGROUND_DESCRIBE),
1348
- confirm: z.boolean().optional().describe('Set true after explicit user OK to bypass the >$0.50 cost confirm gate (which fires for almost every video gen since they\'re expensive).'),
1371
+ confirm: z.boolean().optional().describe('Set true after explicit user OK to bypass the confirm gate (which fires for almost every video gen since they\'re expensive).'),
1349
1372
  }),
1350
1373
  async run(input, ctx) {
1351
1374
  // Resolve the model FIRST — forgiving normalization (cost keys, alias
@@ -1375,7 +1398,7 @@ export const generateVideo = {
1375
1398
  return ok({
1376
1399
  requires_clarification: true,
1377
1400
  missing: ['projectId'],
1378
- message: 'projectId is required for video generation. Use slates_list_projects to find one or slates_create_project to make a new one. Video gens cost $0.50-$2.00+ per call — they need to land in a project so the user sees the progress card and the result.',
1401
+ message: 'projectId is required for video generation. Use slates_list_projects to find one or slates_create_project to make a new one. Video gens cost tens to a few hundred credits per call — they need to land in a project so the user sees the progress card and the result.',
1379
1402
  });
1380
1403
  }
1381
1404
  if (!input.aspectRatio || !input.duration) {
@@ -1482,7 +1505,7 @@ export const generateVideo = {
1482
1505
  throw new Error(`Model variant not in registry: ${costKey}. ` +
1483
1506
  `Available video models: ${registry.models.filter((m) => m.model.startsWith('kling') || m.model.startsWith('veo') || m.model.startsWith('seedance')).map((m) => m.model).slice(0, 20).join(', ')}`);
1484
1507
  }
1485
- const totalCents = entry.cost_cents;
1508
+ const totalCents = creditCost(entry);
1486
1509
  // Pre-flight confirm gate. Fires when:
1487
1510
  // (a) cost > $0.50 (the cost gate), OR
1488
1511
  // (b) any reference assets are involved (the look-first gate)
@@ -1511,7 +1534,7 @@ export const generateVideo = {
1511
1534
  referenceRefs.push({ id, type: 'image', role: 'style' });
1512
1535
  }
1513
1536
  const hasReferences = referenceRefs.length > 0;
1514
- if ((totalCents > 50 || hasReferences) && !input.confirm) {
1537
+ if ((totalCents > CONFIRM_CREDITS || hasReferences) && !input.confirm) {
1515
1538
  const previews = hasReferences ? await previewAssets(ctx, referenceRefs) : [];
1516
1539
  const refLines = previews.map((p) => ` - ${p.role}: ${p.ref}`).join('\n');
1517
1540
  const refSummary = hasReferences
@@ -1521,7 +1544,7 @@ export const generateVideo = {
1521
1544
  const refImages = previews.flatMap((p) => p.images);
1522
1545
  return {
1523
1546
  text: `Pre-flight for ${input.duration}s ${input.model} (${costKey}): ` +
1524
- `$${(totalCents / 100).toFixed(2)} (${totalCents}¢).` +
1547
+ `${fmtCredits(totalCents)}.` +
1525
1548
  refSummary +
1526
1549
  `\n\nWhen ready, re-call slates_generate_video with confirm=true and the (possibly revised) prompt.`,
1527
1550
  images: refImages,
@@ -1530,7 +1553,7 @@ export const generateVideo = {
1530
1553
  model: input.model,
1531
1554
  variant: costKey,
1532
1555
  estimated_cents: totalCents,
1533
- estimated_dollars: (totalCents / 100).toFixed(2),
1556
+ estimated_credits: totalCents,
1534
1557
  references: previews.map((p) => ({
1535
1558
  role: p.role,
1536
1559
  ref: p.ref,
@@ -1581,7 +1604,7 @@ export const generateVideo = {
1581
1604
  variant: costKey,
1582
1605
  projectId: input.projectId,
1583
1606
  cost_cents: totalCents,
1584
- cost_dollars: (totalCents / 100).toFixed(2),
1607
+ cost_credits: totalCents,
1585
1608
  references: refInputs.map(({ ref, role }) => ({
1586
1609
  role,
1587
1610
  ...(resolvedRefs.get(ref) ?? { id: ref, code: null, label: null }),
@@ -1590,7 +1613,7 @@ export const generateVideo = {
1590
1613
  }
1591
1614
  return {
1592
1615
  text: `Generated ${input.duration}s ${input.model} video into project ${input.projectId} ` +
1593
- `for $${(totalCents / 100).toFixed(2)} (${totalCents}¢). ` +
1616
+ `for ${fmtCredits(totalCents)}. ` +
1594
1617
  `Prompt: "${input.prompt.slice(0, 60)}${input.prompt.length > 60 ? '...' : ''}"` +
1595
1618
  (refEcho ? ` ${refEcho}` : ''),
1596
1619
  data: {
@@ -1600,7 +1623,7 @@ export const generateVideo = {
1600
1623
  aspectRatio: input.aspectRatio,
1601
1624
  duration: input.duration,
1602
1625
  cost_cents: totalCents,
1603
- cost_dollars: (totalCents / 100).toFixed(2),
1626
+ cost_credits: totalCents,
1604
1627
  asset: result.asset,
1605
1628
  generationId: result.generationId,
1606
1629
  },
@@ -1621,8 +1644,8 @@ export const generateLipSync = {
1621
1644
  ttsLanguage: z.enum(['EN', 'ZH', 'JA', 'KO', 'ES']).optional().describe('Kling engine only — TTS language. Default EN.'),
1622
1645
  ttsSpeed: z.number().min(0.5).max(2).optional().describe('Kling engine only — TTS speech rate. Default 1.0. Range 0.5-2.0.'),
1623
1646
  audioFilePath: z.string().optional().describe('Required when audioMethod=upload. Absolute path to the audio file on the user\'s machine (mp3, wav, m4a).'),
1624
- avatarModel: z.enum(['avatar-standard', 'avatar-pro']).optional().describe('Kling engine, image-source only. avatar-standard ($0.42/5s) for general use. avatar-pro ($0.86/5s) for sharper face fidelity.'),
1625
- klingProvider: z.enum(['fal', 'kling']).optional().describe('Kling engine only — provider routing. "fal" (default) uses Slates credits. "kling" uses the user\'s own BYOK Kling key if configured.'),
1647
+ avatarModel: z.enum(['avatar-standard', 'avatar-pro']).optional().describe('Kling engine, image-source only. avatar-standard (~14 credits/5s) for general use. avatar-pro (~29 credits/5s) for sharper face fidelity.'),
1648
+ klingProvider: z.enum(['fal', 'kling']).optional().describe('Kling engine only — provider routing. Leave unset: all agent generations bill Slates credits (BYOK is retired).'),
1626
1649
  engine: z.enum(['kling', 'seedance-2']).optional().describe('Default kling (cheap utility). seedance-2 = premium single-pass: natural speech generated in the video, voice cloned from a video source, audio included. Credits only.'),
1627
1650
  videoResolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe('Seedance engine only. Default 1080p.'),
1628
1651
  aspectRatio: z.string().optional().describe('Seedance engine only. Default 16:9.'),
@@ -1632,7 +1655,7 @@ export const generateLipSync = {
1632
1655
  sourceSeconds: z.number().optional().describe('Seedance engine + sourceType=video: the source clip\'s duration in seconds (from the asset listing). Feeds the vref cost key (input+output billing).'),
1633
1656
  audioSeconds: z.number().optional().describe('Seedance engine + audioMethod=upload: the audio file\'s duration in seconds — sets the output length (4-15s).'),
1634
1657
  background: z.boolean().optional().describe(BACKGROUND_DESCRIBE),
1635
- confirm: z.boolean().optional().describe('Set true to bypass the >$0.50 cost confirm gate. Required for avatar-pro.'),
1658
+ confirm: z.boolean().optional().describe('Set true to bypass the confirm gate. Required for avatar-pro.'),
1636
1659
  }),
1637
1660
  async run(input, ctx) {
1638
1661
  if (input.audioMethod === 'tts' && !input.ttsText) {
@@ -1696,12 +1719,12 @@ export const generateLipSync = {
1696
1719
  const entry = registry.models.find((m) => m.model === costKey);
1697
1720
  if (!entry)
1698
1721
  throw new Error(`Model variant not in registry: ${costKey}`);
1699
- const totalCents = entry.cost_cents;
1722
+ const totalCents = creditCost(entry);
1700
1723
  // Cost confirm gate. Lip-sync is mechanical — the model re-syncs the
1701
1724
  // user-chosen source to the user-chosen audio. The agent doesn't
1702
1725
  // write a prompt that depends on what the source looks like, so we
1703
1726
  // skip the inline preview and just announce the source code in text.
1704
- if (totalCents > 50 && !input.confirm) {
1727
+ if (totalCents > CONFIRM_CREDITS && !input.confirm) {
1705
1728
  const sourceRef = await lookupAssetRef(ctx.desktop(), input.sourceAssetId);
1706
1729
  const audioPreview = input.audioMethod === 'tts'
1707
1730
  ? `Audio: TTS — "${(input.ttsText ?? '').slice(0, 120)}"`
@@ -1710,9 +1733,9 @@ export const generateLipSync = {
1710
1733
  requires_confirm: true,
1711
1734
  variant: costKey,
1712
1735
  estimated_cents: totalCents,
1713
- estimated_dollars: (totalCents / 100).toFixed(2),
1736
+ estimated_credits: totalCents,
1714
1737
  source_ref: sourceRef,
1715
- message: `Cost: $${(totalCents / 100).toFixed(2)} for ${isSeedance ? `${seedanceDuration}s Seedance` : '5s'} lip-sync (${costKey}). ` +
1738
+ message: `Cost: ${fmtCredits(totalCents)} for ${isSeedance ? `${seedanceDuration}s Seedance` : '5s'} lip-sync (${costKey}). ` +
1716
1739
  `Source: ${sourceRef}. ${audioPreview}. ` +
1717
1740
  `Re-call with confirm=true after the user explicitly OKs the spend. ` +
1718
1741
  `When discussing with the user, refer to the source by its code (matches the gallery badge).`,
@@ -1761,12 +1784,12 @@ export const generateLipSync = {
1761
1784
  projectId: input.projectId,
1762
1785
  sourceAssetId: input.sourceAssetId,
1763
1786
  cost_cents: totalCents,
1764
- cost_dollars: (totalCents / 100).toFixed(2),
1787
+ cost_credits: totalCents,
1765
1788
  });
1766
1789
  }
1767
1790
  return {
1768
1791
  text: `Generated ${isSeedance ? `${seedanceDuration}s` : '5s'} lip-sync (${costKey}) into project ${input.projectId} ` +
1769
- `for $${(totalCents / 100).toFixed(2)} (${totalCents}¢). ` +
1792
+ `for ${fmtCredits(totalCents)}. ` +
1770
1793
  (input.audioMethod === 'tts'
1771
1794
  ? `Spoken: "${(input.ttsText ?? '').slice(0, 60)}${(input.ttsText ?? '').length > 60 ? '...' : ''}"`
1772
1795
  : `Audio: ${input.audioFilePath}`),
@@ -1776,7 +1799,7 @@ export const generateLipSync = {
1776
1799
  sourceType: input.sourceType,
1777
1800
  sourceAssetId: input.sourceAssetId,
1778
1801
  cost_cents: totalCents,
1779
- cost_dollars: (totalCents / 100).toFixed(2),
1802
+ cost_credits: totalCents,
1780
1803
  asset: result.asset,
1781
1804
  generationId: result.generationId,
1782
1805
  },
@@ -1791,7 +1814,7 @@ export const generateMotionTransfer = {
1791
1814
  projectId: z.string().uuid().describe('Slates project. Both source and target assets must live here.'),
1792
1815
  sourceVideoAssetId: z.string().uuid().describe('Asset id of the reference video — its motion will be retargeted onto the target image. Must already exist in the project. Seedance engine: 2-15s clips only.'),
1793
1816
  targetImageAssetId: z.string().uuid().describe('Asset id of the target image (the character that will perform the motion). Must already exist in the project.'),
1794
- motionModel: z.enum(['kling-mc-std', 'kling-mc-pro', 'seedance-2']).optional().describe('kling-mc-std ($0.95) general motion; kling-mc-pro ($1.26) cleaner anatomy — default. seedance-2 = premium single-pass lane (prompt-driven, native audio, input+output-second billing) — pick when motion fidelity or audio matters.'),
1817
+ motionModel: z.enum(['kling-mc-std', 'kling-mc-pro', 'seedance-2']).optional().describe('kling-mc-std (~32 credits) general motion; kling-mc-pro (~42 credits) cleaner anatomy — default. seedance-2 = premium single-pass lane (prompt-driven, native audio, input+output-second billing) — pick when motion fidelity or audio matters.'),
1795
1818
  characterOrientation: z.enum(['video', 'image']).optional().describe('Kling only. "video" = use the source video\'s framing. "image" = use the target image\'s framing. Default video.'),
1796
1819
  prompt: z.string().optional().describe('Kling: optional refinement. Seedance: THE driver — describe what the character does with the motion from the clip (ordinal references: "the character from image 1 performs the motion from video 1"). A sensible default recipe is used if omitted. Read slates-prompting-motion-transfer.'),
1797
1820
  klingProvider: z.enum(['fal', 'kling']).optional().describe('Kling engine only — provider routing. "fal" (default) uses Slates credits.'),
@@ -1803,7 +1826,7 @@ export const generateMotionTransfer = {
1803
1826
  realFaceConsent: z.boolean().optional().describe('MANDATORY with seedanceRealFace — set true only after the user explicitly confirms they hold rights/consent to the likeness.'),
1804
1827
  sourceVideoSeconds: z.number().optional().describe('Seedance engine: the driving clip\'s duration in seconds (from the asset listing, 2-15s). Feeds the vref cost key (input+output billing).'),
1805
1828
  background: z.boolean().optional().describe(BACKGROUND_DESCRIBE),
1806
- confirm: z.boolean().optional().describe('Set true to bypass the >$0.50 confirm gate. Required — both tiers exceed.'),
1829
+ confirm: z.boolean().optional().describe('Set true to bypass the confirm gate. Required — both tiers exceed.'),
1807
1830
  }),
1808
1831
  async run(input, ctx) {
1809
1832
  const motionModel = input.motionModel ?? 'kling-mc-pro';
@@ -1843,12 +1866,12 @@ export const generateMotionTransfer = {
1843
1866
  const entry = registry.models.find((m) => m.model === costKey);
1844
1867
  if (!entry)
1845
1868
  throw new Error(`Model variant not in registry: ${costKey}`);
1846
- const totalCents = entry.cost_cents;
1869
+ const totalCents = creditCost(entry);
1847
1870
  // Cost confirm gate. Motion transfer is mechanical — the model
1848
1871
  // applies source motion to target image deterministically. We don't
1849
1872
  // burn tokens previewing assets the user already chose; codes in the
1850
1873
  // text are enough to keep the chat unambiguous.
1851
- if (totalCents > 50 && !input.confirm) {
1874
+ if (totalCents > CONFIRM_CREDITS && !input.confirm) {
1852
1875
  const desktop = ctx.desktop();
1853
1876
  const [source, target] = await Promise.all([
1854
1877
  lookupAssetRef(desktop, input.sourceVideoAssetId),
@@ -1858,12 +1881,12 @@ export const generateMotionTransfer = {
1858
1881
  requires_confirm: true,
1859
1882
  variant: costKey,
1860
1883
  estimated_cents: totalCents,
1861
- estimated_dollars: (totalCents / 100).toFixed(2),
1884
+ estimated_credits: totalCents,
1862
1885
  source_ref: source,
1863
1886
  target_ref: target,
1864
- message: `Cost: $${(totalCents / 100).toFixed(2)} for ${isSeedance ? `${seedanceDuration}s Seedance motion transfer` : `5s ${motionModel}`} (${costKey}). ` +
1887
+ message: `Cost: ${fmtCredits(totalCents)} for ${isSeedance ? `${seedanceDuration}s Seedance motion transfer` : `5s ${motionModel}`} (${costKey}). ` +
1865
1888
  `Transferring motion from ${source} onto ${target}. ` +
1866
- `Re-call with confirm=true after the user explicitly OKs the spend${isSeedance ? '' : ', or pick kling-mc-std to save $0.31'}. ` +
1889
+ `Re-call with confirm=true after the user explicitly OKs the spend${isSeedance ? '' : ', or pick kling-mc-std to save ~10 credits'}. ` +
1867
1890
  `When discussing with the user, refer to the assets by those codes — they'll match the gallery badges.`,
1868
1891
  });
1869
1892
  }
@@ -1908,12 +1931,12 @@ export const generateMotionTransfer = {
1908
1931
  sourceVideoAssetId: input.sourceVideoAssetId,
1909
1932
  targetImageAssetId: input.targetImageAssetId,
1910
1933
  cost_cents: totalCents,
1911
- cost_dollars: (totalCents / 100).toFixed(2),
1934
+ cost_credits: totalCents,
1912
1935
  });
1913
1936
  }
1914
1937
  return {
1915
1938
  text: `Generated ${isSeedance ? `${seedanceDuration}s` : '5s'} motion transfer (${motionModel}) into project ${input.projectId} ` +
1916
- `for $${(totalCents / 100).toFixed(2)} (${totalCents}¢).` +
1939
+ `for ${fmtCredits(totalCents)}.` +
1917
1940
  (input.prompt ? ` Prompt: "${input.prompt.slice(0, 60)}${input.prompt.length > 60 ? '...' : ''}"` : ''),
1918
1941
  data: {
1919
1942
  variant: costKey,
@@ -1922,7 +1945,7 @@ export const generateMotionTransfer = {
1922
1945
  sourceVideoAssetId: input.sourceVideoAssetId,
1923
1946
  targetImageAssetId: input.targetImageAssetId,
1924
1947
  cost_cents: totalCents,
1925
- cost_dollars: (totalCents / 100).toFixed(2),
1948
+ cost_credits: totalCents,
1926
1949
  asset: result.asset,
1927
1950
  generationId: result.generationId,
1928
1951
  },
@@ -1988,17 +2011,17 @@ export const editVideo = {
1988
2011
  const entry = registry.models.find((m) => m.model === costKey);
1989
2012
  if (!entry)
1990
2013
  throw new Error(`Model variant not in registry: ${costKey}`);
1991
- const totalCents = entry.cost_cents;
2014
+ const totalCents = creditCost(entry);
1992
2015
  // Confirm gate — look-first: preview the source clip + refs so the LLM
1993
2016
  // sees what it's editing before committing spend (mirrors generateVideo).
1994
- if ((totalCents > 50 || refInputs.length > 1) && !input.confirm) {
2017
+ if ((totalCents > CONFIRM_CREDITS || refInputs.length > 1) && !input.confirm) {
1995
2018
  const previews = await previewAssets(ctx, [
1996
2019
  { id: sourceId, type: 'video', role: 'source clip' },
1997
2020
  ...characterAssetIds.map((id) => ({ id, type: 'image', role: 'subject element' })),
1998
2021
  ...styleAssetIds.map((id) => ({ id, type: 'image', role: 'style' })),
1999
2022
  ]);
2000
2023
  return {
2001
- text: `Cost: $${(totalCents / 100).toFixed(2)} (${totalCents}¢) to edit a ${billedSeconds}s clip with ${model} (${costKey}). ` +
2024
+ text: `Cost: ${fmtCredits(totalCents)} to edit a ${billedSeconds}s clip with ${model} (${costKey}). ` +
2002
2025
  `${refEcho} Re-call with confirm=true after the user explicitly OKs the spend.`,
2003
2026
  images: previews.flatMap((p) => p.images),
2004
2027
  data: {
@@ -2008,7 +2031,7 @@ export const editVideo = {
2008
2031
  billed_seconds: billedSeconds,
2009
2032
  clip_seconds: clipSeconds,
2010
2033
  estimated_cents: totalCents,
2011
- estimated_dollars: (totalCents / 100).toFixed(2),
2034
+ estimated_credits: totalCents,
2012
2035
  references: previews.map((p) => ({ ref: p.ref, role: p.role, ...p.meta })),
2013
2036
  },
2014
2037
  };
@@ -2033,12 +2056,12 @@ export const editVideo = {
2033
2056
  projectId: input.projectId,
2034
2057
  sourceVideoAssetId: sourceId,
2035
2058
  cost_cents: totalCents,
2036
- cost_dollars: (totalCents / 100).toFixed(2),
2059
+ cost_credits: totalCents,
2037
2060
  }, refEcho);
2038
2061
  }
2039
2062
  return {
2040
2063
  text: `Edited ${billedSeconds}s clip saved as a new asset in project ${input.projectId} ` +
2041
- `for $${(totalCents / 100).toFixed(2)} (${totalCents}¢) via ${model}. ` +
2064
+ `for ${fmtCredits(totalCents)} via ${model}. ` +
2042
2065
  `Edit: "${input.prompt.slice(0, 60)}${input.prompt.length > 60 ? '...' : ''}"` +
2043
2066
  (refEcho ? ` ${refEcho}` : ''),
2044
2067
  data: {
@@ -2048,7 +2071,7 @@ export const editVideo = {
2048
2071
  sourceVideoAssetId: sourceId,
2049
2072
  billed_seconds: billedSeconds,
2050
2073
  cost_cents: totalCents,
2051
- cost_dollars: (totalCents / 100).toFixed(2),
2074
+ cost_credits: totalCents,
2052
2075
  asset: result.asset,
2053
2076
  generationId: result.generationId,
2054
2077
  },
@@ -2086,7 +2109,7 @@ export const getGenerationStatus = {
2086
2109
  // agent acts on, with the EXACT billed cost front and center.
2087
2110
  return ok({
2088
2111
  status,
2089
- cost_cents: g.cost ?? null,
2112
+ cost_credits: g.cost != null ? creditsFromDollars(g.cost) : null,
2090
2113
  error: g.error ?? null,
2091
2114
  model: g.model,
2092
2115
  completed_at: g.completedAt ?? null,