@ossy/media-tasks 3.16.0 → 3.17.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/README.md CHANGED
@@ -25,7 +25,7 @@ All four set **`configurable: true`**. Workspaces arm them on `/automation` with
25
25
  | extract-colors | `palette` | `@ossy/media-tasks/schema/extract-colors` |
26
26
  | resize-common-web | `sizes` (+ binary keys) | `@ossy/media-tasks/schema/resize-common-web` (JSON meta placed; binaries via URL) |
27
27
  | visual-content-descriptors | `descriptors` | `@ossy/media-tasks/schema/visual-content-descriptors` |
28
- | run-prompt | `completion` | `@ossy/media-tasks/schema/run-prompt` |
28
+ | run-prompt | `completion` | `@ossy/platform/schema/text-completion` (freeform text/md — a platform schema, not feature-owned) |
29
29
 
30
30
  See [TaskOutput.md](../../docs/concepts/TaskOutput.md).
31
31
 
@@ -61,6 +61,12 @@ OpenRouter is OpenAI-compatible (`baseURL` `https://openrouter.ai/api/v1`). The
61
61
 
62
62
  `run-prompt` sends the resolved prompt as the user message. A matching file is appended as context (text/JSON excerpt, or an image data URL). A Markdown document (`@ossy/resources/schema/markdown`) sends `content.body` instead. The completion is stored at `{resourceId}:task-run-prompt-completion` and placed only when the workspace set a folder. **Run now** invokes `@ossy/media-tasks/actions/run-prompt` with the editor values; without a resource id the completion is returned on the task run and not stored.
63
63
 
64
+ ## Usage metering and a monthly token cap (ADR 0017)
65
+
66
+ Both OpenRouter tasks read `response.usage` and return it as `result.usage = { model, promptTokens, completionTokens, totalTokens }`. `TaskRunService` forwards this onto `MeteringService.record()` automatically (any task returning `result.usage` in this shape gets metered — no per-task wiring needed).
67
+
68
+ Before calling OpenRouter, each task calls `assertLlmUsageBudget()` (`@ossy/platform/metering`) — a **hard, fail-closed** monthly token cap **per workspace** (`LLM_WORKSPACE_MONTHLY_TOKEN_CAP` env var default `2_000_000`, overridable per workspace via `@ossy/workspaces/actions/set-llm-monthly-token-limit`). Once a workspace crosses its effective limit, further runs throw `{ status: 402, code: 'LLM_TOKEN_QUOTA_EXCEEDED' }` — no OpenRouter call made, no tokens spent. Named generically, not after OpenRouter specifically, so swapping the provider later needs no rename. See `docs/adr/0017-llm-usage-metering-and-quota.md`.
69
+
64
70
  ## Dependencies
65
71
 
66
72
  - **`sharp`** (peer) — image resizing + metadata
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/media-tasks",
3
- "version": "3.16.0",
3
+ "version": "3.17.0",
4
4
  "description": "Changestream-triggered media processing tasks (image resize, AI descriptors, prompts)",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -17,7 +17,9 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "@ossy/observability": "^3.0.9",
20
- "@ossy/resources": "^3.16.0",
20
+ "@ossy/platform": "^3.17.0",
21
+ "@ossy/resources": "^3.17.0",
22
+ "@ossy/schema": "^3.17.0",
21
23
  "blurhash": "^2.0.5"
22
24
  },
23
25
  "peerDependencies": {
@@ -34,5 +36,5 @@
34
36
  ],
35
37
  "author": "Ossy <yourfriends@ossy.se> (https://ossy.se)",
36
38
  "license": "MIT",
37
- "gitHead": "1872fb1467bf656284aae35881c34960c3860807"
39
+ "gitHead": "91ff7dffe9b0bddcb823daee67213d2008eb64e9"
38
40
  }
@@ -1,3 +1,5 @@
1
+ import { TASK_OUTPUT_PROVENANCE_FIELDS } from '@ossy/schema'
2
+
1
3
  export default {
2
4
  name: 'Extract Colors',
3
5
  id: '@ossy/media-tasks/schema/extract-colors',
@@ -14,30 +16,6 @@ export default {
14
16
  type: 'textarea',
15
17
  description: 'Palette entries as JSON ({ hex, ratio }[])',
16
18
  },
17
- {
18
- name: 'derivedFrom',
19
- type: 'text',
20
- description: 'Source resource id this output was derived from',
21
- },
22
- {
23
- name: 'taskId',
24
- type: 'text',
25
- description: 'Task that produced this resource',
26
- },
27
- {
28
- name: 'artifactName',
29
- type: 'text',
30
- description: 'Named task output',
31
- },
32
- {
33
- name: 'artifactKey',
34
- type: 'text',
35
- description: 'Storage key for the artifact bytes/JSON',
36
- },
37
- {
38
- name: 'artifactHref',
39
- type: 'text',
40
- description: 'Stable /r/…/tasks/… read path',
41
- },
19
+ ...TASK_OUTPUT_PROVENANCE_FIELDS,
42
20
  ],
