@ossy/media-tasks 3.16.0 → 3.18.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 +7 -1
- package/package.json +5 -3
- package/src/extract-colors.schema.js +3 -25
- package/src/llm-usage.js +21 -0
- package/src/llm-usage.spec.js +41 -0
- package/src/resize-common-web.schema.js +3 -25
- package/src/resize-common-web.task.js +2 -2
- package/src/run-prompt.js +12 -9
- package/src/run-prompt.spec.js +28 -1
- package/src/run-prompt.task.js +13 -3
- package/src/visual-content-descriptors.js +10 -1
- package/src/visual-content-descriptors.schema.js +3 -25
- package/src/visual-content-descriptors.spec.js +25 -0
- package/src/visual-content-descriptors.task.js +10 -2
- package/src/run-prompt.schema.js +0 -48
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/
|
|
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.
|
|
3
|
+
"version": "3.18.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/
|
|
20
|
+
"@ossy/platform": "^3.18.0",
|
|
21
|
+
"@ossy/resources": "^3.18.0",
|
|
22
|
+
"@ossy/schema": "^3.18.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": "
|
|
39
|
+
"gitHead": "8ceef98795a01c3b3486034db1d3b72b4350ccf1"
|
|
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
|
}
|
package/src/llm-usage.js
ADDED
|
@@ -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
|
}
|
|
@@ -23,8 +23,8 @@ const Sizes = [
|
|
|
23
23
|
{ width: 48, height: 48, name: 'thumbnailLarge' },
|
|
24
24
|
{ width: 274, height: undefined, name: 'galleryMedium' },
|
|
25
25
|
{ width: 548, height: undefined, name: 'galleryLarge' },
|
|
26
|
-
|
|
27
|
-
{ width:
|
|
26
|
+
// Matches ImageResource / detail maxWidth (960) so the browser downscales instead of upscales.
|
|
27
|
+
{ width: 960, height: undefined, name: 'galleryXLarge' },
|
|
28
28
|
]
|
|
29
29
|
|
|
30
30
|
export const metadata = {
|
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
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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:
|
|
123
|
+
model: usage.model,
|
|
124
|
+
usage,
|
|
122
125
|
}
|
|
123
126
|
}
|
package/src/run-prompt.spec.js
CHANGED
|
@@ -79,13 +79,40 @@ describe('runOpenRouterPrompt', () => {
|
|
|
79
79
|
contextText: 'Name: a.png',
|
|
80
80
|
})
|
|
81
81
|
|
|
82
|
-
expect(result).toEqual({
|
|
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 = {
|
package/src/run-prompt.task.js
CHANGED
|
@@ -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
|
-
|
|
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<{
|
|
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
|
-
|
|
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
|
}
|
package/src/run-prompt.schema.js
DELETED
|
@@ -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
|
-
}
|