@goodandready/dsh-subscriptions 0.7.49 → 0.7.51

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/lib/analytics.js CHANGED
@@ -342,6 +342,27 @@ export function calculateQuotaForecast(accounts = [], entries = [], nowMs = Date
342
342
  return forecast
343
343
  }
344
344
 
345
+ /** #789: 24h request heatmap grouped by account ref and model. */
346
+ export function calculateAccountModelHeatmap(entries = [], nowMs = Date.now()) {
347
+ const ONE_HOUR = 3600000
348
+ const cutoff = nowMs - 24 * ONE_HOUR
349
+ const rows = new Map()
350
+ for (const entry of entries) {
351
+ if (!entry || !entry.ts || entry.ts < cutoff || entry.ts > nowMs) continue
352
+ const ref = String(entry.ref || entry.accountRef || entry.provider || 'unknown')
353
+ const model = String(entry.model || 'unknown')
354
+ const key = ref + '\u0000' + model
355
+ let row = rows.get(key)
356
+ if (!row) {
357
+ row = { ref, provider: entry.provider || '', model, hours: Array(24).fill(0) }
358
+ rows.set(key, row)
359
+ }
360
+ const bucket = 23 - Math.floor((nowMs - entry.ts) / ONE_HOUR)
361
+ if (bucket >= 0 && bucket < 24) row.hours[bucket]++
362
+ }
363
+ return [...rows.values()]
364
+ }
365
+
345
366
  export function calculateAnalytics(entries = [], accounts = [], nowMs = Date.now()) {
346
367
  let totalPrompt = 0, totalCompletion = 0, totalCacheRead = 0
347
368
  for (const r of entries) {
@@ -360,6 +381,7 @@ export function calculateAnalytics(entries = [], accounts = [], nowMs = Date.now
360
381
  cache: calculateCacheMetrics(entries),
361
382
  performance: calculatePerformanceMetrics(entries),
362
383
  hourly: calculateHourlyBuckets(entries, nowMs),
384
+ heatmap: calculateAccountModelHeatmap(entries, nowMs),
363
385
  accounts: calculateAccountBreakdown(entries, accounts),
364
386
  forecast: calculateQuotaForecast(accounts, entries, nowMs),
365
387
  valueDelivered,
package/lib/client.js CHANGED
@@ -1090,6 +1090,7 @@ window.__ModuleLoader__.load({
1090
1090
  const SourcesPanel = lazyPanel('SourcesPanel')
1091
1091
  const AccountsDashboardPanel = lazyPanel('AccountsDashboardPanel')
1092
1092
  const SpeedToggle = lazyPanel('SpeedToggle')
1093
+ const GeneratedMediaRow = lazyPanel('GeneratedMediaRow')
1093
1094
 
1094
1095
  // #839 model-list helpers
1095
1096
  function modelListSearchText(model) {
@@ -3912,6 +3913,17 @@ h('div', { className: 'dsub-row' },
3912
3913
  }, SpeedToggle))
3913
3914
  }, 'dsh-subscriptions: composer speed toggle')
3914
3915
 
3916
+ ctx.effect(() => {
3917
+ if (!ctx.slots || typeof ctx.slots.inject !== 'function') return () => {}
3918
+ const offs = ['image_generate', 'video_generate'].map((key) => ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
3919
+ name: 'tool.call.toolview',
3920
+ key,
3921
+ locale: NS,
3922
+ children: { 'tool.call.images': { kind: 'single', scope: 'session' } },
3923
+ }, GeneratedMediaRow)))
3924
+ return () => { for (const off of offs) if (typeof off === 'function') off() }
3925
+ }, 'dsh-subscriptions: generated media rows')
3926
+
3915
3927
  ctx.effect(() => {
3916
3928
  const cleanups = []
3917
3929
  const registerLegacySeats = () => {
@@ -291,12 +291,13 @@ window.__ModuleLoader__.load({
291
291
  h('label', { className: 'dsub-sub' }, h('input', { type: 'checkbox', checked: compact, onChange: (e) => setCompact(e.target.checked) }), ' ' + t('compact')),
292
292
  ),
293
293
  h('div', { className: 'dsub-h' }, t('heatmap')),
294
- h('div', { className: 'dsub-row', style: { gap: 2 } },
295
- ((analytics && analytics.hourly) || []).slice(-24).map((hour, i) => h('span', {
294
+ ((analytics && analytics.heatmap) || []).filter((row) => row.provider === selected).slice(0, 12).map((row) => h('div', { key: row.ref + ':' + row.model, className: 'dsub-row', style: { gap: 2 } },
295
+ h('span', { className: 'dsub-dim', style: { width: '130px', minWidth: '130px', overflow: 'hidden', textOverflow: 'ellipsis' } }, row.ref + ' · ' + row.model),
296
+ (row.hours || []).map((count, i) => h('span', {
296
297
  key: i,
297
- title: (hour && hour.requests || 0) + ' req',
298
- style: { display: 'inline-block', width: 8, height: 18, background: 'rgba(88,166,255,' + Math.min(1, ((hour && hour.requests) || 0) / 10 + 0.1) + ')' },
299
- }))),
298
+ title: count + ' req',
299
+ style: { display: 'inline-block', width: 8, height: 18, background: 'rgba(88,166,255,' + Math.min(1, (count || 0) / 10 + 0.1) + ')' },
300
+ })))),
300
301
  h('div', { className: 'dsub-row' },
301
302
  h('span', { className: 'dsub-h' }, t('modelEditor') + ' · ' + t('discovered') + ' ' + models.length + ' · ' + t('shown') + ' ' + shown.length),
302
303
  h('button', { type: 'button', className: 'dsub-mini', onClick: () => setEditing(!editing) }, t('editModels')),
@@ -364,6 +365,24 @@ window.__ModuleLoader__.load({
364
365
  }, mode === 'fast' ? '⚡ FAST' : 'Speed')
365
366
  }
366
367
 
367
- return { WebhookTestButton, VaultSnapshotsPanel, ProviderPacksPanel, SourcesPanel, AccountsDashboardPanel, SpeedToggle }
368
+ // #801: inline image/video result row for generated media.
369
+ function GeneratedMediaRow(props) {
370
+ const block = (props && props.block) || {}
371
+ const content = Array.isArray(block.content) ? block.content : []
372
+ const text = content.map((part) => (part && part.text) || '').join('\n')
373
+ const refs = []
374
+ for (const match of text.matchAll(/Attachment:\s*(\S+)/g)) refs.push(match[1])
375
+ const urls = []
376
+ for (const match of text.matchAll(/https?:\/\/\S+\.(?:mp4|webm|mov)\S*/g)) urls.push(match[0])
377
+ return h('div', { className: 'dsub-section-card' },
378
+ h('div', { className: 'dsub-sub' }, text.split('\n').filter((line) => !line.startsWith('Attachment:')).join('\n').slice(0, 500)),
379
+ refs.length && props.renderSlot
380
+ ? props.renderSlot('tool.call.images', { images: refs.map((attachment) => ({ attachment })) })
381
+ : null,
382
+ urls.map((url, i) => h('video', { key: i, src: url, controls: true, style: { maxWidth: '100%', marginTop: 8 } })),
383
+ )
384
+ }
385
+
386
+ return { WebhookTestButton, VaultSnapshotsPanel, ProviderPacksPanel, SourcesPanel, AccountsDashboardPanel, SpeedToggle, GeneratedMediaRow }
368
387
  }
369
388
  })
package/lib/index.js CHANGED
@@ -481,6 +481,7 @@ export function apply(ctx, rawConfig) {
481
481
  ctx.effect(() => registerSubscriptionTools(ctx, {
482
482
  subscriptions,
483
483
  images: () => ctx.subscriptionImages,
484
+ attachments: () => (typeof ctx.get === 'function' ? ctx.get('attachments') : null),
484
485
  }, { enabled: live().nativeTools || process.env.DSH_SUBSCRIPTIONS_NATIVE_TOOLS === '1' }), 'dsh-subscriptions: native tools')
485
486
  ctx.effect(() => () => clearSessionPins(), 'dsh-subscriptions: session pins cleanup')
486
487
 
@@ -0,0 +1,48 @@
1
+ const ASPECTS = new Set(['16:9', '9:16', '1:1', '4:3', '3:4', '3:2', '2:3'])
2
+ const RESOLUTIONS = new Set(['480p', '720p', '1080p'])
3
+
4
+ /** #801: validate video generation parameters before dispatch. */
5
+ export function validateVideoRequest({ duration, aspectRatio, resolution, imageUrl } = {}) {
6
+ const seconds = duration == null ? 5 : Number(duration)
7
+ if (!Number.isFinite(seconds) || seconds < 1 || seconds > 15) {
8
+ const err = new Error('duration must be between 1 and 15 seconds')
9
+ err.code = 'INVALID_VIDEO_PARAMS'
10
+ throw err
11
+ }
12
+ if (aspectRatio != null && !ASPECTS.has(String(aspectRatio))) {
13
+ const err = new Error('unsupported aspect ratio')
14
+ err.code = 'INVALID_VIDEO_PARAMS'
15
+ throw err
16
+ }
17
+ if (resolution != null && !RESOLUTIONS.has(String(resolution))) {
18
+ const err = new Error('unsupported resolution')
19
+ err.code = 'INVALID_VIDEO_PARAMS'
20
+ throw err
21
+ }
22
+ if (imageUrl != null && !/^https?:\/\//i.test(String(imageUrl))) {
23
+ const err = new Error('imageUrl must be http(s)')
24
+ err.code = 'INVALID_VIDEO_PARAMS'
25
+ throw err
26
+ }
27
+ return {
28
+ duration: seconds,
29
+ ...(aspectRatio ? { aspectRatio: String(aspectRatio) } : {}),
30
+ ...(resolution ? { resolution: String(resolution) } : {}),
31
+ ...(imageUrl ? { imageUrl: String(imageUrl) } : {}),
32
+ }
33
+ }
34
+
35
+ export function videoPollDelay(attempt) {
36
+ const n = Number(attempt) || 0
37
+ return Math.min(4000, 500 * 2 ** n)
38
+ }
39
+
40
+ export function extractVideoResult(payload) {
41
+ const body = payload && typeof payload === 'object' ? payload : {}
42
+ const data = body.data && typeof body.data === 'object' ? body.data : body
43
+ return {
44
+ id: data.id || data.video_id || data.request_id || null,
45
+ status: String(data.status || '').toLowerCase(),
46
+ url: data.url || data.video_url || (Array.isArray(data.output) ? data.output[0] : null),
47
+ }
48
+ }
@@ -1,6 +1,7 @@
1
1
  import { mkdir, writeFile } from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
  import { validateReferenceImages } from './media-reference.js'
4
+ import { validateVideoRequest, videoPollDelay, extractVideoResult } from './media-video.js'
4
5
 
5
6
  const TOOL_OUTPUT = {
6
7
  schema: {
@@ -44,18 +45,26 @@ function outputDir(exec) {
44
45
  return path.join(cwd, 'generated', 'subscriptions')
45
46
  }
46
47
 
47
- async function saveImages(images, exec) {
48
+ async function saveImages(images, exec, attachments) {
48
49
  const dir = outputDir(exec)
49
50
  await mkdir(dir, { recursive: true })
50
51
  const out = []
52
+ const stored = []
51
53
  for (let i = 0; i < images.length; i += 1) {
52
54
  const image = images[i]
53
55
  if (!image || typeof image.b64_json !== 'string') continue
56
+ const data = Buffer.from(image.b64_json, 'base64')
54
57
  const file = path.join(dir, `image-${Date.now()}-${i}.png`)
55
- await writeFile(file, Buffer.from(image.b64_json, 'base64'))
58
+ await writeFile(file, data)
56
59
  out.push(file)
60
+ if (attachments && typeof attachments.saveImages === 'function') {
61
+ try {
62
+ const refs = await attachments.saveImages([{ data, mediaType: 'image/png', name: `image-${i + 1}.png` }])
63
+ for (const ref of refs || []) stored.push(ref)
64
+ } catch { /* file output still works */ }
65
+ }
57
66
  }
58
- return out
67
+ return { files: out, attachments: stored }
59
68
  }
60
69
 
61
70
  export function registerSubscriptionTools(ctx, deps, options = {}) {
@@ -109,9 +118,11 @@ export function registerSubscriptionTools(ctx, deps, options = {}) {
109
118
  referenceImages: references.refs,
110
119
  signal: exec && exec.signal,
111
120
  })
112
- const files = await saveImages(rows, exec)
113
- if (!files.length) throw new Error('image response contained no images')
114
- return textResult(files.map((file) => `Generated image: ${file}`).join('\n'))
121
+ const saved = await saveImages(rows, exec, deps.attachments && deps.attachments())
122
+ if (!saved.files.length) throw new Error('image response contained no images')
123
+ const lines = saved.files.map((file) => `Generated image: ${file}`)
124
+ for (const ref of saved.attachments) lines.push(`Attachment: ${ref}`)
125
+ return textResult(lines.join('\n'))
115
126
  } catch (err) {
116
127
  lastError = err
117
128
  }
@@ -194,11 +205,18 @@ export function registerSubscriptionTools(ctx, deps, options = {}) {
194
205
  prompt: { type: 'string', description: 'Video prompt' },
195
206
  duration: { type: 'number', description: 'Duration in seconds (1-15)' },
196
207
  aspect_ratio: { type: 'string', description: 'e.g. 16:9, 1:1, 9:16' },
197
- image_url: { type: 'string', description: 'Optional source image URL' },
208
+ resolution: { type: 'string', enum: ['480p', '720p', '1080p'] },
209
+ image_url: { type: 'string', description: 'Optional source image URL (image-to-video)' },
198
210
  },
199
211
  required: ['prompt'],
200
212
  },
201
213
  async execute(args, exec) {
214
+ const params = validateVideoRequest({
215
+ duration: args && args.duration,
216
+ aspectRatio: args && args.aspect_ratio,
217
+ resolution: args && args.resolution,
218
+ imageUrl: args && args.image_url,
219
+ })
202
220
  const res = await deps.subscriptions.request({
203
221
  provider: 'grok',
204
222
  path: '/v1/videos',
@@ -207,15 +225,33 @@ export function registerSubscriptionTools(ctx, deps, options = {}) {
207
225
  body: {
208
226
  model: 'grok-imagine-video-1.5',
209
227
  prompt: args && args.prompt,
210
- duration: args && args.duration,
211
- aspect_ratio: args && args.aspect_ratio,
212
- image_url: args && args.image_url,
228
+ duration: params.duration,
229
+ ...(params.aspectRatio ? { aspect_ratio: params.aspectRatio } : {}),
230
+ ...(params.resolution ? { resolution: params.resolution } : {}),
231
+ ...(params.imageUrl ? { image_url: params.imageUrl } : {}),
213
232
  },
214
233
  signal: exec && exec.signal,
215
234
  })
216
- const data = await res.json().catch(() => ({}))
235
+ let data = await res.json().catch(() => ({}))
217
236
  if (!res.ok) throw new Error(`video_generate HTTP ${res.status}: ${extractResponseText(data)}`)
218
- return textResult(`Video request submitted: ${JSON.stringify(data).slice(0, 1000)}`)
237
+ let result = extractVideoResult(data)
238
+ for (let attempt = 0; result.id && !result.url && result.status !== 'failed' && attempt < 5; attempt += 1) {
239
+ await new Promise((resolve) => setTimeout(resolve, videoPollDelay(attempt)))
240
+ const poll = await deps.subscriptions.request({
241
+ provider: 'grok',
242
+ path: '/v1/videos/' + encodeURIComponent(result.id),
243
+ method: 'GET',
244
+ headers: { Accept: 'application/json' },
245
+ signal: exec && exec.signal,
246
+ })
247
+ data = await poll.json().catch(() => ({}))
248
+ if (!poll.ok) break
249
+ result = extractVideoResult(data)
250
+ }
251
+ if (result.status === 'failed') throw new Error('video generation failed')
252
+ return textResult(result.url
253
+ ? 'Video: ' + result.url
254
+ : `Video request submitted: ${JSON.stringify(data).slice(0, 1000)}`)
219
255
  },
220
256
  })
221
257
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.7.49",
3
+ "version": "0.7.51",
4
4
  "description": "Use ChatGPT Codex, Claude, Grok, Antigravity, Kimi, GLM, Cursor, Kiro, Copilot, Qwen, ERNIE, Spark, JetBrains, Perplexity, Replit, and Cody subscriptions as DeepSeek Harness LLM providers via OAuth.",
5
5
  "license": "MIT",
6
6
  "type": "module",