43
21
  }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * OpenRouter (OpenAI-compatible) chat completions return token usage as
3
+ * `response.usage = { prompt_tokens, completion_tokens, total_tokens }`.
4
+ * Callers echo this back on their result as `usage` so it flows through
5
+ * `TaskRunService` → `MeteringService` generically (see ADR 0017).
6
+ *
7
+ * @param {{ usage?: { prompt_tokens?: number, completion_tokens?: number, total_tokens?: number }, model?: string } | null | undefined} response
8
+ * @param {string} fallbackModel Model slug requested, used when the response omits `model`.
9
+ * @returns {{ model: string, promptTokens: number | null, completionTokens: number | null, totalTokens: number | null }}
10
+ */
11
+ export function usageFromResponse (response, fallbackModel) {
12
+ const model = typeof response?.model === 'string' && response.model ? response.model : fallbackModel
13
+ const usage = response?.usage
14
+ const promptTokens = typeof usage?.prompt_tokens === 'number' ? usage.prompt_tokens : null
15
+ const completionTokens = typeof usage?.completion_tokens === 'number' ? usage.completion_tokens : null
16
+ const totalTokens = typeof usage?.total_tokens === 'number'
17
+ ? usage.total_tokens
18
+ : (promptTokens != null || completionTokens != null ? (promptTokens ?? 0) + (completionTokens ?? 0) : null)
19
+
20
+ return { model, promptTokens, completionTokens, totalTokens }
21
+ }
@@ -0,0 +1,41 @@
1
+ import { usageFromResponse } from './llm-usage.js'
2
+
3
+ describe('usageFromResponse', () => {
4
+ it('reads prompt/completion/total tokens and the model that answered', () => {
5
+ expect(usageFromResponse({
6
+ model: 'openai/gpt-4o-mini-2024-07-18',
7
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
8
+ }, 'openai/gpt-4o-mini')).toEqual({
9
+ model: 'openai/gpt-4o-mini-2024-07-18',
10
+ promptTokens: 10,
11
+ completionTokens: 5,
12
+ totalTokens: 15,
13
+ })
14
+ })
15
+
16
+ it('derives totalTokens when the response omits it', () => {
17
+ expect(usageFromResponse({
18
+ usage: { prompt_tokens: 10, completion_tokens: 5 },
19
+ }, 'openai/gpt-4o-mini')).toEqual({
20
+ model: 'openai/gpt-4o-mini',
21
+ promptTokens: 10,
22
+ completionTokens: 5,
23
+ totalTokens: 15,
24
+ })
25
+ })
26
+
27
+ it('falls back to the requested model and nulls when usage is missing', () => {
28
+ expect(usageFromResponse({}, 'openai/gpt-4o-mini')).toEqual({
29
+ model: 'openai/gpt-4o-mini',
30
+ promptTokens: null,
31
+ completionTokens: null,
32
+ totalTokens: null,
33
+ })
34
+ expect(usageFromResponse(null, 'openai/gpt-4o-mini')).toEqual({
35
+ model: 'openai/gpt-4o-mini',
36
+ promptTokens: null,
37
+ completionTokens: null,
38
+ totalTokens: null,
39
+ })
40
+ })
41
+ })
@@ -1,3 +1,5 @@
1
+ import { TASK_OUTPUT_PROVENANCE_FIELDS } from '@ossy/schema'
2
+
1
3
  export default {
2
4
  name: 'Resize Common Web',
3
5
  id: '@ossy/media-tasks/schema/resize-common-web',
@@ -24,30 +26,6 @@ export default {
24
26
  type: 'textarea',
25
27
  description: 'Named derivative artifacts (thumbnail/gallery sizes)',
26
28
  },
27
- {
28
- name: 'derivedFrom',
29
- type: 'text',
30
- description: 'Source resource id this output was derived from',
31
- },
32
- {
33
- name: 'taskId',
34
- type: 'text',
35
- description: 'Task that produced this resource',
36
- },
37
- {
38
- name: 'artifactName',
39
- type: 'text',
40
- description: 'Named task output',
41
- },
42
- {
43
- name: 'artifactKey',
44
- type: 'text',
45
- description: 'Storage key for the artifact bytes/JSON',
46
- },
47
- {
48
- name: 'artifactHref',
49
- type: 'text',
50
- description: 'Stable /r/…/tasks/… read path',
51
- },
29
+ ...TASK_OUTPUT_PROVENANCE_FIELDS,
52
30
  ],
