@ossy/media-tasks 3.15.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 +43 -24
- package/package.json +6 -4
- package/src/extract-colors.js +71 -0
- package/src/extract-colors.schema.js +21 -0
- package/src/extract-colors.spec.js +44 -0
- package/src/extract-colors.task.js +79 -0
- package/src/llm-usage.js +21 -0
- package/src/llm-usage.spec.js +41 -0
- package/src/load-source-buffer.js +17 -0
- package/src/openrouter.integration.js +30 -0
- package/src/openrouter.integration.spec.js +37 -0
- package/src/resize-common-web.schema.js +16 -5
- package/src/resize-common-web.task.js +59 -31
- package/src/resolve-configured-placement.js +44 -0
- package/src/resolve-configured-placement.spec.js +58 -0
- package/src/run-prompt.action.js +5 -0
- package/src/run-prompt.js +126 -0
- package/src/run-prompt.spec.js +144 -0
- package/src/run-prompt.task.js +197 -0
- package/src/visual-content-descriptors.js +79 -0
- package/src/visual-content-descriptors.schema.js +16 -5
- package/src/visual-content-descriptors.spec.js +130 -0
- package/src/visual-content-descriptors.task.js +99 -13
- package/src/openai.integration.js +0 -74
package/README.md
CHANGED
|
@@ -6,27 +6,32 @@ Tasks in this package are registered with `TaskService.RegisterTask()` at API st
|
|
|
6
6
|
|
|
7
7
|
## Tasks
|
|
8
8
|
|
|
9
|
-
| Task id |
|
|
10
|
-
|
|
11
|
-
| `@ossy/media-tasks/tasks/resize-common-web` |
|
|
12
|
-
| `@ossy/media-tasks/tasks/
|
|
9
|
+
| Task id | Definition trigger (ceiling) | Description |
|
|
10
|
+
|---------|------------------------------|-------------|
|
|
11
|
+
| `@ossy/media-tasks/tasks/resize-common-web` | file `Created` + `image/*` | Common web sizes + BlurHash → storage keys under the source id |
|
|
12
|
+
| `@ossy/media-tasks/tasks/extract-colors` | file `Created` + `image/*` | Dominant color + palette JSON → storage |
|
|
13
|
+
| `@ossy/media-tasks/tasks/visual-content-descriptors` | file `Created` + `image/*` / `video/*` | OpenRouter vision → title/description/tags/alt JSON → storage. Input `model` (config or payload); default `openai/gpt-4o-mini`, overridable per trigger. |
|
|
14
|
+
| `@ossy/media-tasks/tasks/run-prompt` | file `Created` + `text/*` / `image/*` / `application/json`, or `@ossy/resources/schema/markdown` `Created` | OpenRouter chat completion. Inputs `prompt` (required), `model` (default `openai/gpt-4o-mini`), optional `temperature`. File bytes or the Markdown document `body` are the context. |
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
All four set **`configurable: true`**. Workspaces arm them on `/automation` with their own `triggers[]` (within the ceiling above) and optional placement folders. Until armed, they do not run.
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
## Task outputs
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
1. **Artifact** — storage key `{resourceId}:task-{concept}-{outputName}`; read via `GET /r/:id/tasks/:concept/:name`
|
|
21
|
+
2. **Placement** — `placeTaskArtifacts` creates a document **only when** `outputs[].prefix` or workspace `placementLocations` are set (no default folder)
|
|
19
22
|
|
|
20
|
-
|
|
23
|
+
| Task | Output | Placed schema |
|
|
24
|
+
|------|--------|---------------|
|
|
25
|
+
| extract-colors | `palette` | `@ossy/media-tasks/schema/extract-colors` |
|
|
26
|
+
| resize-common-web | `sizes` (+ binary keys) | `@ossy/media-tasks/schema/resize-common-web` (JSON meta placed; binaries via URL) |
|
|
27
|
+
| visual-content-descriptors | `descriptors` | `@ossy/media-tasks/schema/visual-content-descriptors` |
|
|
28
|
+
| run-prompt | `completion` | `@ossy/platform/schema/text-completion` (freeform text/md — a platform schema, not feature-owned) |
|
|
21
29
|
|
|
22
|
-
|
|
30
|
+
See [TaskOutput.md](../../docs/concepts/TaskOutput.md).
|
|
23
31
|
|
|
24
|
-
|
|
25
|
-
|--------|----|
|
|
26
|
-
| Resize Common Web | `@ossy/media-tasks/schema/resize-common-web` |
|
|
27
|
-
| Visual Content Descriptors | `@ossy/media-tasks/schema/visual-content-descriptors` |
|
|
32
|
+
## Image placeholders (BlurHash)
|
|
28
33
|
|
|
29
|
-
|
|
34
|
+
`resize-common-web` stores BlurHash + dimensions on the **`sizes`** JSON artifact. Consumers use `resizeImageMeta` / `resizeArtifactSrc` from `@ossy/resources` after media URL attach.
|
|
30
35
|
|
|
31
36
|
## Task contract
|
|
32
37
|
|
|
@@ -36,21 +41,35 @@ Each `*.task.js` file exports:
|
|
|
36
41
|
export const metadata = {
|
|
37
42
|
id: '@ossy/media-tasks/tasks/my-task',
|
|
38
43
|
triggers: [{ type, event, match?, location? }],
|
|
44
|
+
inputs: [{ name: 'model', type: 'text', default: 'openai/gpt-4o-mini', sources: ['payload', 'config'] }],
|
|
45
|
+
outputs: [{ name, kind: 'location', schemaId, contentType }],
|
|
46
|
+
configurable: true,
|
|
39
47
|
}
|
|
40
48
|
|
|
41
|
-
export async function run ({ event, sdk }) { ... }
|
|
49
|
+
export async function run ({ event, sdk, inputs }) { ... }
|
|
42
50
|
```
|
|
43
51
|
|
|
44
|
-
- `
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
`configurable: true` opts the task into Automation UI workspace triggers, input defaults, per-trigger overrides, and output placement. Omit it for system tasks. Definition `triggers` are the capability ceiling; workspaces author their own `triggers[]`. Each trigger’s `config` overrides instance `config` for that match only. `run` reads the resolved values from `inputs`.
|
|
53
|
+
|
|
54
|
+
## Integrations
|
|
55
|
+
|
|
56
|
+
| Id | Credentials | Used by |
|
|
57
|
+
|----|-------------|---------|
|
|
58
|
+
| `openrouter` | `OPENROUTER_API_KEY` (required); optional `OPENROUTER_SITE_URL`, `OPENROUTER_APP_TITLE`, `OPENROUTER_BASE_URL` | `visual-content-descriptors` and `run-prompt` via `integrations.get('openrouter')` |
|
|
59
|
+
|
|
60
|
+
OpenRouter is OpenAI-compatible (`baseURL` `https://openrouter.ai/api/v1`). The VCD `model` input defaults to `openai/gpt-4o-mini`. A workspace can set another slug as the instance default, and a different slug on a single trigger (folder or MIME). An invoke payload still wins.
|
|
61
|
+
|
|
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
|
+
|
|
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`.
|
|
50
69
|
|
|
51
70
|
## Dependencies
|
|
52
71
|
|
|
53
|
-
- **`sharp`** (peer) — image resizing + metadata
|
|
72
|
+
- **`sharp`** (peer) — image resizing + metadata
|
|
54
73
|
- **`blurhash`** — encode compact placeholders after resize
|
|
55
|
-
- **`openai`** (peer) —
|
|
56
|
-
- **`@ossy/platform`** — `StorageClient
|
|
74
|
+
- **`openai`** (peer) — OpenAI SDK client pointed at OpenRouter
|
|
75
|
+
- **`@ossy/platform`** — `StorageClient` for reading/writing file bytes
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/media-tasks",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "Changestream-triggered media processing tasks (image resize, AI descriptors)",
|
|
3
|
+
"version": "3.17.0",
|
|
4
|
+
"description": "Changestream-triggered media processing tasks (image resize, AI descriptors, prompts)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"module": "./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.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": "
|
|
39
|
+
"gitHead": "91ff7dffe9b0bddcb823daee67213d2008eb64e9"
|
|
38
40
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {number} r
|
|
3
|
+
* @param {number} g
|
|
4
|
+
* @param {number} b
|
|
5
|
+
*/
|
|
6
|
+
function toHex (r, g, b) {
|
|
7
|
+
return `#${[r, g, b].map(c => c.toString(16).padStart(2, '0')).join('')}`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Expand a 4-bit channel (0–15) to 0–255. */
|
|
11
|
+
function expandNibble (nibble) {
|
|
12
|
+
return nibble * 17
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Extract a small palette from an image buffer via sharp (resize + 4-bit RGB buckets).
|
|
17
|
+
*
|
|
18
|
+
* @param {import('sharp')} sharp
|
|
19
|
+
* @param {Buffer} sourceBuffer
|
|
20
|
+
* @param {{ maxColors?: number, sampleSize?: number }} [options]
|
|
21
|
+
* @returns {Promise<{ dominant: string | null, colors: Array<{ hex: string, ratio: number }> }>}
|
|
22
|
+
*/
|
|
23
|
+
export async function extractColorsFromBuffer (sharp, sourceBuffer, options = {}) {
|
|
24
|
+
const maxColors = options.maxColors ?? 5
|
|
25
|
+
const sampleSize = options.sampleSize ?? 64
|
|
26
|
+
|
|
27
|
+
const { data, info } = await sharp(sourceBuffer)
|
|
28
|
+
.ensureAlpha()
|
|
29
|
+
.resize(sampleSize, sampleSize, { fit: 'inside' })
|
|
30
|
+
.raw()
|
|
31
|
+
.toBuffer({ resolveWithObject: true })
|
|
32
|
+
|
|
33
|
+
const channels = info.channels
|
|
34
|
+
/** @type {Map<number, number>} */
|
|
35
|
+
const buckets = new Map()
|
|
36
|
+
let visible = 0
|
|
37
|
+
|
|
38
|
+
for (let i = 0; i < data.length; i += channels) {
|
|
39
|
+
const a = channels === 4 ? data[i + 3] : 255
|
|
40
|
+
if (a < 128) continue
|
|
41
|
+
visible += 1
|
|
42
|
+
const r = data[i] >> 4
|
|
43
|
+
const g = data[i + 1] >> 4
|
|
44
|
+
const b = data[i + 2] >> 4
|
|
45
|
+
const key = (r << 8) | (g << 4) | b
|
|
46
|
+
buckets.set(key, (buckets.get(key) || 0) + 1)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (visible === 0) {
|
|
50
|
+
return { dominant: null, colors: [] }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ranked = [...buckets.entries()]
|
|
54
|
+
.sort((a, b) => b[1] - a[1])
|
|
55
|
+
.slice(0, maxColors)
|
|
56
|
+
|
|
57
|
+
const colors = ranked.map(([key, count]) => {
|
|
58
|
+
const r = expandNibble((key >> 8) & 0xf)
|
|
59
|
+
const g = expandNibble((key >> 4) & 0xf)
|
|
60
|
+
const b = expandNibble(key & 0xf)
|
|
61
|
+
return {
|
|
62
|
+
hex: toHex(r, g, b),
|
|
63
|
+
ratio: Math.round((count / visible) * 1000) / 1000,
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
dominant: colors[0]?.hex ?? null,
|
|
69
|
+
colors,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { TASK_OUTPUT_PROVENANCE_FIELDS } from '@ossy/schema'
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
name: 'Extract Colors',
|
|
5
|
+
id: '@ossy/media-tasks/schema/extract-colors',
|
|
6
|
+
categoryName: 'Media processing',
|
|
7
|
+
icon: 'color-picker',
|
|
8
|
+
fields: [
|
|
9
|
+
{
|
|
10
|
+
name: 'dominant',
|
|
11
|
+
type: 'text',
|
|
12
|
+
description: 'Dominant hex color',
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: 'colors',
|
|
16
|
+
type: 'textarea',
|
|
17
|
+
description: 'Palette entries as JSON ({ hex, ratio }[])',
|
|
18
|
+
},
|
|
19
|
+
...TASK_OUTPUT_PROVENANCE_FIELDS,
|
|
20
|
+
],
|
|
21
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import sharp from 'sharp'
|
|
2
|
+
import { extractColorsFromBuffer } from './extract-colors.js'
|
|
3
|
+
|
|
4
|
+
describe('extractColorsFromBuffer', () => {
|
|
5
|
+
it('returns the dominant color for a solid image', async () => {
|
|
6
|
+
const sourceBuffer = await sharp({
|
|
7
|
+
create: {
|
|
8
|
+
width: 40,
|
|
9
|
+
height: 40,
|
|
10
|
+
channels: 3,
|
|
11
|
+
background: { r: 20, g: 120, b: 200 },
|
|
12
|
+
},
|
|
13
|
+
})
|
|
14
|
+
.png()
|
|
15
|
+
.toBuffer()
|
|
16
|
+
|
|
17
|
+
const result = await extractColorsFromBuffer(sharp, sourceBuffer)
|
|
18
|
+
|
|
19
|
+
expect(result.dominant).toMatch(/^#[0-9a-f]{6}$/)
|
|
20
|
+
expect(result.colors.length).toBeGreaterThanOrEqual(1)
|
|
21
|
+
expect(result.colors[0].hex).toBe(result.dominant)
|
|
22
|
+
expect(result.colors[0].ratio).toBeGreaterThan(0.9)
|
|
23
|
+
|
|
24
|
+
// 20→17, 120→119, 200→204 after 4-bit quantization (*17)
|
|
25
|
+
expect(result.dominant).toBe('#1177cc')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('ignores fully transparent pixels', async () => {
|
|
29
|
+
const sourceBuffer = await sharp({
|
|
30
|
+
create: {
|
|
31
|
+
width: 16,
|
|
32
|
+
height: 16,
|
|
33
|
+
channels: 4,
|
|
34
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
.png()
|
|
38
|
+
.toBuffer()
|
|
39
|
+
|
|
40
|
+
const result = await extractColorsFromBuffer(sharp, sourceBuffer)
|
|
41
|
+
expect(result.dominant).toBeNull()
|
|
42
|
+
expect(result.colors).toEqual([])
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
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 { extractColorsFromBuffer } from './extract-colors.js'
|
|
6
|
+
import { loadSourceBuffer } from './load-source-buffer.js'
|
|
7
|
+
import { resolveConfiguredPlacements } from './resolve-configured-placement.js'
|
|
8
|
+
|
|
9
|
+
const log = createLogger('media-tasks')
|
|
10
|
+
|
|
11
|
+
const TASK_ID = '@ossy/media-tasks/tasks/extract-colors'
|
|
12
|
+
const SCHEMA_ID = '@ossy/media-tasks/schema/extract-colors'
|
|
13
|
+
const CONCEPT = 'extract-colors'
|
|
14
|
+
const OUTPUT = {
|
|
15
|
+
name: 'palette',
|
|
16
|
+
kind: 'location',
|
|
17
|
+
schemaId: SCHEMA_ID,
|
|
18
|
+
contentType: 'application/json',
|
|
19
|
+
// prefix omitted — place only when workspace config supplies a location
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const metadata = {
|
|
23
|
+
id: TASK_ID,
|
|
24
|
+
triggers: [
|
|
25
|
+
{
|
|
26
|
+
type: '@ossy/platform/schema/file',
|
|
27
|
+
event: 'Created',
|
|
28
|
+
match: { 'content.ContentType': 'image/*' },
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
outputs: [OUTPUT],
|
|
32
|
+
configurable: true,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function run ({ event, sdk }) {
|
|
36
|
+
const { default: sharp } = await import('sharp')
|
|
37
|
+
const resourceId = event.resourceId
|
|
38
|
+
|
|
39
|
+
log.info(`[media-tasks/tasks/extract-colors] starting for resource ${resourceId}`)
|
|
40
|
+
|
|
41
|
+
const resource = await sdk.invoke(GetResource, { resourceId })
|
|
42
|
+
const key = resource?.content?.Key
|
|
43
|
+
if (!key) {
|
|
44
|
+
throw new Error(`[media-tasks/tasks/extract-colors] missing storage key for ${resourceId}`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const sourceBuffer = await loadSourceBuffer(key)
|
|
48
|
+
if (!sourceBuffer) {
|
|
49
|
+
throw new Error(`[media-tasks/tasks/extract-colors] missing file bytes for ${resourceId}`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const palette = await extractColorsFromBuffer(sharp, sourceBuffer)
|
|
53
|
+
const objectKey = taskArtifactObjectKey(resourceId, CONCEPT, OUTPUT.name)
|
|
54
|
+
await StorageClient.save(objectKey, Buffer.from(JSON.stringify(palette), 'utf8'))
|
|
55
|
+
log.info(`[media-tasks/tasks/extract-colors] saved ${objectKey} dominant=${palette.dominant}`)
|
|
56
|
+
|
|
57
|
+
const placementLocations = await resolveConfiguredPlacements(sdk, TASK_ID, OUTPUT.name)
|
|
58
|
+
const placed = await placeTaskArtifacts({
|
|
59
|
+
sdk,
|
|
60
|
+
sourceResource: resource,
|
|
61
|
+
taskId: TASK_ID,
|
|
62
|
+
output: OUTPUT,
|
|
63
|
+
body: palette,
|
|
64
|
+
artifactKey: objectKey,
|
|
65
|
+
placementLocations,
|
|
66
|
+
})
|
|
67
|
+
if (placed.length) {
|
|
68
|
+
log.info(`[media-tasks/tasks/extract-colors] placed ${placed.length} resource(s): ${placed.map((r) => r.id).join(', ')}`)
|
|
69
|
+
} else {
|
|
70
|
+
log.info(`[media-tasks/tasks/extract-colors] artifact only (no placement location configured)`)
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
output: OUTPUT.name,
|
|
74
|
+
key: objectKey,
|
|
75
|
+
placedResourceIds: placed.map((r) => r.id),
|
|
76
|
+
placedResourceId: placed[0]?.id ?? null,
|
|
77
|
+
dominant: palette.dominant,
|
|
78
|
+
}
|
|
79
|
+
}
|
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
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { StorageClient } from '@ossy/platform'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Created fires before the client finishes PUT; wait briefly for blob bytes.
|
|
5
|
+
*
|
|
6
|
+
* @param {string} key
|
|
7
|
+
* @param {{ attempts?: number, delayMs?: number }} [options]
|
|
8
|
+
* @returns {Promise<Buffer | null>}
|
|
9
|
+
*/
|
|
10
|
+
export async function loadSourceBuffer (key, { attempts = 20, delayMs = 250 } = {}) {
|
|
11
|
+
for (let i = 0; i < attempts; i++) {
|
|
12
|
+
const { buffer, exists } = await StorageClient.load(key)
|
|
13
|
+
if (exists && buffer) return buffer
|
|
14
|
+
await new Promise(resolve => setTimeout(resolve, delayMs))
|
|
15
|
+
}
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import OpenAiSDK from 'openai'
|
|
2
|
+
|
|
3
|
+
export const id = 'openrouter'
|
|
4
|
+
|
|
5
|
+
export const credentials = ['OPENROUTER_API_KEY']
|
|
6
|
+
|
|
7
|
+
const DEFAULT_BASE_URL = 'https://openrouter.ai/api/v1'
|
|
8
|
+
const DEFAULT_SITE_URL = 'https://ossy.se'
|
|
9
|
+
const DEFAULT_APP_TITLE = 'Ossy'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Returns an OpenAI-compatible client pointed at OpenRouter.
|
|
13
|
+
* Tasks receive this via `integrations.get('openrouter')`.
|
|
14
|
+
*
|
|
15
|
+
* @param {{ env: NodeJS.ProcessEnv }} opts
|
|
16
|
+
* @returns {import('openai').OpenAI}
|
|
17
|
+
*/
|
|
18
|
+
export async function connect ({ env }) {
|
|
19
|
+
const siteUrl = env.OPENROUTER_SITE_URL || env.WEB_CLIENT_DOMAIN || DEFAULT_SITE_URL
|
|
20
|
+
const appTitle = env.OPENROUTER_APP_TITLE || DEFAULT_APP_TITLE
|
|
21
|
+
|
|
22
|
+
return new OpenAiSDK({
|
|
23
|
+
apiKey: env.OPENROUTER_API_KEY,
|
|
24
|
+
baseURL: env.OPENROUTER_BASE_URL || DEFAULT_BASE_URL,
|
|
25
|
+
defaultHeaders: {
|
|
26
|
+
'HTTP-Referer': siteUrl,
|
|
27
|
+
'X-OpenRouter-Title': appTitle,
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { connect, credentials, id } from './openrouter.integration.js'
|
|
2
|
+
|
|
3
|
+
describe('openrouter.integration', () => {
|
|
4
|
+
it('exports id and required credentials', () => {
|
|
5
|
+
expect(id).toBe('openrouter')
|
|
6
|
+
expect(credentials).toEqual(['OPENROUTER_API_KEY'])
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('connects an OpenAI-compatible client to OpenRouter', async () => {
|
|
10
|
+
const client = await connect({
|
|
11
|
+
env: {
|
|
12
|
+
OPENROUTER_API_KEY: 'test-key',
|
|
13
|
+
WEB_CLIENT_DOMAIN: 'https://example.test',
|
|
14
|
+
OPENROUTER_APP_TITLE: 'Ossy Test',
|
|
15
|
+
},
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
expect(client.apiKey).toBe('test-key')
|
|
19
|
+
expect(client.baseURL).toBe('https://openrouter.ai/api/v1')
|
|
20
|
+
expect(client._options.defaultHeaders['HTTP-Referer']).toBe('https://example.test')
|
|
21
|
+
expect(client._options.defaultHeaders['X-OpenRouter-Title']).toBe('Ossy Test')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('allows overriding base URL and site attribution', async () => {
|
|
25
|
+
const client = await connect({
|
|
26
|
+
env: {
|
|
27
|
+
OPENROUTER_API_KEY: 'k',
|
|
28
|
+
OPENROUTER_BASE_URL: 'https://openrouter.example/v1',
|
|
29
|
+
OPENROUTER_SITE_URL: 'https://custom.site',
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
expect(client.baseURL).toBe('https://openrouter.example/v1')
|
|
34
|
+
expect(client._options.defaultHeaders['HTTP-Referer']).toBe('https://custom.site')
|
|
35
|
+
expect(client._options.defaultHeaders['X-OpenRouter-Title']).toBe('Ossy')
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -1,20 +1,31 @@
|
|
|
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',
|
|
4
6
|
categoryName: 'Media processing',
|
|
5
|
-
icon: '
|
|
7
|
+
icon: 'screen-shot',
|
|
6
8
|
fields: [
|
|
7
9
|
{
|
|
8
|
-
name: '
|
|
10
|
+
name: 'blurhash',
|
|
9
11
|
type: 'text',
|
|
12
|
+
description: 'BlurHash placeholder string',
|
|
10
13
|
},
|
|
11
14
|
{
|
|
12
|
-
name: '
|
|
13
|
-
type: '
|
|
15
|
+
name: 'width',
|
|
16
|
+
type: 'number',
|
|
17
|
+
description: 'Natural image width',
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'height',
|
|
21
|
+
type: 'number',
|
|
22
|
+
description: 'Natural image height',
|
|
14
23
|
},
|
|
15
24
|
{
|
|
16
|
-
name: '
|
|
25
|
+
name: 'artifacts',
|
|
17
26
|
type: 'textarea',
|
|
27
|
+
description: 'Named derivative artifacts (thumbnail/gallery sizes)',
|
|
18
28
|
},
|
|
29
|
+
...TASK_OUTPUT_PROVENANCE_FIELDS,
|
|
19
30
|
],
|
|
20
31
|
}
|
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
import { createLogger } from '@ossy/observability'
|
|
2
2
|
import { StorageClient } from '@ossy/platform'
|
|
3
|
-
import {
|
|
3
|
+
import { taskArtifactObjectKey } from '@ossy/platform/storage-keys'
|
|
4
|
+
import { GetResource, placeTaskArtifacts } from '@ossy/resources'
|
|
4
5
|
import { encodeBlurhashFromBuffer } from './encode-blurhash.js'
|
|
6
|
+
import { loadSourceBuffer } from './load-source-buffer.js'
|
|
7
|
+
import { resolveConfiguredPlacements } from './resolve-configured-placement.js'
|
|
5
8
|
|
|
6
9
|
const log = createLogger('media-tasks')
|
|
7
10
|
|
|
11
|
+
const TASK_ID = '@ossy/media-tasks/tasks/resize-common-web'
|
|
12
|
+
const SCHEMA_ID = '@ossy/media-tasks/schema/resize-common-web'
|
|
13
|
+
const CONCEPT = 'resize-common-web'
|
|
14
|
+
const OUTPUT = {
|
|
15
|
+
name: 'sizes',
|
|
16
|
+
kind: 'location',
|
|
17
|
+
schemaId: SCHEMA_ID,
|
|
18
|
+
contentType: 'application/json',
|
|
19
|
+
}
|
|
20
|
+
|
|
8
21
|
const Sizes = [
|
|
9
22
|
{ width: 24, height: 24, name: 'thumbnailSmall' },
|
|
10
23
|
{ width: 48, height: 48, name: 'thumbnailLarge' },
|
|
@@ -14,18 +27,8 @@ const Sizes = [
|
|
|
14
27
|
{ width: 4, height: undefined, name: 'loader-square-blurred-after' },
|
|
15
28
|
]
|
|
16
29
|
|
|
17
|
-
/** Created fires before the client finishes PUT; wait briefly for blob bytes. */
|
|
18
|
-
async function loadSourceBuffer (key, { attempts = 20, delayMs = 250 } = {}) {
|
|
19
|
-
for (let i = 0; i < attempts; i++) {
|
|
20
|
-
const { buffer, exists } = await StorageClient.load(key)
|
|
21
|
-
if (exists && buffer) return buffer
|
|
22
|
-
await new Promise(resolve => setTimeout(resolve, delayMs))
|
|
23
|
-
}
|
|
24
|
-
return null
|
|
25
|
-
}
|
|
26
|
-
|
|
27
30
|
export const metadata = {
|
|
28
|
-
id:
|
|
31
|
+
id: TASK_ID,
|
|
29
32
|
triggers: [
|
|
30
33
|
{
|
|
31
34
|
type: '@ossy/platform/schema/file',
|
|
@@ -33,6 +36,8 @@ export const metadata = {
|
|
|
33
36
|
match: { 'content.ContentType': 'image/*' },
|
|
34
37
|
},
|
|
35
38
|
],
|
|
39
|
+
outputs: [OUTPUT],
|
|
40
|
+
configurable: true,
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
export async function run ({ event, sdk }) {
|
|
@@ -52,31 +57,54 @@ export async function run ({ event, sdk }) {
|
|
|
52
57
|
throw new Error(`[media-tasks/tasks/resize-common-web] missing file bytes for ${resourceId}`)
|
|
53
58
|
}
|
|
54
59
|
|
|
60
|
+
const mime = resource.content?.ContentType ?? 'image/jpeg'
|
|
61
|
+
/** @type {Record<string, { key: string, contentType: string, width?: number, height?: number }>} */
|
|
62
|
+
const artifacts = {}
|
|
63
|
+
|
|
55
64
|
log.info(`[media-tasks/tasks/resize-common-web] Resizing ${resourceId}`)
|
|
56
65
|
|
|
57
66
|
for (const { width, height, name } of Sizes) {
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
const
|
|
67
|
+
const resized = sharp(sourceBuffer).resize(width, height)
|
|
68
|
+
const { data, info } = await resized.toBuffer({ resolveWithObject: true })
|
|
69
|
+
const objectKey = taskArtifactObjectKey(resourceId, CONCEPT, name)
|
|
70
|
+
|
|
71
|
+
log.info(`[media-tasks/tasks/resize-common-web] Saving ${name} for ${resourceId}`)
|
|
72
|
+
await StorageClient.save(objectKey, data)
|
|
61
73
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
throw error
|
|
74
|
+
artifacts[name] = {
|
|
75
|
+
key: objectKey,
|
|
76
|
+
contentType: mime,
|
|
77
|
+
...(info.width != null ? { width: info.width } : {}),
|
|
78
|
+
...(info.height != null ? { height: info.height } : {}),
|
|
68
79
|
}
|
|
69
80
|
}
|
|
70
81
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
82
|
+
const { blurhash, width, height } = await encodeBlurhashFromBuffer(sharp, sourceBuffer)
|
|
83
|
+
const sizesBody = { artifacts, blurhash, width, height }
|
|
84
|
+
const sizesKey = taskArtifactObjectKey(resourceId, CONCEPT, OUTPUT.name)
|
|
85
|
+
await StorageClient.save(sizesKey, Buffer.from(JSON.stringify(sizesBody), 'utf8'))
|
|
86
|
+
log.info(`[media-tasks/tasks/resize-common-web] saved ${sizesKey}`)
|
|
87
|
+
|
|
88
|
+
const placementLocations = await resolveConfiguredPlacements(sdk, TASK_ID, OUTPUT.name)
|
|
89
|
+
const placed = await placeTaskArtifacts({
|
|
90
|
+
sdk,
|
|
91
|
+
sourceResource: resource,
|
|
92
|
+
taskId: TASK_ID,
|
|
93
|
+
output: OUTPUT,
|
|
94
|
+
body: sizesBody,
|
|
95
|
+
artifactKey: sizesKey,
|
|
96
|
+
placementLocations,
|
|
97
|
+
})
|
|
98
|
+
if (placed.length) {
|
|
99
|
+
log.info(`[media-tasks/tasks/resize-common-web] placed ${placed.length} resource(s)`)
|
|
100
|
+
} else {
|
|
101
|
+
log.info(`[media-tasks/tasks/resize-common-web] artifact only (no placement location configured)`)
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
output: OUTPUT.name,
|
|
105
|
+
key: sizesKey,
|
|
106
|
+
placedResourceIds: placed.map((r) => r.id),
|
|
107
|
+
placedResourceId: placed[0]?.id ?? null,
|
|
108
|
+
artifactCount: Object.keys(artifacts).length,
|
|
81
109
|
}
|
|
82
110
|
}
|