@ossy/media-tasks 3.15.0 → 3.16.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.
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Resolve workspace-configured placement folders for a task output.
3
+ * Uses `@ossy/automation/actions/get-task-instance` (string id — no package import).
4
+ *
5
+ * @param {{ invoke: Function }} sdk
6
+ * @param {string} taskId
7
+ * @param {string} outputName
8
+ * @returns {Promise<string[]>}
9
+ */
10
+ export async function resolveConfiguredPlacements (sdk, taskId, outputName) {
11
+ if (!sdk?.invoke || !taskId || !outputName) return []
12
+ try {
13
+ const instance = await sdk.invoke('@ossy/automation/actions/get-task-instance', { taskId })
14
+ const config = instance?.outputs?.[outputName]
15
+ const fromList = Array.isArray(config?.placementLocations)
16
+ ? config.placementLocations
17
+ : null
18
+ const raw = fromList ?? config?.placementLocation
19
+ const list = Array.isArray(raw) ? raw : (typeof raw === 'string' ? [raw] : [])
20
+ const seen = new Set()
21
+ const result = []
22
+ for (const entry of list) {
23
+ const trimmed = typeof entry === 'string' ? entry.trim() : ''
24
+ if (!trimmed || seen.has(trimmed)) continue
25
+ seen.add(trimmed)
26
+ result.push(trimmed)
27
+ }
28
+ return result
29
+ } catch {
30
+ return []
31
+ }
32
+ }
33
+
34
+ /**
35
+ * @deprecated Use {@link resolveConfiguredPlacements}
36
+ * @param {{ invoke: Function }} sdk
37
+ * @param {string} taskId
38
+ * @param {string} outputName
39
+ * @returns {Promise<string | null>}
40
+ */
41
+ export async function resolveConfiguredPlacement (sdk, taskId, outputName) {
42
+ const locations = await resolveConfiguredPlacements(sdk, taskId, outputName)
43
+ return locations[0] ?? null
44
+ }
@@ -0,0 +1,58 @@
1
+ import {
2
+ resolveConfiguredPlacement,
3
+ resolveConfiguredPlacements,
4
+ } from './resolve-configured-placement.js'
5
+
6
+ describe('resolveConfiguredPlacements', () => {
7
+ it('returns empty without sdk or ids', async () => {
8
+ expect(await resolveConfiguredPlacements(null, 't', 'o')).toEqual([])
9
+ expect(await resolveConfiguredPlacements({ invoke: async () => ({}) }, '', 'o')).toEqual([])
10
+ })
11
+
12
+ it('reads placementLocations from get-task-instance', async () => {
13
+ const calls = []
14
+ const sdk = {
15
+ invoke: async (action, payload) => {
16
+ calls.push([action, payload])
17
+ return {
18
+ outputs: {
19
+ palette: { placementLocations: ['/derived/colors/', ' /also/ ', '/derived/colors/'] },
20
+ },
21
+ }
22
+ },
23
+ }
24
+ await expect(resolveConfiguredPlacements(sdk, 'task-1', 'palette')).resolves.toEqual([
25
+ '/derived/colors/',
26
+ '/also/',
27
+ ])
28
+ expect(calls).toEqual([
29
+ ['@ossy/automation/actions/get-task-instance', { taskId: 'task-1' }],
30
+ ])
31
+ })
32
+
33
+ it('falls back to singular placementLocation', async () => {
34
+ await expect(resolveConfiguredPlacements({
35
+ invoke: async () => ({ outputs: { palette: { placementLocation: '/one/' } } }),
36
+ }, 't', 'palette')).resolves.toEqual(['/one/'])
37
+ })
38
+
39
+ it('returns empty when invoke fails or locations empty', async () => {
40
+ await expect(resolveConfiguredPlacements({
41
+ invoke: async () => { throw new Error('missing') },
42
+ }, 't', 'palette')).resolves.toEqual([])
43
+
44
+ await expect(resolveConfiguredPlacements({
45
+ invoke: async () => ({ outputs: { palette: { placementLocations: [' '] } } }),
46
+ }, 't', 'palette')).resolves.toEqual([])
47
+ })
48
+ })
49
+
50
+ describe('resolveConfiguredPlacement', () => {
51
+ it('returns the first location', async () => {
52
+ await expect(resolveConfiguredPlacement({
53
+ invoke: async () => ({
54
+ outputs: { palette: { placementLocations: ['/a/', '/b/'] } },
55
+ }),
56
+ }, 't', 'palette')).resolves.toBe('/a/')
57
+ })
58
+ })
@@ -0,0 +1,5 @@
1
+ export const metadata = {
2
+ id: '@ossy/media-tasks/actions/run-prompt',
3
+ access: 'workspace',
4
+ label: 'automation.task.run-prompt.title',
5
+ }
@@ -0,0 +1,123 @@
1
+ /** Default OpenRouter model for freeform completions. */
2
+ export const DEFAULT_RUN_PROMPT_MODEL = 'openai/gpt-4o-mini'
3
+
4
+ /** Document schema whose `content.body` is the prompt context. */
5
+ export const MARKDOWN_DOCUMENT_TYPE = '@ossy/resources/schema/markdown'
6
+
7
+ /** Cap resource text included beside the prompt so one file cannot fill the context. */
8
+ export const MAX_PROMPT_CONTEXT_CHARS = 12_000
9
+
10
+ /**
11
+ * @param {string} [contentType]
12
+ * @returns {boolean}
13
+ */
14
+ export function isTextContentType (contentType) {
15
+ const mime = String(contentType || '').split(';')[0].trim().toLowerCase()
16
+ if (!mime) return false
17
+ return mime.startsWith('text/')
18
+ || mime === 'application/json'
19
+ || mime === 'application/xml'
20
+ || mime.endsWith('+json')
21
+ || mime.endsWith('+xml')
22
+ }
23
+
24
+ /**
25
+ * @param {unknown} temperature
26
+ * @returns {number | undefined}
27
+ */
28
+ export function clampTemperature (temperature) {
29
+ if (temperature == null || temperature === '') return undefined
30
+ const n = typeof temperature === 'number' ? temperature : Number(temperature)
31
+ if (!Number.isFinite(n)) return undefined
32
+ return Math.min(2, Math.max(0, n))
33
+ }
34
+
35
+ /**
36
+ * True for a Markdown document (text in `content.body`, not a stored file).
37
+ *
38
+ * @param {object | null | undefined} resource
39
+ * @returns {boolean}
40
+ */
41
+ export function isMarkdownDocument (resource) {
42
+ return resource?.type === MARKDOWN_DOCUMENT_TYPE
43
+ }
44
+
45
+ /**
46
+ * @param {object | null | undefined} resource
47
+ * @returns {string}
48
+ */
49
+ export function markdownDocumentBody (resource) {
50
+ const body = resource?.content?.body
51
+ return typeof body === 'string' ? body : ''
52
+ }
53
+
54
+ /**
55
+ * Short context block prepended under the workspace prompt.
56
+ *
57
+ * @param {{ name?: string, contentType?: string, text?: string }} resource
58
+ * @returns {string}
59
+ */
60
+ export function resourceContextText ({ name, contentType, text } = {}) {
61
+ const lines = []
62
+ if (name) lines.push(`Name: ${name}`)
63
+ if (contentType) lines.push(`Content type: ${contentType}`)
64
+ const body = typeof text === 'string' ? text.trim() : ''
65
+ if (body) {
66
+ const clipped = body.length > MAX_PROMPT_CONTEXT_CHARS
67
+ ? `${body.slice(0, MAX_PROMPT_CONTEXT_CHARS)}…`
68
+ : body
69
+ lines.push('', clipped)
70
+ }
71
+ return lines.join('\n').trim()
72
+ }
73
+
74
+ /**
75
+ * @param {import('openai').OpenAI} client
76
+ * @param {{
77
+ * prompt: string,
78
+ * model?: string,
79
+ * temperature?: number,
80
+ * contextText?: string,
81
+ * imageUrl?: string,
82
+ * }} opts
83
+ * @returns {Promise<{ text: string, model: string }>}
84
+ */
85
+ export async function runOpenRouterPrompt (client, opts = {}) {
86
+ if (!client?.chat?.completions?.create) {
87
+ throw new Error('openrouter client is required')
88
+ }
89
+ const prompt = typeof opts.prompt === 'string' ? opts.prompt.trim() : ''
90
+ if (!prompt) {
91
+ throw new Error('prompt is required')
92
+ }
93
+
94
+ const model = opts.model || DEFAULT_RUN_PROMPT_MODEL
95
+ const temperature = clampTemperature(opts.temperature)
96
+ const contextText = typeof opts.contextText === 'string' ? opts.contextText.trim() : ''
97
+ const text = contextText ? `${prompt}\n\n---\n${contextText}` : prompt
98
+ const imageUrl = typeof opts.imageUrl === 'string' && opts.imageUrl ? opts.imageUrl : ''
99
+
100
+ /** @type {string | Array<{ type: string, text?: string, image_url?: { url: string } }>} */
101
+ const content = imageUrl
102
+ ? [
103
+ { type: 'text', text },
104
+ { type: 'image_url', image_url: { url: imageUrl } },
105
+ ]
106
+ : text
107
+
108
+ const response = await client.chat.completions.create({
109
+ model,
110
+ ...(temperature != null ? { temperature } : {}),
111
+ messages: [{ role: 'user', content }],
112
+ })
113
+
114
+ const completion = response?.choices?.[0]?.message?.content
115
+ if (typeof completion !== 'string' || !completion.trim()) {
116
+ throw new Error('run-prompt: model returned an empty completion')
117
+ }
118
+
119
+ return {
120
+ text: completion,
121
+ model: typeof response?.model === 'string' && response.model ? response.model : model,
122
+ }
123
+ }
@@ -0,0 +1,48 @@
1
+ export default {
2
+ name: 'Prompt completion',
3
+ id: '@ossy/media-tasks/schema/run-prompt',
4
+ categoryName: 'Media processing',
5
+ icon: 'comment',
6
+ fields: [
7
+ {
8
+ name: 'text',
9
+ type: 'textarea',
10
+ description: 'Model completion',
11
+ },
12
+ {
13
+ name: 'model',
14
+ type: 'text',
15
+ description: 'OpenRouter model that produced the completion',
16
+ },
17
+ {
18
+ name: 'prompt',
19
+ type: 'textarea',
20
+ description: 'Prompt that was sent, before resource context',
21
+ },
22
+ {
23
+ name: 'derivedFrom',
24
+ type: 'text',
25
+ description: 'Source resource id this output was derived from',
26
+ },
27
+ {
28
+ name: 'taskId',
29
+ type: 'text',
30
+ description: 'Task that produced this resource',
31
+ },
32
+ {
33
+ name: 'artifactName',
34
+ type: 'text',
35
+ description: 'Named task output',
36
+ },
37
+ {
38
+ name: 'artifactKey',
39
+ type: 'text',
40
+ description: 'Storage key for the artifact bytes/JSON',
41
+ },
42
+ {
43
+ name: 'artifactHref',
44
+ type: 'text',
45
+ description: 'Stable /r/…/tasks/… read path',
46
+ },
47
+ ],
48
+ }
@@ -0,0 +1,117 @@
1
+ import {
2
+ DEFAULT_RUN_PROMPT_MODEL,
3
+ MARKDOWN_DOCUMENT_TYPE,
4
+ clampTemperature,
5
+ isMarkdownDocument,
6
+ isTextContentType,
7
+ markdownDocumentBody,
8
+ resourceContextText,
9
+ runOpenRouterPrompt,
10
+ } from './run-prompt.js'
11
+
12
+ describe('isTextContentType', () => {
13
+ it('accepts text, json, and xml', () => {
14
+ expect(isTextContentType('text/plain')).toBe(true)
15
+ expect(isTextContentType('text/markdown; charset=utf-8')).toBe(true)
16
+ expect(isTextContentType('application/json')).toBe(true)
17
+ expect(isTextContentType('application/ld+json')).toBe(true)
18
+ expect(isTextContentType('image/png')).toBe(false)
19
+ })
20
+ })
21
+
22
+ describe('isMarkdownDocument', () => {
23
+ it('recognises the markdown schema and reads its body', () => {
24
+ const resource = {
25
+ type: MARKDOWN_DOCUMENT_TYPE,
26
+ name: 'notes',
27
+ content: { body: '# Hello' },
28
+ }
29
+ expect(isMarkdownDocument(resource)).toBe(true)
30
+ expect(markdownDocumentBody(resource)).toBe('# Hello')
31
+ expect(isMarkdownDocument({
32
+ type: '@ossy/platform/schema/file',
33
+ content: { ContentType: 'text/plain', Key: 'abc' },
34
+ })).toBe(false)
35
+ })
36
+ })
37
+
38
+ describe('resourceContextText', () => {
39
+ it('includes name, type, and clipped body', () => {
40
+ const text = resourceContextText({
41
+ name: 'notes.md',
42
+ contentType: 'text/markdown',
43
+ text: 'hello',
44
+ })
45
+ expect(text).toContain('Name: notes.md')
46
+ expect(text).toContain('hello')
47
+ })
48
+ })
49
+
50
+ describe('clampTemperature', () => {
51
+ it('keeps 0 and clamps the range', () => {
52
+ expect(clampTemperature(0)).toBe(0)
53
+ expect(clampTemperature(3)).toBe(2)
54
+ expect(clampTemperature(-1)).toBe(0)
55
+ expect(clampTemperature(undefined)).toBeUndefined()
56
+ })
57
+ })
58
+
59
+ describe('runOpenRouterPrompt', () => {
60
+ it('sends the prompt and optional temperature, and returns the completion', async () => {
61
+ const calls = []
62
+ const client = {
63
+ chat: {
64
+ completions: {
65
+ create: async (args) => {
66
+ calls.push(args)
67
+ return {
68
+ model: 'openai/gpt-4o-mini',
69
+ choices: [{ message: { content: 'A short title' } }],
70
+ }
71
+ },
72
+ },
73
+ },
74
+ }
75
+
76
+ const result = await runOpenRouterPrompt(client, {
77
+ prompt: 'Name this file',
78
+ temperature: 0.2,
79
+ contextText: 'Name: a.png',
80
+ })
81
+
82
+ expect(result).toEqual({ text: 'A short title', model: 'openai/gpt-4o-mini' })
83
+ expect(calls[0].model).toBe(DEFAULT_RUN_PROMPT_MODEL)
84
+ expect(calls[0].temperature).toBe(0.2)
85
+ expect(calls[0].messages[0].content).toContain('Name this file')
86
+ expect(calls[0].messages[0].content).toContain('Name: a.png')
87
+ })
88
+
89
+ it('attaches an image as a content part', async () => {
90
+ const calls = []
91
+ const client = {
92
+ chat: {
93
+ completions: {
94
+ create: async (args) => {
95
+ calls.push(args)
96
+ return { choices: [{ message: { content: 'ok' } }] }
97
+ },
98
+ },
99
+ },
100
+ }
101
+
102
+ await runOpenRouterPrompt(client, {
103
+ prompt: 'Describe',
104
+ model: 'google/gemini-2.5-flash-lite',
105
+ imageUrl: 'data:image/png;base64,aaaa',
106
+ })
107
+
108
+ expect(calls[0].model).toBe('google/gemini-2.5-flash-lite')
109
+ expect(calls[0].temperature).toBeUndefined()
110
+ expect(calls[0].messages[0].content[1].image_url.url).toBe('data:image/png;base64,aaaa')
111
+ })
112
+
113
+ it('rejects an empty prompt', async () => {
114
+ const client = { chat: { completions: { create: async () => ({}) } } }
115
+ await expect(runOpenRouterPrompt(client, { prompt: ' ' })).rejects.toThrow(/prompt/)
116
+ })
117
+ })
@@ -0,0 +1,187 @@
1
+ import { createLogger } from '@ossy/observability'
2
+ import { StorageClient } from '@ossy/platform'
3
+ import { taskArtifactObjectKey } from '@ossy/platform/storage-keys'
4
+ import { GetResource, placeTaskArtifacts } from '@ossy/resources'
5
+ import { loadSourceBuffer } from './load-source-buffer.js'
6
+ import { toImageDataUrl } from './visual-content-descriptors.js'
7
+ import {
8
+ DEFAULT_RUN_PROMPT_MODEL,
9
+ MARKDOWN_DOCUMENT_TYPE,
10
+ isMarkdownDocument,
11
+ isTextContentType,
12
+ markdownDocumentBody,
13
+ resourceContextText,
14
+ runOpenRouterPrompt,
15
+ } from './run-prompt.js'
16
+ import { resolveConfiguredPlacements } from './resolve-configured-placement.js'
17
+
18
+ const log = createLogger('media-tasks')
19
+
20
+ const TASK_ID = '@ossy/media-tasks/tasks/run-prompt'
21
+ const SCHEMA_ID = '@ossy/media-tasks/schema/run-prompt'
22
+ const CONCEPT = 'run-prompt'
23
+ const OUTPUT = {
24
+ name: 'completion',
25
+ kind: 'location',
26
+ schemaId: SCHEMA_ID,
27
+ contentType: 'application/json',
28
+ }
29
+
30
+ export const metadata = {
31
+ id: TASK_ID,
32
+ triggers: [
33
+ {
34
+ type: '@ossy/platform/schema/file',
35
+ event: 'Created',
36
+ match: { 'content.ContentType': 'text/*' },
37
+ },
38
+ {
39
+ type: '@ossy/platform/schema/file',
40
+ event: 'Created',
41
+ match: { 'content.ContentType': 'image/*' },
42
+ },
43
+ {
44
+ type: '@ossy/platform/schema/file',
45
+ event: 'Created',
46
+ match: { 'content.ContentType': 'application/json' },
47
+ },
48
+ {
49
+ type: MARKDOWN_DOCUMENT_TYPE,
50
+ event: 'Created',
51
+ },
52
+ ],
53
+ outputs: [OUTPUT],
54
+ inputs: [
55
+ {
56
+ name: 'prompt',
57
+ type: 'text',
58
+ multiline: true,
59
+ sources: ['payload', 'config'],
60
+ required: true,
61
+ description: 'Instructions for the model',
62
+ },
63
+ {
64
+ name: 'model',
65
+ type: 'text',
66
+ default: DEFAULT_RUN_PROMPT_MODEL,
67
+ sources: ['payload', 'config'],
68
+ description: 'OpenRouter model slug',
69
+ },
70
+ {
71
+ name: 'temperature',
72
+ type: 'number',
73
+ sources: ['payload', 'config'],
74
+ description: 'Sampling temperature from 0 to 2. Leave empty for the provider default.',
75
+ },
76
+ ],
77
+ configurable: true,
78
+ }
79
+
80
+ export async function run ({ event, payload, sdk, integrations, inputs }) {
81
+ const openrouter = integrations?.get?.('openrouter')
82
+ if (!openrouter) {
83
+ log.warn('[media-tasks/tasks/run-prompt] openrouter integration not connected')
84
+ throw Object.assign(new Error('OpenRouter integration not available'), { status: 503 })
85
+ }
86
+
87
+ const prompt = typeof inputs?.prompt === 'string' ? inputs.prompt : ''
88
+ const resourceId = event?.resourceId || payload?.resourceId || null
89
+ log.info(`[media-tasks/tasks/run-prompt] starting${resourceId ? ` for resource ${resourceId}` : ''}`)
90
+
91
+ let resource = null
92
+ let contextText = ''
93
+ let imageUrl
94
+ if (resourceId) {
95
+ resource = await sdk.invoke(GetResource, { resourceId })
96
+ const loaded = await loadResourceContext(resource)
97
+ contextText = loaded.contextText
98
+ imageUrl = loaded.imageUrl
99
+ }
100
+
101
+ const completion = await runOpenRouterPrompt(openrouter, {
102
+ prompt,
103
+ model: inputs?.model,
104
+ temperature: inputs?.temperature,
105
+ contextText,
106
+ imageUrl,
107
+ })
108
+ const body = {
109
+ text: completion.text,
110
+ model: completion.model,
111
+ prompt,
112
+ }
113
+
114
+ if (!resource?.id) {
115
+ return { output: OUTPUT.name, completion: body, placedResourceIds: [] }
116
+ }
117
+
118
+ const objectKey = taskArtifactObjectKey(resource.id, CONCEPT, OUTPUT.name)
119
+ await StorageClient.save(objectKey, Buffer.from(JSON.stringify(body), 'utf8'))
120
+ log.info(`[media-tasks/tasks/run-prompt] saved ${objectKey}`)
121
+
122
+ const placementLocations = await resolveConfiguredPlacements(sdk, TASK_ID, OUTPUT.name)
123
+ const placed = await placeTaskArtifacts({
124
+ sdk,
125
+ sourceResource: resource,
126
+ taskId: TASK_ID,
127
+ output: OUTPUT,
128
+ body,
129
+ artifactKey: objectKey,
130
+ placementLocations,
131
+ })
132
+ if (placed.length) {
133
+ log.info(`[media-tasks/tasks/run-prompt] placed ${placed.length} resource(s)`)
134
+ }
135
+
136
+ return {
137
+ output: OUTPUT.name,
138
+ key: objectKey,
139
+ completion: body,
140
+ placedResourceIds: placed.map((item) => item.id),
141
+ placedResourceId: placed[0]?.id ?? null,
142
+ }
143
+ }
144
+
145
+ /**
146
+ * @param {object | null | undefined} resource
147
+ * @returns {Promise<{ contextText: string, imageUrl?: string }>}
148
+ */
149
+ async function loadResourceContext (resource) {
150
+ const name = resource?.name
151
+ if (isMarkdownDocument(resource)) {
152
+ return {
153
+ contextText: resourceContextText({
154
+ name,
155
+ contentType: MARKDOWN_DOCUMENT_TYPE,
156
+ text: markdownDocumentBody(resource),
157
+ }),
158
+ }
159
+ }
160
+
161
+ const contentType = resource?.content?.ContentType || ''
162
+ const key = resource?.content?.Key
163
+ if (!key) {
164
+ return { contextText: resourceContextText({ name, contentType }) }
165
+ }
166
+
167
+ if (String(contentType).startsWith('image/')) {
168
+ const buffer = await loadSourceBuffer(key)
169
+ return {
170
+ contextText: resourceContextText({ name, contentType }),
171
+ ...(buffer ? { imageUrl: toImageDataUrl(buffer, contentType) } : {}),
172
+ }
173
+ }
174
+
175
+ if (isTextContentType(contentType)) {
176
+ const buffer = await loadSourceBuffer(key)
177
+ return {
178
+ contextText: resourceContextText({
179
+ name,
180
+ contentType,
181
+ text: buffer ? buffer.toString('utf8') : '',
182
+ }),
183
+ }
184
+ }
185
+
186
+ return { contextText: resourceContextText({ name, contentType }) }
187
+ }
@@ -0,0 +1,70 @@
1
+ /** Default OpenRouter model slug (vision-capable). */
2
+ export const DEFAULT_VCD_MODEL = 'openai/gpt-4o-mini'
3
+
4
+ const SYSTEM_PROMPT = `You will be provided with images, and your task is to create a json object including the following properties:
5
+ - title: Short attention grabbing title that describes the image
6
+ - description: A short text describing the image, suitable for SEO purposes
7
+ - tags: an array of tags describing the image that will be used for categorization and SEO purposes
8
+ - alt: A short text that describes the image, suitable for alt text`
9
+
10
+ /**
11
+ * Build a data URL so the model provider can read the image without fetching
12
+ * our storage URL (localhost / auth-gated URLs fail with OpenRouter 400).
13
+ *
14
+ * @param {Buffer} buffer
15
+ * @param {string} [contentType]
16
+ * @returns {string}
17
+ */
18
+ export function toImageDataUrl (buffer, contentType = 'application/octet-stream') {
19
+ if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
20
+ throw new Error('image buffer is required')
21
+ }
22
+ const mime = String(contentType || 'application/octet-stream').split(';')[0].trim() || 'application/octet-stream'
23
+ return `data:${mime};base64,${buffer.toString('base64')}`
24
+ }
25
+
26
+ /**
27
+ * Ask an OpenRouter (OpenAI-compatible) client for SEO descriptors of an image.
28
+ *
29
+ * @param {import('openai').OpenAI} client
30
+ * @param {string} imageUrl Absolute http(s) URL or data: URL of the image
31
+ * @param {{ model?: string }} [opts]
32
+ * @returns {Promise<{ title: string | null, description: string | null, tags: string[], alt: string | null }>}
33
+ */
34
+ export async function getVisualContentDescriptors (client, imageUrl, opts = {}) {
35
+ if (!client?.chat?.completions?.create) {
36
+ throw new Error('openrouter client is required')
37
+ }
38
+ if (!imageUrl) {
39
+ throw new Error('imageUrl is required')
40
+ }
41
+
42
+ const model = opts.model || DEFAULT_VCD_MODEL
43
+ const response = await client.chat.completions.create({
44
+ model,
45
+ max_tokens: 4000,
46
+ response_format: { type: 'json_object' },
47
+ messages: [
48
+ { role: 'system', content: SYSTEM_PROMPT },
49
+ {
50
+ role: 'user',
51
+ content: [{ type: 'image_url', image_url: { url: imageUrl } }],
52
+ },
53
+ ],
54
+ })
55
+
56
+ const raw = response?.choices?.[0]?.message?.content ?? '{}'
57
+ let parsed
58
+ try {
59
+ parsed = JSON.parse(raw)
60
+ } catch {
61
+ throw new Error('visual-content-descriptors: model returned non-JSON content')
62
+ }
63
+
64
+ return {
65
+ title: typeof parsed.title === 'string' ? parsed.title : null,
66
+ description: typeof parsed.description === 'string' ? parsed.description : null,
67
+ tags: Array.isArray(parsed.tags) ? parsed.tags.filter((t) => typeof t === 'string') : [],
68
+ alt: typeof parsed.alt === 'string' ? parsed.alt : null,
69
+ }
70
+ }