53
31
  }
package/src/run-prompt.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { usageFromResponse } from './llm-usage.js'
2
+
1
3
  /** Default OpenRouter model for freeform completions. */
2
4
  export const DEFAULT_RUN_PROMPT_MODEL = 'openai/gpt-4o-mini'
3
5
 
@@ -73,14 +75,12 @@ export function resourceContextText ({ name, contentType, text } = {}) {
73
75
 
74
76
  /**
75
77
  * @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 }>}
78
+ * @param {{ prompt: string, model?: string, temperature?: number, contextText?: string, imageUrl?: string }} opts
79
+ * @returns {Promise<{
80
+ * text: string,
81
+ * model: string,
82
+ * usage: { model: string, promptTokens: number | null, completionTokens: number | null, totalTokens: number | null },
83
+ * }>}
84
84
  */
85
85
  export async function runOpenRouterPrompt (client, opts = {}) {
86
86
  if (!client?.chat?.completions?.create) {
@@ -116,8 +116,11 @@ export async function runOpenRouterPrompt (client, opts = {}) {
116
116
  throw new Error('run-prompt: model returned an empty completion')
117
117
  }
118
118
 
119
+ const usage = usageFromResponse(response, model)
120
+
119
121
  return {
120
122
  text: completion,
121
- model: typeof response?.model === 'string' && response.model ? response.model : model,
123
+ model: usage.model,
124
+ usage,
122
125
  }
123
126
  }
@@ -79,13 +79,40 @@ describe('runOpenRouterPrompt', () => {
79
79
  contextText: 'Name: a.png',
80
80
  })
81
81
 
82
- expect(result).toEqual({ text: 'A short title', model: 'openai/gpt-4o-mini' })
82
+ expect(result).toEqual({
83
+ text: 'A short title',
84
+ model: 'openai/gpt-4o-mini',
85
+ usage: { model: 'openai/gpt-4o-mini', promptTokens: null, completionTokens: null, totalTokens: null },
86
+ })
83
87
  expect(calls[0].model).toBe(DEFAULT_RUN_PROMPT_MODEL)
84
88
  expect(calls[0].temperature).toBe(0.2)
85
89
  expect(calls[0].messages[0].content).toContain('Name this file')
86
90
  expect(calls[0].messages[0].content).toContain('Name: a.png')
87
91
  })
88
92
 
93
+ it('captures token usage from the OpenRouter response', async () => {
94
+ const client = {
95
+ chat: {
96
+ completions: {
97
+ create: async () => ({
98
+ model: 'openai/gpt-4o-mini-2024-07-18',
99
+ usage: { prompt_tokens: 42, completion_tokens: 8, total_tokens: 50 },
100
+ choices: [{ message: { content: 'ok' } }],
101
+ }),
102
+ },
103
+ },
104
+ }
105
+
106
+ const result = await runOpenRouterPrompt(client, { prompt: 'Name this file' })
107
+
108
+ expect(result.usage).toEqual({
109
+ model: 'openai/gpt-4o-mini-2024-07-18',
110
+ promptTokens: 42,
111
+ completionTokens: 8,
112
+ totalTokens: 50,
113
+ })
114
+ })
115
+
89
116
  it('attaches an image as a content part', async () => {
90
117
  const calls = []
91
118
  const client = {
@@ -1,6 +1,7 @@
1
1
  import { createLogger } from '@ossy/observability'
2
2
  import { StorageClient } from '@ossy/platform'
3
3
  import { taskArtifactObjectKey } from '@ossy/platform/storage-keys'
4
+ import { assertLlmUsageBudget, resolveWorkspaceIdForBudget } from '@ossy/platform/metering'
4
5
  import { GetResource, placeTaskArtifacts } from '@ossy/resources'
5
6
  import { loadSourceBuffer } from './load-source-buffer.js'
6
7
  import { toImageDataUrl } from './visual-content-descriptors.js'
@@ -18,7 +19,10 @@ import { resolveConfiguredPlacements } from './resolve-configured-placement.js'
18
19
  const log = createLogger('media-tasks')
19
20
 
20
21
  const TASK_ID = '@ossy/media-tasks/tasks/run-prompt'
21
- const SCHEMA_ID = '@ossy/media-tasks/schema/run-prompt'
22
+ // Freeform text/md completion → platform schema (see docs/concepts/TaskOutput.md),
23
+ // not a feature-owned one. Structured JSON outputs (e.g. visual-content-descriptors)
24
+ // still declare their own schema.
25
+ const SCHEMA_ID = '@ossy/platform/schema/text-completion'
22
26
  const CONCEPT = 'run-prompt'
23
27
  const OUTPUT = {
24
28
  name: 'completion',
@@ -77,7 +81,12 @@ export const metadata = {
77
81
  configurable: true,
78
82
  }
79
83
 
80
- export async function run ({ event, payload, sdk, integrations, inputs }) {
84
+ export async function run ({ event, payload, req, sdk, integrations, inputs }) {
85
+ // Fail closed before spending anything — ADR 0017's first QuotaPolicy.
86
+ await assertLlmUsageBudget({
87
+ workspaceId: resolveWorkspaceIdForBudget({ event, payload, req }),
88
+ })
89
+
81
90
  const openrouter = integrations?.get?.('openrouter')
82
91
  if (!openrouter) {
83
92
  log.warn('[media-tasks/tasks/run-prompt] openrouter integration not connected')
@@ -112,7 +121,7 @@ export async function run ({ event, payload, sdk, integrations, inputs }) {
112
121
  }
113
122
 
114
123
  if (!resource?.id) {
115
- return { output: OUTPUT.name, completion: body, placedResourceIds: [] }
124
+ return { output: OUTPUT.name, completion: body, placedResourceIds: [], usage: completion.usage }
116
125
  }
117
126
 
118
127
  const objectKey = taskArtifactObjectKey(resource.id, CONCEPT, OUTPUT.name)
@@ -139,6 +148,7 @@ export async function run ({ event, payload, sdk, integrations, inputs }) {
139
148
  completion: body,
140
149
  placedResourceIds: placed.map((item) => item.id),
141
150
  placedResourceId: placed[0]?.id ?? null,
151
+ usage: completion.usage,
142
152
  }
143
153
  }
144
154
 
@@ -1,3 +1,5 @@
1
+ import { usageFromResponse } from './llm-usage.js'
2
+
1
3
  /** Default OpenRouter model slug (vision-capable). */
2
4
  export const DEFAULT_VCD_MODEL = 'openai/gpt-4o-mini'
3
5
 
@@ -29,7 +31,13 @@ export function toImageDataUrl (buffer, contentType = 'application/octet-stream'
29
31
  * @param {import('openai').OpenAI} client
30
32
  * @param {string} imageUrl Absolute http(s) URL or data: URL of the image
31
33
  * @param {{ model?: string }} [opts]
32
- * @returns {Promise<{ title: string | null, description: string | null, tags: string[], alt: string | null }>}
34
+ * @returns {Promise<{
35
+ * title: string | null,
36
+ * description: string | null,
37
+ * tags: string[],
38
+ * alt: string | null,
39
+ * usage: { model: string, promptTokens: number | null, completionTokens: number | null, totalTokens: number | null },
40
+ * }>}
33
41
  */
34
42
  export async function getVisualContentDescriptors (client, imageUrl, opts = {}) {
35
43
  if (!client?.chat?.completions?.create) {
@@ -66,5 +74,6 @@ export async function getVisualContentDescriptors (client, imageUrl, opts = {})
66
74
  description: typeof parsed.description === 'string' ? parsed.description : null,
67
75
  tags: Array.isArray(parsed.tags) ? parsed.tags.filter((t) => typeof t === 'string') : [],
68
76
  alt: typeof parsed.alt === 'string' ? parsed.alt : null,
77
+ usage: usageFromResponse(response, model),
69
78
  }
70
79
  }
@@ -1,3 +1,5 @@
1
+ import { TASK_OUTPUT_PROVENANCE_FIELDS } from '@ossy/schema'
2
+
1
3
  export default {
2
4
  name: 'Visual Content Descriptors',
3
5
  id: '@ossy/media-tasks/schema/visual-content-descriptors',
@@ -24,30 +26,6 @@ export default {
24
26
  type: 'text',
25
27
  description: 'Suggested alt text',
26
28
  },
27
- {
28
- name: 'derivedFrom',
29
- type: 'text',
30
- description: 'Source resource id this output was derived from',
31
- },
32
- {
33
- name: 'taskId',
34
- type: 'text',
35
- description: 'Task that produced this resource',
36
- },
37
- {
38
- name: 'artifactName',
39
- type: 'text',
40
- description: 'Named task output',
41
- },
42
- {
43
- name: 'artifactKey',
44
- type: 'text',
45
- description: 'Storage key for the artifact bytes/JSON',
46
- },
47
- {
48
- name: 'artifactHref',
49
- type: 'text',
50
- description: 'Stable /r/…/tasks/… read path',
51
- },
29
+ ...TASK_OUTPUT_PROVENANCE_FIELDS,
52
30
  ],
53
31
  }
@@ -49,6 +49,7 @@ describe('getVisualContentDescriptors', () => {
49
49
  description: 'Orange sky over water',
50
50
  tags: ['sunset', 'nature'],
51
51
  alt: 'Sunset over water',
52
+ usage: { model: DEFAULT_VCD_MODEL, promptTokens: null, completionTokens: null, totalTokens: null },
52
53
  })
53
54
 
54
55
  expect(calls).toHaveLength(1)
@@ -81,6 +82,30 @@ describe('getVisualContentDescriptors', () => {
81
82
  description: null,
82
83
  tags: ['ok'],
83
84
  alt: null,
85
+ usage: { model: DEFAULT_VCD_MODEL, promptTokens: null, completionTokens: null, totalTokens: null },
86
+ })
87
+ })
88
+
89
+ it('captures token usage and the model that actually answered', async () => {
90
+ const client = {
91
+ chat: {
92
+ completions: {
93
+ create: async () => ({
94
+ model: 'openai/gpt-4o-mini-2024-07-18',
95
+ usage: { prompt_tokens: 120, completion_tokens: 40, total_tokens: 160 },
96
+ choices: [{ message: { content: '{"title":"Sunset"}' } }],
97
+ }),
98
+ },
99
+ },
100
+ }
101
+ await expect(getVisualContentDescriptors(client, 'data:image/png;base64,x'))
102
+ .resolves.toMatchObject({
103
+ usage: {
104
+ model: 'openai/gpt-4o-mini-2024-07-18',
105
+ promptTokens: 120,
106
+ completionTokens: 40,
107
+ totalTokens: 160,
108
+ },
84
109
  })
85
110
  })
86
111
 
@@ -1,6 +1,7 @@
1
1
  import { createLogger } from '@ossy/observability'
2
2
  import { StorageClient } from '@ossy/platform'
3
3
  import { taskArtifactObjectKey } from '@ossy/platform/storage-keys'
4
+ import { assertLlmUsageBudget, resolveWorkspaceIdForBudget } from '@ossy/platform/metering'
4
5
  import { GetResource, placeTaskArtifacts } from '@ossy/resources'
5
6
  import { loadSourceBuffer } from './load-source-buffer.js'
6
7
  import {
@@ -49,11 +50,16 @@ export const metadata = {
49
50
  configurable: true,
50
51
  }
51
52
 
52
- export async function run ({ event, sdk, integrations, inputs }) {
53
+ export async function run ({ event, payload, req, sdk, integrations, inputs }) {
53
54
  const resourceId = event.resourceId
54
55
 
55
56
  log.info(`[media-tasks/tasks/visual-content-descriptors] starting for resource ${resourceId}`)
56
57
 
58
+ // Fail closed before spending anything — ADR 0017's first QuotaPolicy.
59
+ await assertLlmUsageBudget({
60
+ workspaceId: resolveWorkspaceIdForBudget({ event, payload, req }),
61
+ })
62
+
57
63
  const openrouter = integrations?.get?.('openrouter')
58
64
  if (!openrouter) {
59
65
  log.warn('[media-tasks/tasks/visual-content-descriptors] openrouter integration not connected')
@@ -80,7 +86,8 @@ export async function run ({ event, sdk, integrations, inputs }) {
80
86
 
81
87
  // Inline bytes — OpenRouter cannot fetch localhost / auth-gated storage URLs.
82
88
  const imageUrl = toImageDataUrl(sourceBuffer, contentType)
83
- const body = await getVisualContentDescriptors(openrouter, imageUrl, {
89
+ // `usage` is metering-only — never persisted on the artifact/placed document.
90
+ const { usage, ...body } = await getVisualContentDescriptors(openrouter, imageUrl, {
84
91
  model: inputs?.model,
85
92
  })
86
93
  const objectKey = taskArtifactObjectKey(resourceId, CONCEPT, OUTPUT.name)
@@ -108,5 +115,6 @@ export async function run ({ event, sdk, integrations, inputs }) {
108
115
  descriptors: body,
109
116
  placedResourceIds: placed.map((r) => r.id),
110
117
  placedResourceId: placed[0]?.id ?? null,
118
+ usage,
111
119
  }
112
120
  }
@@ -1,48 +0,0 @@
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
- }