openvibers/openvibe.ai Commits
AI: one shared image task (find or make, into Media) (#428)
Changes
nnew file mode 100644@@@ -0,0 +1,67 @@++# The shared image task (`image.picture`)++++One workflow, two modes, for Blog's and Wiki's editors and writers. Every picture is stored in++OpenVibe.Media and returned by reference (`med_…`); no image bytes are ever inline in a run's output.++++```++POST /api/v1/runs?wait=60000++{++ "workflow": "image.picture",++ "task": "image.picture",++ "on_behalf_of": { "type": "user", "id": "usr_…" },++ "input": { "mode": "find", "query": "a workbench", "count": 8, "media_app": "blog" }++}++```++++## find++++Licensed pictures from Wikimedia Commons and Openverse. Only a picture with a licence that allows++reuse is fetched and stored; the licence, the author and the source page are kept in the Media++object's metadata and in the answer. The owner's engine order (`search_engines`) and guest rule++(`guest_find`) apply; signed-in editors may always find.++++```json++{ "mode": "find", "task": "image.picture", "query": "a workbench", "media": [++ { "media_id": "med_…", "url": "https://openvibe.media/o/med_…", "title": "A workbench",++ "license": "CC BY-SA 4.0", "author": "Ada Example", "credit": "Ada Example, CC BY-SA 4.0",++ "source_url": "https://commons.wikimedia.org/wiki/File:…", "provider": "commons", "ai": false }++], "count": 1, "gaps": [] }++```++++## make++++The owner's configured image model, through the ordinary provider routing. **Off until the owner++picks a model**: the task default `image_model` is `null` at seed and make answers++`503 image.make_unconfigured` until it is set.++++A dry run quotes first and calls no provider:++++```++"input": { "mode": "make", "prompt": "A goose eating croutons", "n": 2, "dry_run": true }++→ "estimate": { "model": "openai/gpt-image-1", "source": "openvibe", "images": 2,++ "per_image_usd": 0.04, "price_usd": 0.08 }, "media": []++```++++A real make reserves the run, calls the model, uploads each picture to Media and settles the run at++`per_image_usd × images` (the model row's `metadata.price_per_image_usd`). A paid, unpriced image++offer is refused (`402 image.price_required`) rather than guessed at. The owner's `daily_cap` limits++made pictures per person per UTC day (`429 image.daily_cap`), and a made picture carries++`"ai": true`, `"model": "…"` and the credit `Made with AI (<model>)` in its caption data.++++## Owner defaults (API)++++`GET|PUT /api/v1/admin/task-defaults`, owner only. The `image.picture` value adds:++++| field | meaning |++|---|---|++| `image_model` | the model make uses; `null` keeps make off |++| `daily_cap` | made pictures per person per UTC day; `null` means the service default (24) |++| `guest_find` | whether a run with no person may use find (seed: `false`) |++| `search_engines` | find's engine order, Commons/Openverse |++| `max_cost_usd` | a per-run ceiling for one make |++++## Media tenant++++`media_app` names the Media tenant: `blog` or `wiki` (their editors can attach the object by id),++or absent for the AI tenant. Network grants the AI service `media.object.upload` in the `ai`, `blog`++and `wiki` namespaces; Media seeds the `ai` tenant at boot.@@@ -13,7 +13,7 @@ const { PROVIDERS: ENGINES } = require('../research');const KINDS = ['text', 'code', 'vision', 'image', 'search'];const SOURCES = ['openvibe', 'keys', 'machines'];--const GENERAL = ['ai.generate', 'ai.complete', 'ai.image', 'research.gather'];++const GENERAL = ['ai.generate', 'ai.complete', 'ai.image', 'image.picture', 'research.gather'];const MAX_COST_USD = 1000;const CACHE_MS = 60000;@@@ -25,6 +25,7 @@ const SEEDS = [['blog.devblog.art', 'image', 'Draw title art for a devblog.', 'ai.image'],['wiki.article.write', 'text', 'Write a sourced wiki article.', 'ai.generate'],['wiki.article.images', 'search', 'Find licensed pictures for a wiki article.', 'research.gather'],++ ['image.picture', 'image', 'Find a licensed picture or make one, stored in Media.', 'image.picture'],['design.icon', 'code', 'Draw an original SVG icon.', 'ai.chat'],['chat.reply', 'text', 'Answer a chat message.', 'ai.complete'],['image.generate', 'image', 'Make an image from a description.', 'ai.image'],@@@ -64,7 +65,7 @@ function choice(value) {function defaultValue(value) {const keys = value && typeof value === 'object' && !Array.isArray(value) ? Object.keys(value) : [];if (!value || typeof value !== 'object' || Array.isArray(value)-- || keys.some((k) => !['signed_out', 'signed_in', 'fallbacks', 'image_model', 'search_engines', 'max_cost_usd', 'effort'].includes(k))++ || keys.some((k) => !['signed_out', 'signed_in', 'fallbacks', 'image_model', 'search_engines', 'max_cost_usd', 'daily_cap', 'guest_find', 'effort'].includes(k))|| (value.fallbacks != null && !Array.isArray(value.fallbacks))) {throw new AiError(422, 'defaults.invalid', 'invalid task defaults');}@@@ -77,6 +78,10 @@ function defaultValue(value) {image_model: value.image_model == null ? null : choice(value.image_model),search_engines: value.search_engines == null ? [] : value.search_engines,max_cost_usd: value.max_cost_usd == null ? null : value.max_cost_usd,++ // The image task's two extra owner controls: a whole-picture daily cap per person (made pictures,++ // null for no cap) and whether a guest with no person may use find.++ daily_cap: value.daily_cap == null ? null : value.daily_cap,++ guest_find: value.guest_find === true,};const all = [out.signed_out, out.signed_in, ...out.fallbacks, ...(out.image_model ? [out.image_model] : [])];if (all.some((c) => c.source !== 'openvibe')) throw new AiError(422, 'defaults.invalid', 'the owner’s defaults use OpenVibe supply');@@@ -90,6 +95,9 @@ function defaultValue(value) {|| out.max_cost_usd < 0 || out.max_cost_usd > MAX_COST_USD)) {throw new AiError(422, 'defaults.invalid', `max_cost_usd must be 0–${MAX_COST_USD} or null`);}++ if (out.daily_cap !== null && (!Number.isInteger(out.daily_cap) || out.daily_cap < 0 || out.daily_cap > 1000)) {++ throw new AiError(422, 'defaults.invalid', 'daily_cap must be a whole number of pictures from 0 to 1000, or null');++ }return out;}@@@ -116,6 +124,11 @@ function createChoices({ db, registry, preferences, credentials, machines, catal// and in, with a small per-batch ceiling. The owner can change it on Exchange like any task default.await db.prepare('INSERT INTO ai_task_defaults (task, value, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT (task) DO NOTHING').run('blog.changelog.tag', JSON.stringify({ signed_out: built('openvibe/cheapest'), signed_in: built('openvibe/cheapest'), fallbacks: [built('openvibe/cheapest')], image_model: null, search_engines: [], max_cost_usd: 0.1 }), 'seed', iso(clock.now()));++ // The shared image task (rich-content plan §4/§5): find is on for signed-in editors and off for guests++ // until the owner says otherwise; make is off until the owner picks an image model (image_model: null).++ const pictureEngines = [{ engine: 'commons', enabled: true }, { engine: 'openverse', enabled: true }];++ await db.prepare('INSERT INTO ai_task_defaults (task, value, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT (task) DO NOTHING')++ .run('image.picture', JSON.stringify({ signed_out: built('openvibe/auto'), signed_in: built('openvibe/auto'), fallbacks: [], image_model: null, search_engines: pictureEngines, max_cost_usd: null, daily_cap: 24, guest_find: false }), 'seed', iso(clock.now()));}async function register(body, service) {@@@ -207,6 +220,11 @@ function createChoices({ db, registry, preferences, credentials, machines, catalfallbacks: picked.why === 'owner' ? owner.fallbacks : [],max_cost_usd: owner ? owner.max_cost_usd : null,search_engines: owner ? owner.search_engines : [],++ // image.picture's two modes: the owner's model is the make switch (null = off), and the cap and++ // guest rule are read by the step itself.++ image_model: owner && owner.image_model ? owner.image_model : null,++ daily_cap: owner && owner.daily_cap != null ? owner.daily_cap : null,++ guest_find: Boolean(owner && owner.guest_find),};}@@@ -262,6 +262,15 @@ function load(env = process.env) {// Image generation (POST /api/v1/image, workflow ai.image): off until AI_IMAGE_MODEL names the shared// provider's image model (dall-e-3, gpt-image-1); then boot seeds that model and route default.image.image: { model: String(env.AI_IMAGE_MODEL || '').trim(), size: String(env.AI_IMAGE_SIZE || '').trim() || '1024x1024' },++ // The image task (workflow image.picture, rich-content plan §4/§5): pictures found on Wikimedia Commons and++ // Openverse are fetched only from these hosts (the same SSRF rules as every other fetch) and stored in++ // Media under AI_MEDIA_APP. An image model's per-image price lives on the model row's++ // metadata.price_per_image_usd; the task refuses a paid, unpriced offer rather than guess.++ pictures: {++ sourceHosts: list(env.AI_PICTURE_SOURCE_HOSTS, ['upload.wikimedia.org', '*.wikimedia.org', 'api.openverse.org', '*.openverse.org']),++ providerHosts: list(env.AI_PICTURE_PROVIDER_HOSTS, ['*.openai.com', '*.googleusercontent.com', '*.fal.media', '*.replicate.delivery']),++ defaultDailyCap: int(env.AI_PICTURE_DAILY_CAP, 24),++ },// MegaSource (server/research/, workflow research.gather): web search asks the Search Fabric's web scope// first (OpenVibe.Search POST /api/v1/query, capability search.fabric.query) as AI's service principal;// DeepSeek's paid search stays as the fallback when the fabric is off, refuses or answers nothing. The@@@ -459,6 +468,9 @@ function load(env = process.env) {media: {publicUrl: trimUrl(env.OV_MEDIA_URL || 'https://openvibe.media'),internalUrl: trimUrl(env.OV_MEDIA_INTERNAL_URL || ''),++ // The Media tenant (and namespace) AI's pictures are stored in. Network grants the AI service++ // media.object.upload there; Media seeds the tenant at boot.++ app: String(env.AI_MEDIA_APP || 'ai').trim() || 'ai',// https hostnames (exact, or *.suffix) that media/image URLs may point at.allowHosts: list(env.AI_FETCH_ALLOW_HOSTS, ['openvibe.media', 'openvibe.live', 'openvibe.network', '*.openvibe.network', 'openvibe.community', 'openvibe.tools', 'openvibe.games']),// Object storage Media hands recordings off to (a 302 to a presigned URL), and Media's origin shield@@@ -71,16 +71,22 @@ function hostAllowed(hostname, allowHosts) {function createFetcher(config, { transport = null } = {}) {const m = config.media;const internalOrigin = m.internalUrl ? new URL(m.internalUrl).origin : null;++ // Picture sources (Commons, Openverse) and image providers are separate, narrow lists: a caller's++ // media URL never gets them, only the image task's own fetchPicture does.++ const pictureHosts = [++ ...((config.pictures && config.pictures.sourceHosts) || []),++ ...((config.pictures && config.pictures.providerHosts) || []),++ ];/** Judge one URL. Returns { url, internal } or throws AiError 422 fetch.refused. */-- function judge(raw) {++ function judge(raw, hosts = m.allowHosts) {let u;try { u = new URL(String(raw)); } catch { throw new AiError(422, 'fetch.refused', 'not a valid URL'); }if (u.username || u.password) throw new AiError(422, 'fetch.refused', 'credentials in URLs are not allowed');if (internalOrigin && u.origin === internalOrigin) return { url: u, internal: true };if (u.protocol !== 'https:') throw new AiError(422, 'fetch.refused', `only https URLs on allow-listed OpenVibe hosts are fetched (got ${u.protocol})`);if (net.isIP(u.hostname.replace(/^\[|\]$/g, ''))) throw new AiError(422, 'fetch.refused', 'IP-literal hosts are not fetched');-- if (!hostAllowed(u.hostname, m.allowHosts)) throw new AiError(422, 'fetch.refused', `host ${u.hostname} is not on the allow-list`);++ if (!hostAllowed(u.hostname, hosts)) throw new AiError(422, 'fetch.refused', `host ${u.hostname} is not on the allow-list`);return { url: u, internal: false };}@@@ -89,10 +95,10 @@ function createFetcher(config, { transport = null } = {}) {const mediaPublicHost = (() => { try { return new URL(m.publicUrl).hostname.toLowerCase(); } catch { return null; } })();const fromMedia = (t) => t.internal || (mediaPublicHost && t.url.hostname.toLowerCase() === mediaPublicHost);-- function follow(from, location) {++ function follow(from, location, hosts = m.allowHosts) {let next;try {-- next = judge(location);++ next = judge(location, hosts);} catch (err) {// Media hands an offloaded recording to object storage with a 302 to a presigned URL: that storage (and// only it) is followed, only from Media, over https, never to an IP literal; connect-time checks still apply.@@@ -158,17 +164,25 @@ function createFetcher(config, { transport = null } = {}) {});}-- async function fetchUrl(raw, { signal, maxBytes = m.maxBytes, timeoutMs = m.timeoutMs, toFile = null } = {}) {-- let target = judge(raw);++ async function fetchUrl(raw, { signal, maxBytes = m.maxBytes, timeoutMs = m.timeoutMs, toFile = null, hosts = m.allowHosts } = {}) {++ let target = judge(raw, hosts);for (let hop = 0; hop < 4; hop++) {const r = await (transport || once)(target, { signal, maxBytes, timeoutMs, toFile });if (!r.redirect) return { ...r, url: target.url.toString() };if (hop === 3) throw new AiError(502, 'source.unavailable', 'too many redirects');-- target = follow(target, r.redirect);++ target = follow(target, r.redirect, hosts);}throw new AiError(502, 'source.unavailable', 'too many redirects');}++ /** A licensed picture (Commons/Openverse) or an image provider's own URL, fetched under the picture host lists. */++ async function fetchPicture(raw, { signal, maxBytes = m.maxImageBytes, timeoutMs = 30000 } = {}) {++ const r = await fetchUrl(raw, { signal, maxBytes, timeoutMs, hosts: [...m.allowHosts, ...pictureHosts] });++ const type = String(r.contentType || '').split(';')[0].trim().toLowerCase();++ if (!/^image\//.test(type)) throw new AiError(422, 'source.invalid', `expected an image, got ${type || 'unknown type'}`);++ return { bytes: r.buffer, mime: type, url: r.url };++ }++/*** One upstream response for a media reader (server/media-proxy.js): the same URL rules as fetchUrl, redirects* judged and followed here (the reader never sees them), a Range header passed through, the body left as a stream.@@@ -261,7 +275,7 @@ function createFetcher(config, { transport = null } = {}) {return { file, url };}-- return { judge, follow, fetchUrl, openStream, mediaUrlOf, mediaRefUrl, loadImage, loadMediaToFile, hostAllowed: (h) => hostAllowed(h, m.allowHosts) };++ return { judge, follow, fetchUrl, fetchPicture, openStream, mediaUrlOf, mediaRefUrl, loadImage, loadMediaToFile, hostAllowed: (h) => hostAllowed(h, m.allowHosts) };}/** Any image -> downscaled JPEG (sharp, optional). Falls back to the original bytes. */@@@ -32,7 +32,7 @@ const { AiError } = require('./util');const { gracefulStop, telemetry } = require('openvibe-sdk/service');const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');--async function start({ searchFetch = null, config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null, community: givenCommunity = null, communityEndpoints: givenEndpoints = null, webhooks: givenWebhooks = null, abuse: givenAbuse = null, memoryTransport = null, memoryLookup = null, ownerNotify = null } = {}) {++async function start({ searchFetch = null, config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null, community: givenCommunity = null, communityEndpoints: givenEndpoints = null, webhooks: givenWebhooks = null, abuse: givenAbuse = null, memoryTransport = null, memoryLookup = null, ownerNotify = null, mediaStore: givenMediaStore = null } = {}) {config = config || load(env);schemas.configure(config.schemaCache);// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.@@@ -104,8 +104,10 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {const runBudgets = createRunBudgets(db, { clock });const cache = createCache(db, { clock });const fetcher = createFetcher(config);++ // Pictures into Media for the image task (server/media-store.js): one tenant, the owner's namespace.++ const mediaStore = givenMediaStore || require('./media-store').createMediaStore({ config, env, fetchImpl, log });// MegaSource (server/research/): the DeepSeek key resolves by name from this environment; tests hand in their own.-- const engine = createEngine({ registry, pool, fetcher, quotas, config, log, research: research || require('./research').createResearch({ config: config.research, env, log }) });++ const engine = createEngine({ registry, pool, fetcher, quotas, config, log, db, clock, mediaStore, research: research || require('./research').createResearch({ config: config.research, env, log }) });// ai.run.* to OpenVibe.Events through the outbox (server/events.js); before recovery, so its failures are announced.require('./events').init(db, { log });// platform.usage-sample@1 readings to OpenVibe.Billing through their own outbox (server/usage-samples.js).nnew file mode 100644@@@ -0,0 +1,99 @@++'use strict';++/**++ * Pictures into OpenVibe.Media (media.object.upload, the AI tenant): init, bytes, complete (the v2++ * object API). Every picture the image task returns is a Media reference (`med_…`), never inline++ * bytes in the run's output: the editor attaches the id, and Media owns the bytes, its visibility++ * and its licence metadata.++ *++ * A picture is uploaded for the person the run acts for (`X-OV-Subject`) when there is one; a++ * service-only find (a guest, when the owner allows it) belongs to the AI tenant. Off unless++ * OV_MEDIA_INTERNAL_URL and the service secret are both configured.++ */++const crypto = require('crypto');++const { createServiceTokenClient } = require('openvibe-sdk/auth');++++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++++function createMediaStore({ config, env = process.env, fetchImpl = globalThis.fetch, tokens = null, log = console } = {}) {++ const media = (config && config.media) || {};++ const baseUrl = String(media.internalUrl || '').replace(/\/+$/, '');++ const app = media.app || 'ai';++ let client = tokens;++ const tokenClient = () => {++ if (client) return client;++ const secret = env.OV_OAUTH_CLIENT_SECRET;++ if (!baseUrl || !secret) return null;++ client = createServiceTokenClient({++ tokenUrl: `${String(config.networkInternalUrl).replace(/\/+$/, '')}/oauth/token`,++ clientId: env.OV_OAUTH_CLIENT_ID || 'ai', clientSecret: secret,++ audience: 'openvibe.media', scope: 'media.object.upload', fetch: fetchImpl,++ });++ return client;++ };++ const configured = () => Boolean(baseUrl && (client || env.OV_OAUTH_CLIENT_SECRET || tokens));++++ /** The object's public bytes URL, what a figure block and an editor preview use. */++ function publicUrl(mediaId) {++ return mediaId ? `${String(media.publicUrl || 'https://openvibe.media').replace(/\/+$/, '')}/o/${encodeURIComponent(mediaId)}` : null;++ }++++ async function call(url, init, timeoutMs = 30000) {++ let res;++ try {++ res = await fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });++ } catch (err) {++ throw Object.assign(new Error(`Media did not answer: ${err && err.name === 'TimeoutError' ? 'timeout' : err && err.message}`), { status: 503, code: 'media.unavailable' });++ }++ const body = await res.json().catch(() => null);++ if (res.status < 200 || res.status > 299) {++ throw Object.assign(new Error(`Media answered ${res.status}${body && (body.code || body.title) ? ` (${body.code || body.title})` : ''}`), {++ status: res.status === 413 || res.status === 415 || res.status === 422 ? 422 : 503, code: 'media.upload_refused',++ });++ }++ return body || {};++ }++++ /**++ * A picture into Media for the person the run acts for. `app` is the tenant the calling product keeps its++ * pictures in (blog, wiki, or AI's own ai); absent, the AI tenant. → { media_id, url, width, height, mime }.++ * metadata keeps where it came from (source_url, license, author) or what made it (model, ai).++ */++ async function put({ bytes, mime, filename = 'picture', owner = null, app: tenant = null, metadata = {}, timeoutMs = 30000 } = {}) {++ if (!configured()) throw Object.assign(new Error('OpenVibe.Media is not configured (OV_MEDIA_INTERNAL_URL and the AI service secret are needed)'), { status: 503, code: 'media.unavailable' });++ if (!Buffer.isBuffer(bytes) || !bytes.length) throw Object.assign(new Error('the picture has no bytes'), { status: 422, code: 'media.empty' });++ const target = tenant && /^[a-z][a-z0-9-]{1,31}$/.test(String(tenant)) ? String(tenant) : app;++ const base = `${baseUrl}/api/v2/${encodeURIComponent(target)}/objects`;++ const auth = await tokenClient().authHeaders();++ const subject = owner && SUBJECT_RE.test(owner) ? { 'X-OV-Subject': owner } : {};++ const init = await call(base, {++ method: 'POST', headers: { ...auth, ...subject, 'Content-Type': 'application/json', Accept: 'application/json' },++ body: JSON.stringify({++ kind: 'asset', visibility: 'public', mime_type: mime, size_bytes: bytes.length,++ filename: String(filename || 'picture').slice(0, 120), content_hash: crypto.createHash('sha256').update(bytes).digest('hex'),++ metadata,++ }),++ }, timeoutMs);++ const id = init.id || (init.object && init.object.id);++ const up = init.upload || {};++ if (!id || !up.url) throw Object.assign(new Error('Media did not give an upload address'), { status: 503, code: 'media.upload_refused' });++ // The upload and complete addresses may name Media's public origin; AI reaches it on the internal one.++ const internal = (u) => {++ const x = new URL(u, baseUrl);++ return `${baseUrl}${x.pathname}${x.search}`;++ };++ await call(internal(up.url), { method: 'PUT', headers: { ...auth, ...subject, 'Content-Type': mime, ...(up.token ? { 'X-Upload-Token': up.token } : {}) }, body: bytes }, timeoutMs);++ const done = await call(internal(up.complete_url || `${base}/${encodeURIComponent(id)}/complete`), {++ method: 'POST', headers: { ...auth, ...subject, 'Content-Type': 'application/json', Accept: 'application/json', ...(up.token ? { 'X-Upload-Token': up.token } : {}) }, body: '{}',++ }, timeoutMs);++ const object = done.object || done;++ return {++ media_id: id, url: publicUrl(id),++ width: Number.isInteger(object.width) ? object.width : null, height: Number.isInteger(object.height) ? object.height : null,++ mime: object.mime_type || mime || null,++ };++ }++++ return { configured, publicUrl, put };++}++++module.exports = { createMediaStore };@@@ -137,12 +137,12 @@ route('get', '/creations.md', {// ── AI tasks and choices (rich-content plan §1) ──────────────────────────────────────────────route('get', '/tasks', {operationId: 'listTasks', summary: 'The task catalogue: every named piece of AI work', tags: ['choices'], security: [],-- description: 'Public. A task names its service, its kind (text, code, vision, image, search), one sentence and its built-in default choice. A service registers its own with POST /api/v1/tasks.',++ description: 'Public. A task names its service, its kind (text, code, vision, image, search), one sentence and its built-in default choice. A service registers its own with POST /api/v1/tasks. image.picture is the shared image task: find licensed Commons/Openverse pictures or make one with the owner\'s configured image model, every picture stored in Media and returned by Media id.',responses: { 200: json('The catalogue', { type: 'object', required: ['tasks'], properties: { tasks: arrayOf(ref('Task')) } }), ...errors([429]) },});route('post', '/tasks', {operationId: 'registerTask', summary: 'Register a task in the caller’s own namespace', tags: ['choices'], security: SERVICE,-- description: 'ai.workflow.manage. Only a service token registers tasks, and only under its own namespace: the workflow is the service’s own or a general AI workflow (ai.generate, ai.complete, ai.image, research.gather). A registration is audited.',++ description: 'ai.workflow.manage. Only a service token registers tasks, and only under its own namespace: the workflow is the service’s own or a general AI workflow (ai.generate, ai.complete, ai.image, image.picture, research.gather). A registration is audited.',requestBody: body({ type: 'object', required: ['name', 'kind', 'sentence', 'default'], properties: {name: str({ pattern: '^[a-z][a-z0-9_-]*\\.[a-z0-9_.-]{1,100}$' }), kind: str({ enum: ['text', 'code', 'vision', 'image', 'search'] }),sentence: str({ maxLength: 300 }), default: ref('Choice'), workflow: str(),@@@ -1854,14 +1854,16 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.noconst usage = result.usage || { input: 0, output: 0, cached: 0 };await recordEvidence({ provider: c.provider, model, ok: true, timeout: false, ttftMs, latencyMs: latency,outputTokens: usage.output || 0, inputTokens: usage.input || 0, cachedTokens: usage.cached || 0 });-- // An image is metered per image (usage.output = images), and no rate card prices one yet (an owner/T5-- // item): its cost is 0 rather than the image count read as output tokens on the token cards.-- // BA1: the cost is the tier the answer really ran on, and the wait's saving is the-- // standard price minus that one. A provider that does not say which tier it served is-- // priced at the list price: a saving nobody confirmed is not a saving.++ // An image is metered per image (usage.output = images). The image task (workflow image.picture)++ // sets ctx.imagePriceUsd from the offer's metadata.price_per_image_usd before the call, so the++ // measured count settles at that price; every other image run has no card and costs 0 rather++ // than the image count being read as output tokens.const ranOn = tiers.tierOfAnswer(result, askedTier);++ const imagePrice = ctx.imagePriceUsd == null ? null : Number(ctx.imagePriceUsd);const priced = operation === 'image'-- ? { cost: 0, standard: 0, saved: 0 }++ ? (Number.isFinite(imagePrice) && imagePrice >= 0++ ? { cost: imagePrice * (Number(usage.output) || 0), standard: imagePrice * (Number(usage.output) || 0), saved: 0 }++ : { cost: 0, standard: 0, saved: 0 }): await costOnTier({ providerKey: c.provider, model: result.model || model, usage, tier: ranOn, p });const cost = priced.cost;statFor(c.provider).ok++;@@@ -361,7 +361,7 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.eschemas.assertSchema(w.output_schema, 'output_schema');if (!Array.isArray(w.steps) || !w.steps.length) throw new AiError(422, 'ai.invalid', 'workflow needs at least one step');for (const s of w.steps) {-- if (!s || !['llm', 'passthrough', 'complete', 'messages', 'transcribe', 'embed', 'image', 'media_analysis', 'research', 'devblog'].includes(s.kind)) throw new AiError(422, 'ai.invalid', 'step kind must be llm, passthrough, complete, messages, transcribe, embed, image, media_analysis or research');++ if (!s || !['llm', 'passthrough', 'complete', 'messages', 'transcribe', 'embed', 'image', 'picture', 'media_analysis', 'research', 'devblog'].includes(s.kind)) throw new AiError(422, 'ai.invalid', 'step kind must be llm, passthrough, complete, messages, transcribe, embed, image, picture, media_analysis or research');if ((s.kind === 'llm' || (s.kind === 'media_analysis' && s.template)) && !(s.template && await latestAny('templates', s.template))) throw new AiError(422, 'ai.invalid', `step references unknown template ${s.template}`);}if (!['none', 'private', 'service'].includes(w.cache_mode)) throw new AiError(422, 'ai.invalid', 'cache_mode must be none, private or service');@@@ -13,6 +13,9 @@ function createOpenverse({ http }) {return (j.results || []).filter((r) => r.url && /^https:/.test(r.url) && (r.width || 0) >= 320 && NAMES[r.license]).map((r) => ({url: r.url,original: r.url,++ // Openverse's own thumbnail proxy is the small, stable URL the image task fetches; the original++ // may live on a host we do not fetch (Flickr, a museum).++ thumbnail: r.thumbnail || null,width: r.width || null,height: r.height || null,mime: null,@@@ -47,6 +47,7 @@ function imageFrom(page, provider, fallbackSource, { allowSvg = false } = {}) {original: untracked(ii.url),width: ii.thumbwidth || width,height: ii.thumbheight || height,++ thumbnail: untracked(ii.thumburl || ii.url),mime: svg ? 'image/png' : ii.mime,title: stripTags(v('ObjectName')) || String(page.title || '').replace(/^File:/, '').replace(/\.[a-z0-9]{2,5}$/i, '').replace(/_/g, ' '),description: clip(stripTags(v('ImageDescription')), 300) || null,@@@ -155,7 +155,7 @@ function batchExec(b, result) {};}--function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spawnImpl, log = console, research = null }) {++function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spawnImpl, log = console, research = null, db = null, clock = { now: () => Date.now() }, mediaStore = null }) {pool = require('../effort/pool').wrapPool(pool);// One loopback reader for everything that streams media (server/media-proxy.js): transcripts and media.analyze.let proxy = null;@@@ -169,6 +169,8 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spaif (r.disabled) throw new AiError(503, 'route.unavailable', `route ${key} is disabled`);return r;}++ // The shared image task (image.picture): find licensed pictures or make one with the owner's model.++ const pictures = require('./pictures').createPictures({ registry, pool, fetcher, config, db, clock, mediaStore, routeFor, research: megasource, log });/*** Everything an llm step needs before its provider call: the template it pinned, the rendered system and@@@ -569,7 +571,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spareturn { output, exec: { ...last.exec, usage, cost, savedUsd }, route: last.route };}-- const STEP = { devblog: devblogStep, llm: llmStep, passthrough: passthroughStep, complete: completeStep, messages: messagesStep, transcribe: transcribeStep, embed: embedStep, image: imageStep, media_analysis: mediaAnalysis.step, research: researchStep };++ const STEP = { devblog: devblogStep, llm: llmStep, passthrough: passthroughStep, complete: completeStep, messages: messagesStep, transcribe: transcribeStep, embed: embedStep, image: imageStep, picture: pictures.step, media_analysis: mediaAnalysis.step, research: researchStep };/*** Run a workflow. ctx: { signal, logRequest, debugRaw, inputHash }nnew file mode 100644@@@ -0,0 +1,328 @@++'use strict';++/**++ * The image task (rich-content plan §4/§5): one workflow, `image.picture`, with two modes.++ *++ * find licensed pictures from Wikimedia Commons and Openverse, with the licence, the author and the++ * source page kept. Nothing without a licence that allows reuse is fetched or uploaded. Each++ * picture goes to OpenVibe.Media; the answer names the Media object, never inline bytes.++ * make the owner's configured image model, through the ordinary provider routing: a dry run first++ * (image count, model, per-image price, total), then a priced reserve/estimate, the provider++ * call, and one Media object per returned image labelled "Made with AI (<model>)".++ *++ * The task is the one Blog's editor and Wiki's editor/writer share. It never publishes and never++ * attaches anything: a Media id is a candidate an editor chooses.++ */++const { AiError, sha256 } = require('../util');++const { canonicalOf } = require('../exchange/catalog');++++const MODES = ['find', 'make'];++const SOURCES = ['commons', 'openverse'];++const STR = (n) => ({ type: 'string', maxLength: n });++const NULLABLE = (n) => ({ type: ['string', 'null'], maxLength: n });++const INT = (lo, hi) => ({ type: 'integer', minimum: lo, maximum: hi });++++const MEDIA = {++ type: 'object', additionalProperties: false,++ required: ['media_id', 'url', 'ai'],++ properties: {++ media_id: STR(80), url: STR(2048), width: { type: ['integer', 'null'] }, height: { type: ['integer', 'null'] },++ title: NULLABLE(300), alt: NULLABLE(400), caption: NULLABLE(500), credit: NULLABLE(300),++ author: NULLABLE(200), license: NULLABLE(200), license_url: NULLABLE(2048), source_url: NULLABLE(2048),++ provider: STR(80), ai: { type: 'boolean' }, model: NULLABLE(200),++ },++};++++const ESTIMATE = {++ type: 'object', additionalProperties: false, required: ['model', 'source', 'images', 'per_image_usd', 'price_usd'],++ properties: {++ model: STR(200), source: STR(10), images: INT(1, 4),++ per_image_usd: { type: ['number', 'null'], minimum: 0 }, price_usd: { type: ['number', 'null'], minimum: 0 },++ },++};++++const oneLine = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);++const creditOf = (img) => [oneLine(img.author, 120), oneLine(img.license, 80)].filter(Boolean).join(', ');++const sourceOf = (provider) => (String(provider || '').toLowerCase().startsWith('openverse') ? 'openverse' : 'commons');++const slug = (s) => oneLine(s, 80).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || 'picture';++++/** The bytes' real type, from their first bytes: a provider or a source is not asked what it sent. */++function mimeOf(bytes) {++ if (bytes && bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'image/png';++ if (bytes && bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg';++ if (bytes && bytes.length >= 6 && bytes.subarray(0, 3).toString('ascii') === 'GIF') return 'image/gif';++ if (bytes && bytes.length >= 12 && bytes.subarray(0, 4).toString('ascii') === 'RIFF' && bytes.subarray(8, 12).toString('ascii') === 'WEBP') return 'image/webp';++ return 'image/png';++}++++/** A provider whose capacity is already paid for: never metered, so a picture costs nothing. */++function freeProvider(provider) {++ return Boolean(provider) && (provider.kind === 'stub' || provider.kind === 'whisper'++ || Boolean(provider.metadata && (provider.metadata.local || provider.metadata.paid === false))++ || ['subscription', 'free', 'byok'].includes(provider.billing_profile));++}++++const perImagePrice = (row) => {++ const n = Number(row && row.metadata && row.metadata.price_per_image_usd);++ return Number.isFinite(n) && n >= 0 ? n : null;++};++++const workflows = [{++ key: 'image.picture', name: 'Find or make a picture', namespace: 'image',++ description: 'One image task with two modes: find a licensed picture on Wikimedia Commons or Openverse (licence, author and source kept), or make one with the owner\'s configured image model. Every picture is stored in OpenVibe.Media and returned by reference.',++ input_schema: {++ type: 'object', additionalProperties: false, required: ['mode'],++ properties: {++ mode: { enum: MODES },++ query: STR(200), providers: { type: 'array', maxItems: SOURCES.length, uniqueItems: true, items: { enum: SOURCES } },++ count: INT(1, 12), prompt: STR(4000), alt: STR(300), n: INT(1, 4),++ // The Media tenant the calling product keeps its pictures in (Blog/Wiki pass theirs so their own++ // Media attach panel can see the object); absent, the AI tenant.++ media_app: { enum: ['ai', 'blog', 'wiki', 'media', 'pics'] },++ size: { type: 'string', pattern: '^[0-9]{3,4}x[0-9]{3,4}$' }, dry_run: { type: 'boolean' },++ },++ },++ output_schema: {++ type: 'object', additionalProperties: false,++ required: ['mode', 'task', 'media', 'count', 'gaps'],++ properties: {++ mode: { enum: MODES }, task: STR(40), query: NULLABLE(200), prompt: NULLABLE(4000), dry_run: { type: 'boolean' },++ estimate: { anyOf: [ESTIMATE, { type: 'null' }] },++ media: { type: 'array', maxItems: 12, items: MEDIA },++ count: INT(0, 12), gaps: { type: 'array', maxItems: 24, items: STR(300) }, note: NULLABLE(500),++ },++ },++ // Find reads our own picture sources (no route); make resolves default.image itself, so the workflow can be++ // created and find can run when no image model is configured yet.++ steps: [{ kind: 'picture' }], default_route: null, cache_mode: 'none',++ metadata: {++ creation: {++ id: 'picture', product: 'media', name: 'Find or make a picture', workflow: 'image.picture', task: 'image.picture',++ gives: 'A search for a licensed picture, or a description of one to make.',++ returns: 'Pictures in OpenVibe.Media, with their licence and author or the model that made them.',++ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,++ start_url: 'https://openvibe.media/',++ },++ },++}];++++function createPictures({ registry, pool, fetcher, config = {}, db = null, clock = { now: () => Date.now() }, mediaStore = null, research = null, routeFor = null, log = console } = {}) {++ const pictureConfig = config.pictures || {};++ const subjectOf = (ctx) => {++ const v = ctx && ctx.onBehalfOf;++ const id = typeof v === 'string' ? v : (v && v.id ? String(v.id) : null);++ return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id || '')) ? String(id) : null;++ };++++ /** The task's own choice (the owner's image model for make; the engines for find), or null. */++ const planOf = (ctx) => (ctx && ctx.taskChoice) || null;++++ function enginesFor(plan, input) {++ const asked = Array.isArray(input.providers) && input.providers.length ? input.providers : null;++ const owner = plan && Array.isArray(plan.search_engines)++ ? plan.search_engines.filter((e) => e && e.enabled !== false && SOURCES.includes(e.engine)).map((e) => e.engine)++ : [];++ return (asked || (owner.length ? owner : SOURCES)).filter((e, i, a) => SOURCES.includes(e) && a.indexOf(e) === i);++ }++++ /**++ * The model offers the owner's image choice names, cheapest first, with a per-image price. A paid++ * offer without metadata.price_per_image_usd is refused rather than guessed at; a prepaid, free or++ * local offer costs 0.++ */++ async function pricedOffers(picked) {++ const [rows, providers] = await Promise.all([registry.listModels(), registry.listProviders()]);++ const byKey = new Map(providers.map((p) => [p.key, p]));++ const dynamic = String(picked.model || '').startsWith('openvibe/');++ const out = [];++ for (const row of rows) {++ if (row.type !== 'image' || row.status !== 'active') continue;++ const provider = byKey.get(row.provider_key);++ if (!provider || provider.status !== 'active') continue;++ const id = canonicalOf(row, provider);++ if (!dynamic && id !== picked.model && row.model_key !== picked.model) continue;++ const adult = Boolean(row.metadata && row.metadata.uncensored) || /uncensor|abliterat/i.test(`${id} ${(row.metadata && row.metadata.variant) || ''}`);++ if (dynamic && adult) continue;++ const price = perImagePrice(row);++ out.push({ id, provider: provider.key, model: row.model_key, row, free: freeProvider(provider), price });++ }++ out.sort((a, b) => (a.price == null ? Infinity : a.price) - (b.price == null ? Infinity : b.price));++ if (!out.length) throw new AiError(422, 'choice.unavailable', `no deployment of ${picked.model} is available`);++ return out;++ }++++ /** Pictures the subject has made today (UTC), in images: the owner's daily cap counts what was actually made. */++ async function madeToday(subject) {++ if (!db || !subject) return 0;++ const day = new Date(clock.now()).toISOString().slice(0, 10);++ const row = await db.prepare(++ "SELECT COALESCE(SUM(tokens_out), 0) AS n FROM runs WHERE workflow_key = 'image.picture' AND status = 'succeeded' AND created_at >= ? AND (on_behalf_of::jsonb->>'id') = ?",++ ).get(`${day}T00:00:00.000Z`, subject).catch(() => null);++ return Number(row && row.n) || 0;++ }++++ async function find(input, plan, ctx) {++ const query = oneLine(input.query, 200);++ if (!query) throw new AiError(422, 'input.invalid', 'find needs a query');++ const limit = Math.max(1, Math.min(12, Number(input.count) || 8));++ const engines = enginesFor(plan, input);++ if (!engines.length) throw new AiError(422, 'choice.unavailable', 'no licensed picture source is enabled for this task');++ if (!research) throw new AiError(503, 'source.unavailable', 'picture research is not available');++ let gathered = null;++ const gaps = [];++ try {++ gathered = await research().gather({ query, images: true, providers: engines, max_images: Math.max(limit * 2, limit), content_chars: 800 }, { signal: ctx && ctx.signal, allowPaid: false, allowFabric: false });++ } catch (err) {++ if (ctx && ctx.signal && ctx.signal.aborted) throw err;++ throw new AiError(502, 'source.unavailable', `no picture source answered for "${query}": ${oneLine(err && err.message, 200)}`);++ }++ const found = (gathered && Array.isArray(gathered.images) ? gathered.images : [])++ .filter((img) => img && img.free === true && img.license && oneLine(img.license, 200))++ .filter((img) => SOURCES.includes(sourceOf(img.provider)));++ for (const [name, p] of Object.entries((gathered && gathered.providers) || {})) {++ if (p && p.ok === false) gaps.push(`${name}: ${oneLine(p.skipped || p.error, 160) || 'did not answer'}`);++ }++ const media = [];++ const seen = new Set();++ for (const img of found) {++ if (media.length >= limit) break;++ const source = sourceOf(img.provider);++ const page = img.source_url || img.original || img.url;++ if (!page || seen.has(page)) continue;++ seen.add(page);++ try {++ const got = await fetcher.fetchPicture(img.thumbnail || img.url, { signal: ctx && ctx.signal });++ const mime = got.mime && /^image\//.test(got.mime) ? got.mime : mimeOf(got.bytes);++ const stored = await mediaStore.put({++ bytes: got.bytes, mime, filename: `${slug(img.title || query)}.${mime === 'image/jpeg' ? 'jpg' : mime.split('/')[1] || 'png'}`,++ owner: subjectOf(ctx), app: input.media_app || null,++ metadata: { title: oneLine(img.title, 200) || query, source_url: page, license: oneLine(img.license, 200), license_url: img.license_url || null, author: oneLine(img.author, 200) || null, provider: img.provider || source, origin: 'image.picture.find', free: true },++ });++ media.push({++ media_id: stored.media_id, url: stored.url || mediaStore.publicUrl(stored.media_id),++ width: Number.isInteger(stored.width) ? stored.width : (Number.isInteger(img.width) ? img.width : null),++ height: Number.isInteger(stored.height) ? stored.height : (Number.isInteger(img.height) ? img.height : null),++ title: oneLine(img.title, 300) || null, alt: oneLine(img.description || img.title, 400) || null,++ caption: oneLine(img.description || img.title, 500) || null, credit: creditOf(img) || null,++ author: oneLine(img.author, 200) || null, license: oneLine(img.license, 200),++ license_url: img.license_url || null, source_url: page, provider: img.provider || source,++ ai: false, model: null,++ });++ } catch (err) {++ if (ctx && ctx.signal && ctx.signal.aborted) throw err;++ gaps.push(`${img.title || source}: ${oneLine(err && err.message, 200) || 'not fetched'}`);++ }++ }++ const exec = { provider: 'research', model: engines.join('+'), usage: { input: 0, output: 0, cached: 0 }, cost: 0, synthetic: false, fallbackUsed: false, explain: null };++ return {++ output: {++ mode: 'find', task: 'image.picture', query, prompt: null, dry_run: false, estimate: null,++ media, count: media.length, gaps: gaps.slice(0, 24),++ note: media.length ? null : 'No licensed picture was found for this search.',++ },++ exec,++ };++ }++++ async function make(input, plan, ctx, run) {++ const prompt = String(input.prompt || '').trim();++ if (!prompt) throw new AiError(422, 'input.invalid', 'make needs a prompt');++ const n = Math.max(1, Math.min(4, Number(input.n) || 1));++ const subject = subjectOf(ctx);++ if (!subject) throw new AiError(401, 'image.sign_in', 'Sign in to make an image');++ // The owner's switch: until an owner picks an image model for this task, make is off. The default++ // resolution never falls back to a signed-in model for image.picture's make mode.++ const picked = plan && plan.image_model ? plan.image_model : null;++ if (!picked) throw new AiError(503, 'image.make_unconfigured', 'The owner has not picked an image model for the picture task; set image_model in the image.picture task defaults.');++ const offers = await pricedOffers(picked);++ const chosen = offers[0];++ const perImage = chosen.price == null ? (chosen.free ? 0 : null) : chosen.price;++ if (perImage == null) throw new AiError(402, 'image.price_required', `the image model ${chosen.id} has no per-image price; set metadata.price_per_image_usd on the model before a paid picture can be made`);++ const estimate = { model: chosen.id, source: 'openvibe', images: n, per_image_usd: perImage, price_usd: perImage * n };++ if (plan.max_cost_usd != null && estimate.price_usd > plan.max_cost_usd) throw new AiError(402, 'choice.cost_ceiling', `the picture would cost about $${estimate.price_usd} and this task's per-run ceiling is $${plan.max_cost_usd}`);++ const cap = plan.daily_cap == null ? pictureConfig.defaultDailyCap : Number(plan.daily_cap);++ if (Number.isFinite(cap) && cap >= 0) {++ const made = await madeToday(subject);++ if (made + n > cap) throw new AiError(429, 'image.daily_cap', `this task's daily cap is ${cap} picture${cap === 1 ? '' : 's'} and ${made} ${made === 1 ? 'has' : 'have'} been made today`, { retry_after_seconds: 3600 });++ }++ if (input.dry_run) {++ return {++ output: {++ mode: 'make', task: 'image.picture', query: null, prompt, dry_run: true, estimate, media: [], count: 0, gaps: [],++ note: `Dry run: ${n} image${n === 1 ? '' : 's'} with ${chosen.id} at $${perImage} each, about $${estimate.price_usd}.`,++ },++ exec: { provider: null, model: chosen.model, usage: { input: 0, output: 0, cached: 0 }, cost: 0, synthetic: false, fallbackUsed: false, explain: null },++ };++ }++ const route = await ((routeFor || (async () => { throw new AiError(503, 'route.unavailable', 'the image route is not available'); }))('default.image'));++ const req = { prompt, size: input.size || ((config.image && config.image.size) || null), n, timeoutMs: route.timeout_ms || 180000 };++ const exec = await pool.execute(route, 'image', req, {++ ...ctx, taskChoice: null, exact: { id: chosen.id, offers: [{ provider: chosen.provider, model: chosen.model }] }, imagePriceUsd: perImage,++ routeKey: route.key, routeVersion: route.version, promptHash: sha256({ p: prompt, n }),++ });++ const images = ((exec.result && exec.result.images) || []).filter((i) => i && (i.b64 || i.url));++ if (!images.length) throw new AiError(502, 'output.invalid', 'no image was returned');++ exec.explain = {++ ...(exec.explain || {}),++ task_choice: {++ task: plan.task && plan.task.name ? plan.task.name : 'image.picture',++ requested: plan.choice, answered: { model: exec.model || chosen.model, source: 'openvibe' },++ why: plan.why || 'owner', fallback_index: 0,++ },++ };++ const model = exec.model || chosen.model;++ const label = `Made with AI (${model})`;++ const media = [];++ const gaps = [];++ for (const image of images) {++ try {++ let bytes; let mime;++ if (image.b64) {++ bytes = Buffer.from(String(image.b64), 'base64');++ mime = mimeOf(bytes);++ } else {++ const got = await fetcher.fetchPicture(image.url, { signal: ctx && ctx.signal });++ bytes = got.bytes;++ mime = got.mime && /^image\//.test(got.mime) ? got.mime : mimeOf(bytes);++ }++ const alt = oneLine(input.alt, 300) || oneLine(prompt, 300);++ const stored = await mediaStore.put({++ bytes, mime, filename: `${slug(input.alt || prompt)}.${mime === 'image/jpeg' ? 'jpg' : mime.split('/')[1] || 'png'}`, owner: subject, app: input.media_app || null,++ metadata: { title: alt, alt, caption: label, ai: true, model, generated_with: model, prompt: prompt.slice(0, 1000), origin: 'image.picture.make' },++ });++ media.push({++ media_id: stored.media_id, url: stored.url || mediaStore.publicUrl(stored.media_id),++ width: Number.isInteger(stored.width) ? stored.width : null, height: Number.isInteger(stored.height) ? stored.height : null,++ title: alt, alt, caption: alt ? `${alt} (${label})` : label, credit: label,++ author: null, license: null, license_url: null, source_url: null, provider: exec.provider || 'image', ai: true, model,++ });++ } catch (err) {++ if (ctx && ctx.signal && ctx.signal.aborted) throw err;++ gaps.push(`the picture could not be stored: ${oneLine(err && err.message, 240)}`);++ }++ }++ if (!media.length) throw new AiError(502, 'media.upload_refused', `the image was made but no picture reached Media${gaps.length ? `: ${gaps[0]}` : ''}`);++ return {++ output: {++ mode: 'make', task: 'image.picture', query: null, prompt, dry_run: false, estimate, media,++ count: media.length, gaps: gaps.slice(0, 24), note: null,++ },++ exec,++ };++ }++++ const step = async (s, input, wf, run, ctx) => {++ const plan = planOf(ctx);++ if (input.mode === 'find') {++ // Guest find is the owner's switch: a run with no person may read Commons and Openverse only when++ // the owner allowed it; the pictures then belong to the AI tenant.++ if (!subjectOf(ctx) && !(plan && plan.guest_find === true)) throw new AiError(401, 'image.guest_forbidden', 'Guests may not find pictures for this task; the owner can allow it in the image.picture task defaults.');++ return await find(input, plan, ctx);++ }++ if (input.mode === 'make') return await make(input, plan, ctx, run);++ throw new AiError(422, 'input.invalid', 'mode must be find or make');++ };++++ return { step, modes: MODES, sources: SOURCES };++}++++module.exports = { templates: [], workflows, createPictures, mimeOf, freeProvider, perImagePrice };@@@ -15,7 +15,7 @@ const PROVIDERS = ['wikipedia', 'fandom', 'wikidata', 'deepseek', 'commons', 'opconst IMAGE = {type: 'object', additionalProperties: false, required: ['url', 'free', 'provider'],properties: {-- url: STR(2048), original: NULLABLE(2048), width: { type: ['integer', 'null'] }, height: { type: ['integer', 'null'] }, mime: NULLABLE(40),++ url: STR(2048), original: NULLABLE(2048), thumbnail: NULLABLE(2048), width: { type: ['integer', 'null'] }, height: { type: ['integer', 'null'] }, mime: NULLABLE(40),title: NULLABLE(300), description: NULLABLE(400), author: NULLABLE(200), license: NULLABLE(200), license_url: NULLABLE(2048),free: { type: 'boolean' }, source_url: NULLABLE(2048), provider: STR(80),},@@@ -24,6 +24,7 @@ const chat = require('./chat');const research = require('./research');const meme = require('./meme');const changelog = require('./changelog');++const pictures = require('./pictures');const poolSeed = require('../providers/pool-seed');const { resolveSecret } = require('../util');@@@ -260,11 +261,11 @@ async function seed({ registry, quotas, config, env = process.env, db }) {await registry.seedVersioned('route', 'chat.member', { primary: { provider: 'shared', model: null }, fallbacks: [], options: {}, max_output_tokens: 600, response_format: 'text', timeout_ms: null, alias_of: 'default.chat' });// Templates, then workflows (workflows reference templates)-- for (const t of [...live.templates, ...core.templates, ...products.templates, ...devblog.templates, ...media.templates, ...chat.templates, ...space.templates, ...research.templates, ...meme.templates, ...changelog.templates]) {++ for (const t of [...live.templates, ...core.templates, ...products.templates, ...devblog.templates, ...media.templates, ...chat.templates, ...space.templates, ...research.templates, ...meme.templates, ...changelog.templates, ...pictures.templates]) {const { key, ...def } = t;await registry.seedVersioned('template', key, { description: null, default_route: null, owner: 'ai', visibility: 'internal', metadata: {}, ...def });}-- for (const w of [...live.workflows, ...core.workflows, ...products.workflows, ...devblog.workflows, ...media.workflows, ...chat.workflows, ...space.workflows, ...research.workflows, ...meme.workflows, ...changelog.workflows]) {++ for (const w of [...live.workflows, ...core.workflows, ...products.workflows, ...devblog.workflows, ...media.workflows, ...chat.workflows, ...space.workflows, ...research.workflows, ...meme.workflows, ...changelog.workflows, ...pictures.workflows]) {const { key, ...def } = w;await registry.seedVersioned('workflow', key, { description: null, default_route: null, cache_ttl_sec: null, metadata: {}, ...def });}nnew file mode 100644@@@ -0,0 +1,207 @@++'use strict';++// The shared image task (image.picture, rich-content plan §4/§5): find licensed Commons/Openverse pictures++// and make one with the owner's model. Pictures go to Media by reference, never inline; a paid offer must++// carry a per-image price; make is off until the owner picks a model; the owner's daily cap holds.++const assert = require('assert');++const http = require('http');++const { suite, boot, request, token, ALL, seamServer } = require('./helpers');++const { createPictures, mimeOf, freeProvider } = require('../server/workflows/pictures');++++const t = suite('image picture');++const SUBJECT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const MEDIA = 'med_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64');++++function store() {++ const puts = [];++ return {++ puts,++ configured: () => true,++ publicUrl: (id) => `https://media.test/o/${id}`,++ put: async (o) => { puts.push(o); return { media_id: MEDIA, url: `https://media.test/o/${MEDIA}`, width: 800, height: 600 }; },++ };++}++const plan = (over = {}) => ({++ task: { name: 'image.picture', kind: 'image' }, choice: { model: 'openvibe/auto', source: 'openvibe' }, why: 'owner',++ fallbacks: [], max_cost_usd: null, search_engines: [{ engine: 'commons', enabled: true }, { engine: 'openverse', enabled: true }],++ image_model: null, daily_cap: null, guest_find: false, ...over,++});++const ctxOf = (over = {}) => ({ taskChoice: plan(), onBehalfOf: { type: 'user', id: SUBJECT }, signal: new AbortController().signal, ...over });++++const image = (over = {}) => ({ url: 'https://upload.wikimedia.org/a.jpg', thumbnail: 'https://upload.wikimedia.org/thumb/a.jpg', original: 'https://upload.wikimedia.org/a.jpg', source_url: 'https://commons.wikimedia.org/wiki/File:A', title: 'A machine', description: 'A machine in a workshop', author: 'Ada Example', license: 'CC BY-SA 4.0', license_url: 'https://creativecommons.org/licenses/by-sa/4.0/', free: true, provider: 'commons', width: 800, height: 600, ...over });++++function pictures(over = {}) {++ const calls = { fetches: [], executes: [] };++ const p = createPictures({++ registry: {++ listModels: async () => [imageModel(over.price)],++ listProviders: async () => [{ key: 'fixture', kind: 'openai', status: 'active', billing_profile: 'metered', metadata: {} }],++ },++ pool: { execute: async (route, op, req, ctx) => { calls.executes.push({ route, op, req, ctx }); return {++ result: { images: [{ b64: PNG.toString('base64'), url: null }] }, provider: 'fixture', model: 'image',++ usage: { input: 0, output: 1, cached: 0 }, cost: 0, synthetic: false, fallbackUsed: false, explain: {},++ }; } },++ fetcher: { fetchPicture: async (url) => { calls.fetches.push(url); return { bytes: PNG, mime: 'image/png', url }; } },++ config: { image: { size: '1024x1024' }, pictures: { defaultDailyCap: 24 } },++ db: { prepare: () => ({ get: async () => ({ n: over.made || 0 }) }) },++ mediaStore: store(),++ routeFor: async () => ({ key: 'default.image', version: 1, timeout_ms: 120000 }),++ research: () => ({ gather: async () => ({ providers: {}, images: over.images || [image()] }) }),++ ...over.deps,++ });++ return { p, calls };++}++const imageModel = (price = 0.04) => ({ provider_key: 'fixture', model_key: 'image', type: 'image', status: 'active', metadata: price == null ? {} : { price_per_image_usd: price } });++++t.test('find keeps licence, author and source, and uploads only free pictures to Media', async () => {++ const { p, calls } = pictures({ images: [++ image(),++ image({ url: 'https://upload.wikimedia.org/b.jpg', thumbnail: null, source_url: 'https://commons.wikimedia.org/wiki/File:B', license: null }),++ image({ provider: 'commons', free: false, source_url: 'https://commons.wikimedia.org/wiki/File:C' }),++ image({ provider: 'openverse', url: 'https://api.openverse.org/v1/images/one/thumb/', thumbnail: 'https://api.openverse.org/v1/images/one/thumb/', source_url: 'https://flickr.com/p/1', author: 'Bo', license: 'CC BY 4.0' }),++ ] });++ const out = await p.step({}, { mode: 'find', query: 'a machine', count: 8 }, {}, {}, ctxOf());++ assert.strictEqual(out.output.mode, 'find');++ assert.strictEqual(out.output.task, 'image.picture');++ assert.strictEqual(out.output.media.length, 2);++ assert.deepStrictEqual(out.output.media.map((m) => m.provider), ['commons', 'openverse']);++ assert.deepStrictEqual({ license: out.output.media[0].license, author: out.output.media[0].author, credit: out.output.media[0].credit, ai: out.output.media[0].ai },++ { license: 'CC BY-SA 4.0', author: 'Ada Example', credit: 'Ada Example, CC BY-SA 4.0', ai: false });++ assert.strictEqual(out.output.media[0].source_url, 'https://commons.wikimedia.org/wiki/File:A');++ assert.strictEqual(out.output.media[0].url, `https://media.test/o/${MEDIA}`);++ assert.deepStrictEqual(calls.fetches, ['https://upload.wikimedia.org/thumb/a.jpg', 'https://api.openverse.org/v1/images/one/thumb/']);++ assert.ok(!JSON.stringify(out.output).includes(PNG.toString('base64')), 'never inline bytes');++});++++t.test('find without a person is refused unless the owner allows guests', async () => {++ const { p } = pictures();++ await assert.rejects(() => p.step({}, { mode: 'find', query: 'x' }, {}, {}, ctxOf({ onBehalfOf: null })), (e) => e.status === 401 && e.code === 'image.guest_forbidden');++ const allowed = await p.step({}, { mode: 'find', query: 'x' }, {}, {}, ctxOf({ onBehalfOf: null, taskChoice: plan({ guest_find: true }) }));++ assert.strictEqual(allowed.output.count, 1);++});++++t.test('make is off until the owner picks an image model, then a dry run quotes without calling a provider', async () => {++ const off = pictures();++ await assert.rejects(() => off.p.step({}, { mode: 'make', prompt: 'a goose' }, {}, {}, ctxOf()), (e) => e.status === 503 && e.code === 'image.make_unconfigured');++ assert.strictEqual(off.calls.executes.length, 0);++++ const on = pictures();++ const out = await on.p.step({}, { mode: 'make', prompt: 'a goose', n: 2, dry_run: true }, {}, {}, ctxOf({ taskChoice: plan({ image_model: { model: 'fixture/image', source: 'openvibe' }, daily_cap: 10 }) }));++ assert.strictEqual(out.output.dry_run, true);++ assert.deepStrictEqual(out.output.estimate, { model: 'fixture/image', source: 'openvibe', images: 2, per_image_usd: 0.04, price_usd: 0.08 });++ assert.deepStrictEqual(out.output.media, []);++ assert.strictEqual(on.calls.executes.length, 0, 'a dry run never calls the provider');++});++++t.test('make uploads each picture to Media and labels it made with AI', async () => {++ const { p, calls } = pictures();++ const out = await p.step({}, { mode: 'make', prompt: 'a goose', alt: 'A goose', n: 1 }, {}, {}, ctxOf({ taskChoice: plan({ image_model: { model: 'fixture/image', source: 'openvibe' }, daily_cap: 10 }) }));++ assert.strictEqual(out.output.media.length, 1);++ assert.strictEqual(out.output.media[0].ai, true);++ assert.strictEqual(out.output.media[0].model, 'image');++ assert.strictEqual(out.output.media[0].credit, 'Made with AI (image)');++ assert.match(out.output.media[0].caption, /Made with AI \(image\)/);++ assert.strictEqual(out.output.media[0].media_id, MEDIA);++ assert.strictEqual(calls.executes.length, 1);++ assert.strictEqual(calls.executes[0].ctx.imagePriceUsd, 0.04, 'the per-image price is reserved for this call');++ assert.strictEqual(calls.executes[0].ctx.taskChoice, null, 'the owner model is pinned, not re-resolved');++ assert.strictEqual(out.exec.explain.task_choice.answered.model, 'image');++});++++t.test('a paid offer without a per-image price is refused, and the daily cap holds', async () => {++ const unpriced = pictures({ price: null });++ await assert.rejects(() => unpriced.p.step({}, { mode: 'make', prompt: 'x' }, {}, {}, ctxOf({ taskChoice: plan({ image_model: { model: 'fixture/image', source: 'openvibe' } }) })), (e) => e.status === 402 && e.code === 'image.price_required');++ assert.strictEqual(unpriced.calls.executes.length, 0);++++ const capped = pictures({ made: 4 });++ await assert.rejects(() => capped.p.step({}, { mode: 'make', prompt: 'x', n: 2 }, {}, {}, ctxOf({ taskChoice: plan({ image_model: { model: 'fixture/image', source: 'openvibe' }, daily_cap: 5 }) })), (e) => e.status === 429 && e.code === 'image.daily_cap');++ assert.strictEqual(capped.calls.executes.length, 0);++});++++t.test('the task is in the catalogue, and the owner defaults take its model, cap and guest rule', async () => {++ const h = await boot({ env: { AI_IMAGE_MODEL: 'gpt-image-1' } });++ try {++ const admin = token('person', [], { actorType: 'user', sub: SUBJECT, extra: { subject_id: SUBJECT, role: 'admin' } });++ const list = await request(h.base, 'GET', '/api/v1/tasks');++ assert.strictEqual(list.status, 200, list.text);++ const task = list.body.tasks.find((x) => x.name === 'image.picture');++ assert.ok(task, 'image.picture is seeded');++ assert.strictEqual(task.workflow, 'image.picture');++ const defaults = await request(h.base, 'GET', '/api/v1/admin/task-defaults', { tok: admin });++ assert.strictEqual(defaults.status, 200, defaults.text);++ assert.strictEqual(defaults.body.defaults['image.picture'].image_model, null, 'make starts off');++ assert.strictEqual(defaults.body.defaults['image.picture'].guest_find, false);++ assert.deepStrictEqual(defaults.body.defaults['image.picture'].search_engines.map((e) => e.engine), ['commons', 'openverse']);++ const value = { signed_out: { model: 'openvibe/auto', source: 'openvibe' }, signed_in: { model: 'openvibe/auto', source: 'openvibe' }, fallbacks: [], image_model: { model: 'gpt-image-1', source: 'openvibe' }, search_engines: [{ engine: 'openverse', enabled: true }, { engine: 'commons', enabled: false }], max_cost_usd: null, daily_cap: 6, guest_find: true };++ const put = await request(h.base, 'PUT', '/api/v1/admin/task-defaults', { tok: admin, body: { defaults: [{ task: 'image.picture', value }] } });++ assert.strictEqual(put.status, 200, put.text);++ assert.deepStrictEqual(put.body.defaults['image.picture'], value);++ } finally { await h.stop(); }++});++++t.test('a make dry run is a run of the task: the owner model, the daily cap query and the estimate', async () => {++ const h = await boot({ env: { AI_IMAGE_MODEL: 'gpt-image-1' } });++ try {++ await h.registry.upsertModel({ provider_key: 'shared', model_key: 'gpt-image-1', type: 'image', metadata: { price_per_image_usd: 0.04 } });++ const value = { signed_out: { model: 'openvibe/auto', source: 'openvibe' }, signed_in: { model: 'openvibe/auto', source: 'openvibe' }, fallbacks: [], image_model: { model: 'gpt-image-1', source: 'openvibe' }, search_engines: [], max_cost_usd: null, daily_cap: 4, guest_find: false };++ await h.db.prepare("UPDATE ai_task_defaults SET value = ? WHERE task = 'image.picture'").run(JSON.stringify(value));++ const tok = token('blog', ALL);++ const r = await request(h.base, 'POST', '/api/v1/runs?wait=10000', { tok, body: { workflow: 'image.picture', on_behalf_of: { type: 'user', id: SUBJECT }, input: { mode: 'make', prompt: 'A goose', n: 2, dry_run: true, media_app: 'blog' } } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.body.run.status, 'succeeded', JSON.stringify(r.body.run.error));++ assert.deepStrictEqual(r.body.run.output.estimate, { model: 'openai/gpt-image-1', source: 'openvibe', images: 2, per_image_usd: 0.04, price_usd: 0.08 });++ assert.deepStrictEqual(r.body.run.output.media, []);++ } finally { await h.stop(); }++});++++t.test('a priced image run settles at the per-image price', async () => {++ const up = await seamServer(() => ({ data: [{ b64_json: PNG.toString('base64') }, { b64_json: PNG.toString('base64') }] }));++ const h = await boot({ env: { AI_IMAGE_MODEL: 'gpt-image-1', AI_BASE_URL: `${up.url}/v1`, AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0' } });++ try {++ await h.registry.upsertModel({ provider_key: 'shared', model_key: 'gpt-image-1', type: 'image', metadata: { price_per_image_usd: 0.04 } });++ const route = await h.registry.resolveRoute('default.image');++ const exec = await h.pool.execute(route, 'image', { prompt: 'x', n: 2, timeoutMs: 5000 }, { workflowKey: 'image.picture', imagePriceUsd: 0.04, routeKey: route.key, routeVersion: route.version });++ assert.strictEqual(exec.usage.output, 2);++ assert.ok(Math.abs(exec.cost - 0.08) < 1e-9, `cost 0.08, got ${exec.cost}`);++ const plain = await h.pool.execute(route, 'image', { prompt: 'x', n: 1, timeoutMs: 5000 }, { workflowKey: 'ai.image', routeKey: route.key, routeVersion: route.version });++ assert.strictEqual(plain.cost, 0, 'an unpriced image run keeps the old zero-cost behaviour');++ } finally { await h.stop(); await up.close(); }++});++++t.test('the Media client uploads into the calling product\'s tenant for the person', async () => {++ const seen = [];++ const srv = http.createServer((req, res) => {++ let raw = '';++ req.on('data', (c) => { raw += c; });++ req.on('end', () => {++ seen.push({ method: req.method, url: req.url, subject: req.headers['x-ov-subject'], body: raw });++ const json = (st, o) => { res.writeHead(st, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };++ if (req.method === 'POST' && req.url === '/api/v2/wiki/objects') return json(201, { id: MEDIA, upload: { url: 'https://openvibe.media/o/x', token: 't', complete_url: 'https://openvibe.media/o/x/complete' } });++ if (req.method === 'PUT' && req.url === '/o/x') return json(200, { ok: true });++ if (req.method === 'POST' && req.url === '/o/x/complete') return json(200, { object: { id: MEDIA, lifecycle_status: 'ready', width: 40, height: 30 } });++ json(404, {});++ });++ });++ await new Promise((r) => srv.listen(0, '127.0.0.1', r));++ try {++ const { createMediaStore } = require('../server/media-store');++ const media = { publicUrl: 'https://openvibe.media', internalUrl: `http://127.0.0.1:${srv.address().port}`, app: 'ai' };++ const store = createMediaStore({ config: { media, networkInternalUrl: 'http://network.test' }, env: { OV_OAUTH_CLIENT_SECRET: 'not-a-secret' }, fetchImpl: fetch, tokens: { authHeaders: async () => ({ Authorization: 'Bearer test' }) } });++ const r = await store.put({ bytes: PNG, mime: 'image/png', owner: SUBJECT, app: 'wiki', metadata: { license: 'CC BY-SA 4.0' } });++ assert.strictEqual(r.media_id, MEDIA);++ assert.strictEqual(r.width, 40);++ assert.ok(seen.every((x) => x.subject === SUBJECT), 'the picture belongs to the person');++ assert.strictEqual(seen[0].url, '/api/v2/wiki/objects', 'the calling product\'s tenant');++ assert.match(seen[0].body, /"license":"CC BY-SA 4.0"/);++ } finally { await new Promise((r) => srv.close(r)); }++});++++t.test('bytes and providers are judged, not trusted', () => {++ assert.strictEqual(mimeOf(PNG), 'image/png');++ assert.strictEqual(mimeOf(Buffer.from([0xff, 0xd8, 0xff, 0])), 'image/jpeg');++ assert.strictEqual(freeProvider({ kind: 'stub' }), true);++ assert.strictEqual(freeProvider({ kind: 'openai', billing_profile: 'metered' }), false);++ assert.strictEqual(freeProvider({ kind: 'openai', billing_profile: 'subscription' }), true);++});++++t.run();@@@ -42,6 +42,7 @@ const EXAMPLES = {'ai.enrich': { record: { name: 'Widget' }, instructions: 'normalise the name' },'ai.embed': { input: ['one', 'two'] },'ai.image': { prompt: 'A goose eating croutons, watercolour.', size: '1024x1024', n: 1 },++ 'image.picture': { mode: 'find', query: 'SQLite', media_app: 'ai' },'wiki.generate_space': { topic: 'SQLite', max_pages: 6, sources: SRC(2) },'content.wiki': { title: 'SQLite', topic: 'SQLite', space: 'SQLite', query: 'SQLite', instruction: 'Write the article from its sources.', outline: ['History'], pages: ['SQLite'], sources: SRC(2) },'wiki.generate_page': { title: 'WAL mode', topic: 'SQLite', pages: ['SQLite', 'WAL mode', 'Journal'], images: [{ title: 'WAL diagram', description: 'How WAL writes' }], sources: SRC(3) },@@@ -144,6 +145,8 @@ t.test('every workflow has compiling, versioned input and output schemas and ant.test('every workflow runs end to end on the stub and returns schema-valid output', async () => {for (const w of await h.registry.listWorkflows()) {if (w.key === 'media.analyze' && !HAS_FFMPEG) { console.log('workflow media.analyze end to end: skipped (no ffprobe)'); continue; }++ // image.picture reads Commons/Openverse and writes Media: its own test brings a fake researcher and store.++ if (w.key === 'image.picture') { console.log('workflow image.picture end to end: covered by test/image-picture.test.js (no live fetch)'); continue; }const r = await request(h.base, 'POST', '/api/v1/runs?wait=10000', { tok, body: { workflow: w.key, input: EXAMPLES[w.key] } });assert.strictEqual(r.status, 201, `${w.key}: ${r.status} ${r.text.slice(0, 300)}`);const run = r.body.run;
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
+# The shared image task (`image.picture`)
+
+One workflow, two modes, for Blog's and Wiki's editors and writers. Every picture is stored in
+OpenVibe.Media and returned by reference (`med_…`); no image bytes are ever inline in a run's output.
+
+```
+POST /api/v1/runs?wait=60000
+{
+ "workflow": "image.picture",
+ "task": "image.picture",
+ "on_behalf_of": { "type": "user", "id": "usr_…" },
+ "input": { "mode": "find", "query": "a workbench", "count": 8, "media_app": "blog" }
+}
+```
+
+## find
+
+Licensed pictures from Wikimedia Commons and Openverse. Only a picture with a licence that allows
+reuse is fetched and stored; the licence, the author and the source page are kept in the Media
+object's metadata and in the answer. The owner's engine order (`search_engines`) and guest rule
+(`guest_find`) apply; signed-in editors may always find.
+
+```json
+{ "mode": "find", "task": "image.picture", "query": "a workbench", "media": [
+ { "media_id": "med_…", "url": "https://openvibe.media/o/med_…", "title": "A workbench",
+ "license": "CC BY-SA 4.0", "author": "Ada Example", "credit": "Ada Example, CC BY-SA 4.0",
+ "source_url": "https://commons.wikimedia.org/wiki/File:…", "provider": "commons", "ai": false }
+], "count": 1, "gaps": [] }
+```
+
+## make
+
+The owner's configured image model, through the ordinary provider routing. **Off until the owner
+picks a model**: the task default `image_model` is `null` at seed and make answers
+`503 image.make_unconfigured` until it is set.
+
+A dry run quotes first and calls no provider:
+
+```
+"input": { "mode": "make", "prompt": "A goose eating croutons", "n": 2, "dry_run": true }
+→ "estimate": { "model": "openai/gpt-image-1", "source": "openvibe", "images": 2,
+ "per_image_usd": 0.04, "price_usd": 0.08 }, "media": []
+```
+
+A real make reserves the run, calls the model, uploads each picture to Media and settles the run at
+`per_image_usd × images` (the model row's `metadata.price_per_image_usd`). A paid, unpriced image
+offer is refused (`402 image.price_required`) rather than guessed at. The owner's `daily_cap` limits
+made pictures per person per UTC day (`429 image.daily_cap`), and a made picture carries
+`"ai": true`, `"model": "…"` and the credit `Made with AI (<model>)` in its caption data.
+
+## Owner defaults (API)
+
+`GET|PUT /api/v1/admin/task-defaults`, owner only. The `image.picture` value adds:
+
+| field | meaning |
+|---|---|
+| `image_model` | the model make uses; `null` keeps make off |
+| `daily_cap` | made pictures per person per UTC day; `null` means the service default (24) |
+| `guest_find` | whether a run with no person may use find (seed: `false`) |
+| `search_engines` | find's engine order, Commons/Openverse |
+| `max_cost_usd` | a per-run ceiling for one make |
+
+## Media tenant
+
+`media_app` names the Media tenant: `blog` or `wiki` (their editors can attach the object by id),
+or absent for the AI tenant. Network grants the AI service `media.object.upload` in the `ai`, `blog`
+and `wiki` namespaces; Media seeds the `ai` tenant at boot.
@@ -13,7 +13,7 @@ const { PROVIDERS: ENGINES } = require('../research');
@@ -13,7 +13,7 @@ const { PROVIDERS: ENGINES } = require('../research');
const KINDS = ['text', 'code', 'vision', 'image', 'search'];
const KINDS = ['text', 'code', 'vision', 'image', 'search'];
const SOURCES = ['openvibe', 'keys', 'machines'];
const SOURCES = ['openvibe', 'keys', 'machines'];
-const GENERAL = ['ai.generate', 'ai.complete', 'ai.image', 'research.gather'];
+const GENERAL = ['ai.generate', 'ai.complete', 'ai.image', 'image.picture', 'research.gather'];
const MAX_COST_USD = 1000;
const MAX_COST_USD = 1000;
const CACHE_MS = 60000;
const CACHE_MS = 60000;
@@ -25,6 +25,7 @@ const SEEDS = [
@@ -25,6 +25,7 @@ const SEEDS = [
['blog.devblog.art', 'image', 'Draw title art for a devblog.', 'ai.image'],
['blog.devblog.art', 'image', 'Draw title art for a devblog.', 'ai.image'],
['wiki.article.write', 'text', 'Write a sourced wiki article.', 'ai.generate'],
['wiki.article.write', 'text', 'Write a sourced wiki article.', 'ai.generate'],
['wiki.article.images', 'search', 'Find licensed pictures for a wiki article.', 'research.gather'],
['wiki.article.images', 'search', 'Find licensed pictures for a wiki article.', 'research.gather'],
+ ['image.picture', 'image', 'Find a licensed picture or make one, stored in Media.', 'image.picture'],
['design.icon', 'code', 'Draw an original SVG icon.', 'ai.chat'],
['design.icon', 'code', 'Draw an original SVG icon.', 'ai.chat'],
['chat.reply', 'text', 'Answer a chat message.', 'ai.complete'],
['chat.reply', 'text', 'Answer a chat message.', 'ai.complete'],
['image.generate', 'image', 'Make an image from a description.', 'ai.image'],
['image.generate', 'image', 'Make an image from a description.', 'ai.image'],
@@ -64,7 +65,7 @@ function choice(value) {
@@ -64,7 +65,7 @@ function choice(value) {
function defaultValue(value) {
function defaultValue(value) {
const keys = value && typeof value === 'object' && !Array.isArray(value) ? Object.keys(value) : [];
const keys = value && typeof value === 'object' && !Array.isArray(value) ? Object.keys(value) : [];
if (!value || typeof value !== 'object' || Array.isArray(value)
if (!value || typeof value !== 'object' || Array.isArray(value)
- || keys.some((k) => !['signed_out', 'signed_in', 'fallbacks', 'image_model', 'search_engines', 'max_cost_usd', 'effort'].includes(k))
+ || keys.some((k) => !['signed_out', 'signed_in', 'fallbacks', 'image_model', 'search_engines', 'max_cost_usd', 'daily_cap', 'guest_find', 'effort'].includes(k))
|| (value.fallbacks != null && !Array.isArray(value.fallbacks))) {
|| (value.fallbacks != null && !Array.isArray(value.fallbacks))) {
throw new AiError(422, 'defaults.invalid', 'invalid task defaults');
throw new AiError(422, 'defaults.invalid', 'invalid task defaults');
}
}
@@ -77,6 +78,10 @@ function defaultValue(value) {
@@ -77,6 +78,10 @@ function defaultValue(value) {
image_model: value.image_model == null ? null : choice(value.image_model),
image_model: value.image_model == null ? null : choice(value.image_model),
search_engines: value.search_engines == null ? [] : value.search_engines,
search_engines: value.search_engines == null ? [] : value.search_engines,
max_cost_usd: value.max_cost_usd == null ? null : value.max_cost_usd,
max_cost_usd: value.max_cost_usd == null ? null : value.max_cost_usd,
+ // The image task's two extra owner controls: a whole-picture daily cap per person (made pictures,
+ // null for no cap) and whether a guest with no person may use find.
+ daily_cap: value.daily_cap == null ? null : value.daily_cap,
+ guest_find: value.guest_find === true,
};
};
const all = [out.signed_out, out.signed_in, ...out.fallbacks, ...(out.image_model ? [out.image_model] : [])];
const all = [out.signed_out, out.signed_in, ...out.fallbacks, ...(out.image_model ? [out.image_model] : [])];
if (all.some((c) => c.source !== 'openvibe')) throw new AiError(422, 'defaults.invalid', 'the owner’s defaults use OpenVibe supply');
if (all.some((c) => c.source !== 'openvibe')) throw new AiError(422, 'defaults.invalid', 'the owner’s defaults use OpenVibe supply');
@@ -90,6 +95,9 @@ function defaultValue(value) {
@@ -90,6 +95,9 @@ function defaultValue(value) {
|| out.max_cost_usd < 0 || out.max_cost_usd > MAX_COST_USD)) {
|| out.max_cost_usd < 0 || out.max_cost_usd > MAX_COST_USD)) {
throw new AiError(422, 'defaults.invalid', `max_cost_usd must be 0–${MAX_COST_USD} or null`);
throw new AiError(422, 'defaults.invalid', `max_cost_usd must be 0–${MAX_COST_USD} or null`);
}
}
+ if (out.daily_cap !== null && (!Number.isInteger(out.daily_cap) || out.daily_cap < 0 || out.daily_cap > 1000)) {
+ throw new AiError(422, 'defaults.invalid', 'daily_cap must be a whole number of pictures from 0 to 1000, or null');
+ }
return out;
return out;
}
}
@@ -116,6 +124,11 @@ function createChoices({ db, registry, preferences, credentials, machines, catal
@@ -116,6 +124,11 @@ function createChoices({ db, registry, preferences, credentials, machines, catal
// and in, with a small per-batch ceiling. The owner can change it on Exchange like any task default.
// and in, with a small per-batch ceiling. The owner can change it on Exchange like any task default.
await db.prepare('INSERT INTO ai_task_defaults (task, value, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT (task) DO NOTHING')
await db.prepare('INSERT INTO ai_task_defaults (task, value, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT (task) DO NOTHING')
.run('blog.changelog.tag', JSON.stringify({ signed_out: built('openvibe/cheapest'), signed_in: built('openvibe/cheapest'), fallbacks: [built('openvibe/cheapest')], image_model: null, search_engines: [], max_cost_usd: 0.1 }), 'seed', iso(clock.now()));
.run('blog.changelog.tag', JSON.stringify({ signed_out: built('openvibe/cheapest'), signed_in: built('openvibe/cheapest'), fallbacks: [built('openvibe/cheapest')], image_model: null, search_engines: [], max_cost_usd: 0.1 }), 'seed', iso(clock.now()));
+ // The shared image task (rich-content plan §4/§5): find is on for signed-in editors and off for guests
+ // until the owner says otherwise; make is off until the owner picks an image model (image_model: null).
+ const pictureEngines = [{ engine: 'commons', enabled: true }, { engine: 'openverse', enabled: true }];
+ await db.prepare('INSERT INTO ai_task_defaults (task, value, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT (task) DO NOTHING')
+ .run('image.picture', JSON.stringify({ signed_out: built('openvibe/auto'), signed_in: built('openvibe/auto'), fallbacks: [], image_model: null, search_engines: pictureEngines, max_cost_usd: null, daily_cap: 24, guest_find: false }), 'seed', iso(clock.now()));
}
}
async function register(body, service) {
async function register(body, service) {
@@ -207,6 +220,11 @@ function createChoices({ db, registry, preferences, credentials, machines, catal
@@ -207,6 +220,11 @@ function createChoices({ db, registry, preferences, credentials, machines, catal
fallbacks: picked.why === 'owner' ? owner.fallbacks : [],
fallbacks: picked.why === 'owner' ? owner.fallbacks : [],
max_cost_usd: owner ? owner.max_cost_usd : null,
max_cost_usd: owner ? owner.max_cost_usd : null,
search_engines: owner ? owner.search_engines : [],
search_engines: owner ? owner.search_engines : [],
+ // image.picture's two modes: the owner's model is the make switch (null = off), and the cap and
+ // guest rule are read by the step itself.
+ image_model: owner && owner.image_model ? owner.image_model : null,
+ daily_cap: owner && owner.daily_cap != null ? owner.daily_cap : null,
+ guest_find: Boolean(owner && owner.guest_find),
};
};
}
}
@@ -262,6 +262,15 @@ function load(env = process.env) {
@@ -262,6 +262,15 @@ function load(env = process.env) {
// Image generation (POST /api/v1/image, workflow ai.image): off until AI_IMAGE_MODEL names the shared
// Image generation (POST /api/v1/image, workflow ai.image): off until AI_IMAGE_MODEL names the shared
// provider's image model (dall-e-3, gpt-image-1); then boot seeds that model and route default.image.
// provider's image model (dall-e-3, gpt-image-1); then boot seeds that model and route default.image.
image: { model: String(env.AI_IMAGE_MODEL || '').trim(), size: String(env.AI_IMAGE_SIZE || '').trim() || '1024x1024' },
image: { model: String(env.AI_IMAGE_MODEL || '').trim(), size: String(env.AI_IMAGE_SIZE || '').trim() || '1024x1024' },
+ // The image task (workflow image.picture, rich-content plan §4/§5): pictures found on Wikimedia Commons and
+ // Openverse are fetched only from these hosts (the same SSRF rules as every other fetch) and stored in
+ // Media under AI_MEDIA_APP. An image model's per-image price lives on the model row's
+ // metadata.price_per_image_usd; the task refuses a paid, unpriced offer rather than guess.
+ pictures: {
+ sourceHosts: list(env.AI_PICTURE_SOURCE_HOSTS, ['upload.wikimedia.org', '*.wikimedia.org', 'api.openverse.org', '*.openverse.org']),
+ providerHosts: list(env.AI_PICTURE_PROVIDER_HOSTS, ['*.openai.com', '*.googleusercontent.com', '*.fal.media', '*.replicate.delivery']),
+ defaultDailyCap: int(env.AI_PICTURE_DAILY_CAP, 24),
+ },
// MegaSource (server/research/, workflow research.gather): web search asks the Search Fabric's web scope
// MegaSource (server/research/, workflow research.gather): web search asks the Search Fabric's web scope
// first (OpenVibe.Search POST /api/v1/query, capability search.fabric.query) as AI's service principal;
// first (OpenVibe.Search POST /api/v1/query, capability search.fabric.query) as AI's service principal;
// DeepSeek's paid search stays as the fallback when the fabric is off, refuses or answers nothing. The
// DeepSeek's paid search stays as the fallback when the fabric is off, refuses or answers nothing. The
@@ -459,6 +468,9 @@ function load(env = process.env) {
@@ -459,6 +468,9 @@ function load(env = process.env) {
media: {
media: {
publicUrl: trimUrl(env.OV_MEDIA_URL || 'https://openvibe.media'),
publicUrl: trimUrl(env.OV_MEDIA_URL || 'https://openvibe.media'),
internalUrl: trimUrl(env.OV_MEDIA_INTERNAL_URL || ''),
internalUrl: trimUrl(env.OV_MEDIA_INTERNAL_URL || ''),
+ // The Media tenant (and namespace) AI's pictures are stored in. Network grants the AI service
+ // media.object.upload there; Media seeds the tenant at boot.
+ app: String(env.AI_MEDIA_APP || 'ai').trim() || 'ai',
// https hostnames (exact, or *.suffix) that media/image URLs may point at.
// https hostnames (exact, or *.suffix) that media/image URLs may point at.
allowHosts: list(env.AI_FETCH_ALLOW_HOSTS, ['openvibe.media', 'openvibe.live', 'openvibe.network', '*.openvibe.network', 'openvibe.community', 'openvibe.tools', 'openvibe.games']),
allowHosts: list(env.AI_FETCH_ALLOW_HOSTS, ['openvibe.media', 'openvibe.live', 'openvibe.network', '*.openvibe.network', 'openvibe.community', 'openvibe.tools', 'openvibe.games']),
// Object storage Media hands recordings off to (a 302 to a presigned URL), and Media's origin shield
// Object storage Media hands recordings off to (a 302 to a presigned URL), and Media's origin shield
@@ -71,16 +71,22 @@ function hostAllowed(hostname, allowHosts) {
@@ -71,16 +71,22 @@ function hostAllowed(hostname, allowHosts) {
function createFetcher(config, { transport = null } = {}) {
function createFetcher(config, { transport = null } = {}) {
const m = config.media;
const m = config.media;
const internalOrigin = m.internalUrl ? new URL(m.internalUrl).origin : null;
const internalOrigin = m.internalUrl ? new URL(m.internalUrl).origin : null;
+ // Picture sources (Commons, Openverse) and image providers are separate, narrow lists: a caller's
+ // media URL never gets them, only the image task's own fetchPicture does.
+ const pictureHosts = [
+ ...((config.pictures && config.pictures.sourceHosts) || []),
+ ...((config.pictures && config.pictures.providerHosts) || []),
+ ];
/** Judge one URL. Returns { url, internal } or throws AiError 422 fetch.refused. */
/** Judge one URL. Returns { url, internal } or throws AiError 422 fetch.refused. */
- function judge(raw) {
+ function judge(raw, hosts = m.allowHosts) {
let u;
let u;
try { u = new URL(String(raw)); } catch { throw new AiError(422, 'fetch.refused', 'not a valid URL'); }
try { u = new URL(String(raw)); } catch { throw new AiError(422, 'fetch.refused', 'not a valid URL'); }
if (u.username || u.password) throw new AiError(422, 'fetch.refused', 'credentials in URLs are not allowed');
if (u.username || u.password) throw new AiError(422, 'fetch.refused', 'credentials in URLs are not allowed');
if (internalOrigin && u.origin === internalOrigin) return { url: u, internal: true };
if (internalOrigin && u.origin === internalOrigin) return { url: u, internal: true };
if (u.protocol !== 'https:') throw new AiError(422, 'fetch.refused', `only https URLs on allow-listed OpenVibe hosts are fetched (got ${u.protocol})`);
if (u.protocol !== 'https:') throw new AiError(422, 'fetch.refused', `only https URLs on allow-listed OpenVibe hosts are fetched (got ${u.protocol})`);
if (net.isIP(u.hostname.replace(/^\[|\]$/g, ''))) throw new AiError(422, 'fetch.refused', 'IP-literal hosts are not fetched');
if (net.isIP(u.hostname.replace(/^\[|\]$/g, ''))) throw new AiError(422, 'fetch.refused', 'IP-literal hosts are not fetched');
- if (!hostAllowed(u.hostname, m.allowHosts)) throw new AiError(422, 'fetch.refused', `host ${u.hostname} is not on the allow-list`);
+ if (!hostAllowed(u.hostname, hosts)) throw new AiError(422, 'fetch.refused', `host ${u.hostname} is not on the allow-list`);
return { url: u, internal: false };
return { url: u, internal: false };
}
}
@@ -89,10 +95,10 @@ function createFetcher(config, { transport = null } = {}) {
@@ -89,10 +95,10 @@ function createFetcher(config, { transport = null } = {}) {
const mediaPublicHost = (() => { try { return new URL(m.publicUrl).hostname.toLowerCase(); } catch { return null; } })();
const mediaPublicHost = (() => { try { return new URL(m.publicUrl).hostname.toLowerCase(); } catch { return null; } })();
const fromMedia = (t) => t.internal || (mediaPublicHost && t.url.hostname.toLowerCase() === mediaPublicHost);
const fromMedia = (t) => t.internal || (mediaPublicHost && t.url.hostname.toLowerCase() === mediaPublicHost);
- function follow(from, location) {
+ function follow(from, location, hosts = m.allowHosts) {
let next;
let next;
try {
try {
- next = judge(location);
+ next = judge(location, hosts);
} catch (err) {
} catch (err) {
// Media hands an offloaded recording to object storage with a 302 to a presigned URL: that storage (and
// Media hands an offloaded recording to object storage with a 302 to a presigned URL: that storage (and
// only it) is followed, only from Media, over https, never to an IP literal; connect-time checks still apply.
// only it) is followed, only from Media, over https, never to an IP literal; connect-time checks still apply.
@@ -158,17 +164,25 @@ function createFetcher(config, { transport = null } = {}) {
@@ -158,17 +164,25 @@ function createFetcher(config, { transport = null } = {}) {
});
});
}
}
- async function fetchUrl(raw, { signal, maxBytes = m.maxBytes, timeoutMs = m.timeoutMs, toFile = null } = {}) {
- let target = judge(raw);
+ async function fetchUrl(raw, { signal, maxBytes = m.maxBytes, timeoutMs = m.timeoutMs, toFile = null, hosts = m.allowHosts } = {}) {
+ let target = judge(raw, hosts);
for (let hop = 0; hop < 4; hop++) {
for (let hop = 0; hop < 4; hop++) {
const r = await (transport || once)(target, { signal, maxBytes, timeoutMs, toFile });
const r = await (transport || once)(target, { signal, maxBytes, timeoutMs, toFile });
if (!r.redirect) return { ...r, url: target.url.toString() };
if (!r.redirect) return { ...r, url: target.url.toString() };
if (hop === 3) throw new AiError(502, 'source.unavailable', 'too many redirects');
if (hop === 3) throw new AiError(502, 'source.unavailable', 'too many redirects');
- target = follow(target, r.redirect);
+ target = follow(target, r.redirect, hosts);
}
}
throw new AiError(502, 'source.unavailable', 'too many redirects');
throw new AiError(502, 'source.unavailable', 'too many redirects');
}
}
+ /** A licensed picture (Commons/Openverse) or an image provider's own URL, fetched under the picture host lists. */
+ async function fetchPicture(raw, { signal, maxBytes = m.maxImageBytes, timeoutMs = 30000 } = {}) {
+ const r = await fetchUrl(raw, { signal, maxBytes, timeoutMs, hosts: [...m.allowHosts, ...pictureHosts] });
+ const type = String(r.contentType || '').split(';')[0].trim().toLowerCase();
+ if (!/^image\//.test(type)) throw new AiError(422, 'source.invalid', `expected an image, got ${type || 'unknown type'}`);
+ return { bytes: r.buffer, mime: type, url: r.url };
+ }
+
/**
/**
* One upstream response for a media reader (server/media-proxy.js): the same URL rules as fetchUrl, redirects
* One upstream response for a media reader (server/media-proxy.js): the same URL rules as fetchUrl, redirects
* judged and followed here (the reader never sees them), a Range header passed through, the body left as a stream.
* judged and followed here (the reader never sees them), a Range header passed through, the body left as a stream.
@@ -261,7 +275,7 @@ function createFetcher(config, { transport = null } = {}) {
@@ -261,7 +275,7 @@ function createFetcher(config, { transport = null } = {}) {
return { file, url };
return { file, url };
}
}
- return { judge, follow, fetchUrl, openStream, mediaUrlOf, mediaRefUrl, loadImage, loadMediaToFile, hostAllowed: (h) => hostAllowed(h, m.allowHosts) };
+ return { judge, follow, fetchUrl, fetchPicture, openStream, mediaUrlOf, mediaRefUrl, loadImage, loadMediaToFile, hostAllowed: (h) => hostAllowed(h, m.allowHosts) };
}
}
/** Any image -> downscaled JPEG (sharp, optional). Falls back to the original bytes. */
/** Any image -> downscaled JPEG (sharp, optional). Falls back to the original bytes. */
@@ -32,7 +32,7 @@ const { AiError } = require('./util');
@@ -32,7 +32,7 @@ const { AiError } = require('./util');
const { gracefulStop, telemetry } = require('openvibe-sdk/service');
const { gracefulStop, telemetry } = require('openvibe-sdk/service');
const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');
const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');
-async function start({ searchFetch = null, config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null, community: givenCommunity = null, communityEndpoints: givenEndpoints = null, webhooks: givenWebhooks = null, abuse: givenAbuse = null, memoryTransport = null, memoryLookup = null, ownerNotify = null } = {}) {
+async function start({ searchFetch = null, config, db: givenDb = null, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, env = process.env, log = console, listen = true, credentialFetch = null, valkey = null, research = null, machineModels = null, variantEvidence = null, community: givenCommunity = null, communityEndpoints: givenEndpoints = null, webhooks: givenWebhooks = null, abuse: givenAbuse = null, memoryTransport = null, memoryLookup = null, ownerNotify = null, mediaStore: givenMediaStore = null } = {}) {
config = config || load(env);
config = config || load(env);
schemas.configure(config.schemaCache);
schemas.configure(config.schemaCache);
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
@@ -104,8 +104,10 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
@@ -104,8 +104,10 @@ async function start({ searchFetch = null, config, db: givenDb = null, clock = {
const runBudgets = createRunBudgets(db, { clock });
const runBudgets = createRunBudgets(db, { clock });
const cache = createCache(db, { clock });
const cache = createCache(db, { clock });
const fetcher = createFetcher(config);
const fetcher = createFetcher(config);
+ // Pictures into Media for the image task (server/media-store.js): one tenant, the owner's namespace.
+ const mediaStore = givenMediaStore || require('./media-store').createMediaStore({ config, env, fetchImpl, log });
// MegaSource (server/research/): the DeepSeek key resolves by name from this environment; tests hand in their own.
// MegaSource (server/research/): the DeepSeek key resolves by name from this environment; tests hand in their own.
- const engine = createEngine({ registry, pool, fetcher, quotas, config, log, research: research || require('./research').createResearch({ config: config.research, env, log }) });
+ const engine = createEngine({ registry, pool, fetcher, quotas, config, log, db, clock, mediaStore, research: research || require('./research').createResearch({ config: config.research, env, log }) });
// ai.run.* to OpenVibe.Events through the outbox (server/events.js); before recovery, so its failures are announced.
// ai.run.* to OpenVibe.Events through the outbox (server/events.js); before recovery, so its failures are announced.
require('./events').init(db, { log });
require('./events').init(db, { log });
// platform.usage-sample@1 readings to OpenVibe.Billing through their own outbox (server/usage-samples.js).
// platform.usage-sample@1 readings to OpenVibe.Billing through their own outbox (server/usage-samples.js).
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
+'use strict';
+/**
+ * Pictures into OpenVibe.Media (media.object.upload, the AI tenant): init, bytes, complete (the v2
+ * object API). Every picture the image task returns is a Media reference (`med_…`), never inline
+ * bytes in the run's output: the editor attaches the id, and Media owns the bytes, its visibility
+ * and its licence metadata.
+ *
+ * A picture is uploaded for the person the run acts for (`X-OV-Subject`) when there is one; a
+ * service-only find (a guest, when the owner allows it) belongs to the AI tenant. Off unless
+ * OV_MEDIA_INTERNAL_URL and the service secret are both configured.
+ */
+const crypto = require('crypto');
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+function createMediaStore({ config, env = process.env, fetchImpl = globalThis.fetch, tokens = null, log = console } = {}) {
+ const media = (config && config.media) || {};
+ const baseUrl = String(media.internalUrl || '').replace(/\/+$/, '');
+ const app = media.app || 'ai';
+ let client = tokens;
+ const tokenClient = () => {
+ if (client) return client;
+ const secret = env.OV_OAUTH_CLIENT_SECRET;
+ if (!baseUrl || !secret) return null;
+ client = createServiceTokenClient({
+ tokenUrl: `${String(config.networkInternalUrl).replace(/\/+$/, '')}/oauth/token`,
+ clientId: env.OV_OAUTH_CLIENT_ID || 'ai', clientSecret: secret,
+ audience: 'openvibe.media', scope: 'media.object.upload', fetch: fetchImpl,
+ });
+ return client;
+ };
+ const configured = () => Boolean(baseUrl && (client || env.OV_OAUTH_CLIENT_SECRET || tokens));
+
+ /** The object's public bytes URL, what a figure block and an editor preview use. */
+ function publicUrl(mediaId) {
+ return mediaId ? `${String(media.publicUrl || 'https://openvibe.media').replace(/\/+$/, '')}/o/${encodeURIComponent(mediaId)}` : null;
+ }
+
+ async function call(url, init, timeoutMs = 30000) {
+ let res;
+ try {
+ res = await fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
+ } catch (err) {
+ throw Object.assign(new Error(`Media did not answer: ${err && err.name === 'TimeoutError' ? 'timeout' : err && err.message}`), { status: 503, code: 'media.unavailable' });
+ }
+ const body = await res.json().catch(() => null);
+ if (res.status < 200 || res.status > 299) {
+ throw Object.assign(new Error(`Media answered ${res.status}${body && (body.code || body.title) ? ` (${body.code || body.title})` : ''}`), {
+ status: res.status === 413 || res.status === 415 || res.status === 422 ? 422 : 503, code: 'media.upload_refused',
+ });
+ }
+ return body || {};
+ }
+
+ /**
+ * A picture into Media for the person the run acts for. `app` is the tenant the calling product keeps its
+ * pictures in (blog, wiki, or AI's own ai); absent, the AI tenant. → { media_id, url, width, height, mime }.
+ * metadata keeps where it came from (source_url, license, author) or what made it (model, ai).
+ */
+ async function put({ bytes, mime, filename = 'picture', owner = null, app: tenant = null, metadata = {}, timeoutMs = 30000 } = {}) {
+ if (!configured()) throw Object.assign(new Error('OpenVibe.Media is not configured (OV_MEDIA_INTERNAL_URL and the AI service secret are needed)'), { status: 503, code: 'media.unavailable' });
+ if (!Buffer.isBuffer(bytes) || !bytes.length) throw Object.assign(new Error('the picture has no bytes'), { status: 422, code: 'media.empty' });
+ const target = tenant && /^[a-z][a-z0-9-]{1,31}$/.test(String(tenant)) ? String(tenant) : app;
+ const base = `${baseUrl}/api/v2/${encodeURIComponent(target)}/objects`;
+ const auth = await tokenClient().authHeaders();
+ const subject = owner && SUBJECT_RE.test(owner) ? { 'X-OV-Subject': owner } : {};
+ const init = await call(base, {
+ method: 'POST', headers: { ...auth, ...subject, 'Content-Type': 'application/json', Accept: 'application/json' },
+ body: JSON.stringify({
+ kind: 'asset', visibility: 'public', mime_type: mime, size_bytes: bytes.length,
+ filename: String(filename || 'picture').slice(0, 120), content_hash: crypto.createHash('sha256').update(bytes).digest('hex'),
+ metadata,
+ }),
+ }, timeoutMs);
+ const id = init.id || (init.object && init.object.id);
+ const up = init.upload || {};
+ if (!id || !up.url) throw Object.assign(new Error('Media did not give an upload address'), { status: 503, code: 'media.upload_refused' });
+ // The upload and complete addresses may name Media's public origin; AI reaches it on the internal one.
+ const internal = (u) => {
+ const x = new URL(u, baseUrl);
+ return `${baseUrl}${x.pathname}${x.search}`;
+ };
+ await call(internal(up.url), { method: 'PUT', headers: { ...auth, ...subject, 'Content-Type': mime, ...(up.token ? { 'X-Upload-Token': up.token } : {}) }, body: bytes }, timeoutMs);
+ const done = await call(internal(up.complete_url || `${base}/${encodeURIComponent(id)}/complete`), {
+ method: 'POST', headers: { ...auth, ...subject, 'Content-Type': 'application/json', Accept: 'application/json', ...(up.token ? { 'X-Upload-Token': up.token } : {}) }, body: '{}',
+ }, timeoutMs);
+ const object = done.object || done;
+ return {
+ media_id: id, url: publicUrl(id),
+ width: Number.isInteger(object.width) ? object.width : null, height: Number.isInteger(object.height) ? object.height : null,
+ mime: object.mime_type || mime || null,
+ };
+ }
+
+ return { configured, publicUrl, put };
+}
+
+module.exports = { createMediaStore };
@@ -137,12 +137,12 @@ route('get', '/creations.md', {
@@ -137,12 +137,12 @@ route('get', '/creations.md', {
// ── AI tasks and choices (rich-content plan §1) ──────────────────────────────────────────────
// ── AI tasks and choices (rich-content plan §1) ──────────────────────────────────────────────
route('get', '/tasks', {
route('get', '/tasks', {
operationId: 'listTasks', summary: 'The task catalogue: every named piece of AI work', tags: ['choices'], security: [],
operationId: 'listTasks', summary: 'The task catalogue: every named piece of AI work', tags: ['choices'], security: [],
- description: 'Public. A task names its service, its kind (text, code, vision, image, search), one sentence and its built-in default choice. A service registers its own with POST /api/v1/tasks.',
+ description: 'Public. A task names its service, its kind (text, code, vision, image, search), one sentence and its built-in default choice. A service registers its own with POST /api/v1/tasks. image.picture is the shared image task: find licensed Commons/Openverse pictures or make one with the owner\'s configured image model, every picture stored in Media and returned by Media id.',
responses: { 200: json('The catalogue', { type: 'object', required: ['tasks'], properties: { tasks: arrayOf(ref('Task')) } }), ...errors([429]) },
responses: { 200: json('The catalogue', { type: 'object', required: ['tasks'], properties: { tasks: arrayOf(ref('Task')) } }), ...errors([429]) },
});
});
route('post', '/tasks', {
route('post', '/tasks', {
operationId: 'registerTask', summary: 'Register a task in the caller’s own namespace', tags: ['choices'], security: SERVICE,
operationId: 'registerTask', summary: 'Register a task in the caller’s own namespace', tags: ['choices'], security: SERVICE,
- description: 'ai.workflow.manage. Only a service token registers tasks, and only under its own namespace: the workflow is the service’s own or a general AI workflow (ai.generate, ai.complete, ai.image, research.gather). A registration is audited.',
+ description: 'ai.workflow.manage. Only a service token registers tasks, and only under its own namespace: the workflow is the service’s own or a general AI workflow (ai.generate, ai.complete, ai.image, image.picture, research.gather). A registration is audited.',
requestBody: body({ type: 'object', required: ['name', 'kind', 'sentence', 'default'], properties: {
requestBody: body({ type: 'object', required: ['name', 'kind', 'sentence', 'default'], properties: {
name: str({ pattern: '^[a-z][a-z0-9_-]*\\.[a-z0-9_.-]{1,100}$' }), kind: str({ enum: ['text', 'code', 'vision', 'image', 'search'] }),
name: str({ pattern: '^[a-z][a-z0-9_-]*\\.[a-z0-9_.-]{1,100}$' }), kind: str({ enum: ['text', 'code', 'vision', 'image', 'search'] }),
sentence: str({ maxLength: 300 }), default: ref('Choice'), workflow: str(),
sentence: str({ maxLength: 300 }), default: ref('Choice'), workflow: str(),
@@ -1854,14 +1854,16 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
@@ -1854,14 +1854,16 @@ function createProviderPool({ db, registry, config, clock = { now: () => Date.no
const usage = result.usage || { input: 0, output: 0, cached: 0 };
const usage = result.usage || { input: 0, output: 0, cached: 0 };
await recordEvidence({ provider: c.provider, model, ok: true, timeout: false, ttftMs, latencyMs: latency,
await recordEvidence({ provider: c.provider, model, ok: true, timeout: false, ttftMs, latencyMs: latency,
outputTokens: usage.output || 0, inputTokens: usage.input || 0, cachedTokens: usage.cached || 0 });
outputTokens: usage.output || 0, inputTokens: usage.input || 0, cachedTokens: usage.cached || 0 });
- // An image is metered per image (usage.output = images), and no rate card prices one yet (an owner/T5
- // item): its cost is 0 rather than the image count read as output tokens on the token cards.
- // BA1: the cost is the tier the answer really ran on, and the wait's saving is the
- // standard price minus that one. A provider that does not say which tier it served is
- // priced at the list price: a saving nobody confirmed is not a saving.
+ // An image is metered per image (usage.output = images). The image task (workflow image.picture)
+ // sets ctx.imagePriceUsd from the offer's metadata.price_per_image_usd before the call, so the
+ // measured count settles at that price; every other image run has no card and costs 0 rather
+ // than the image count being read as output tokens.
const ranOn = tiers.tierOfAnswer(result, askedTier);
const ranOn = tiers.tierOfAnswer(result, askedTier);
+ const imagePrice = ctx.imagePriceUsd == null ? null : Number(ctx.imagePriceUsd);
const priced = operation === 'image'
const priced = operation === 'image'
- ? { cost: 0, standard: 0, saved: 0 }
+ ? (Number.isFinite(imagePrice) && imagePrice >= 0
+ ? { cost: imagePrice * (Number(usage.output) || 0), standard: imagePrice * (Number(usage.output) || 0), saved: 0 }
+ : { cost: 0, standard: 0, saved: 0 })
: await costOnTier({ providerKey: c.provider, model: result.model || model, usage, tier: ranOn, p });
: await costOnTier({ providerKey: c.provider, model: result.model || model, usage, tier: ranOn, p });
const cost = priced.cost;
const cost = priced.cost;
statFor(c.provider).ok++;
statFor(c.provider).ok++;
@@ -361,7 +361,7 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e
@@ -361,7 +361,7 @@ function createRegistry(db, { clock = { now: () => Date.now() }, env = process.e
schemas.assertSchema(w.output_schema, 'output_schema');
schemas.assertSchema(w.output_schema, 'output_schema');
if (!Array.isArray(w.steps) || !w.steps.length) throw new AiError(422, 'ai.invalid', 'workflow needs at least one step');
if (!Array.isArray(w.steps) || !w.steps.length) throw new AiError(422, 'ai.invalid', 'workflow needs at least one step');
for (const s of w.steps) {
for (const s of w.steps) {
- if (!s || !['llm', 'passthrough', 'complete', 'messages', 'transcribe', 'embed', 'image', 'media_analysis', 'research', 'devblog'].includes(s.kind)) throw new AiError(422, 'ai.invalid', 'step kind must be llm, passthrough, complete, messages, transcribe, embed, image, media_analysis or research');
+ if (!s || !['llm', 'passthrough', 'complete', 'messages', 'transcribe', 'embed', 'image', 'picture', 'media_analysis', 'research', 'devblog'].includes(s.kind)) throw new AiError(422, 'ai.invalid', 'step kind must be llm, passthrough, complete, messages, transcribe, embed, image, picture, media_analysis or research');
if ((s.kind === 'llm' || (s.kind === 'media_analysis' && s.template)) && !(s.template && await latestAny('templates', s.template))) throw new AiError(422, 'ai.invalid', `step references unknown template ${s.template}`);
if ((s.kind === 'llm' || (s.kind === 'media_analysis' && s.template)) && !(s.template && await latestAny('templates', s.template))) throw new AiError(422, 'ai.invalid', `step references unknown template ${s.template}`);
}
}
if (!['none', 'private', 'service'].includes(w.cache_mode)) throw new AiError(422, 'ai.invalid', 'cache_mode must be none, private or service');
if (!['none', 'private', 'service'].includes(w.cache_mode)) throw new AiError(422, 'ai.invalid', 'cache_mode must be none, private or service');
@@ -13,6 +13,9 @@ function createOpenverse({ http }) {
@@ -13,6 +13,9 @@ function createOpenverse({ http }) {
return (j.results || []).filter((r) => r.url && /^https:/.test(r.url) && (r.width || 0) >= 320 && NAMES[r.license]).map((r) => ({
return (j.results || []).filter((r) => r.url && /^https:/.test(r.url) && (r.width || 0) >= 320 && NAMES[r.license]).map((r) => ({
url: r.url,
url: r.url,
original: r.url,
original: r.url,
+ // Openverse's own thumbnail proxy is the small, stable URL the image task fetches; the original
+ // may live on a host we do not fetch (Flickr, a museum).
+ thumbnail: r.thumbnail || null,
width: r.width || null,
width: r.width || null,
height: r.height || null,
height: r.height || null,
mime: null,
mime: null,
@@ -47,6 +47,7 @@ function imageFrom(page, provider, fallbackSource, { allowSvg = false } = {}) {
@@ -47,6 +47,7 @@ function imageFrom(page, provider, fallbackSource, { allowSvg = false } = {}) {
original: untracked(ii.url),
original: untracked(ii.url),
width: ii.thumbwidth || width,
width: ii.thumbwidth || width,
height: ii.thumbheight || height,
height: ii.thumbheight || height,
+ thumbnail: untracked(ii.thumburl || ii.url),
mime: svg ? 'image/png' : ii.mime,
mime: svg ? 'image/png' : ii.mime,
title: stripTags(v('ObjectName')) || String(page.title || '').replace(/^File:/, '').replace(/\.[a-z0-9]{2,5}$/i, '').replace(/_/g, ' '),
title: stripTags(v('ObjectName')) || String(page.title || '').replace(/^File:/, '').replace(/\.[a-z0-9]{2,5}$/i, '').replace(/_/g, ' '),
description: clip(stripTags(v('ImageDescription')), 300) || null,
description: clip(stripTags(v('ImageDescription')), 300) || null,
@@ -155,7 +155,7 @@ function batchExec(b, result) {
@@ -155,7 +155,7 @@ function batchExec(b, result) {
};
};
}
}
-function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spawnImpl, log = console, research = null }) {
+function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spawnImpl, log = console, research = null, db = null, clock = { now: () => Date.now() }, mediaStore = null }) {
pool = require('../effort/pool').wrapPool(pool);
pool = require('../effort/pool').wrapPool(pool);
// One loopback reader for everything that streams media (server/media-proxy.js): transcripts and media.analyze.
// One loopback reader for everything that streams media (server/media-proxy.js): transcripts and media.analyze.
let proxy = null;
let proxy = null;
@@ -169,6 +169,8 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
@@ -169,6 +169,8 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
if (r.disabled) throw new AiError(503, 'route.unavailable', `route ${key} is disabled`);
if (r.disabled) throw new AiError(503, 'route.unavailable', `route ${key} is disabled`);
return r;
return r;
}
}
+ // The shared image task (image.picture): find licensed pictures or make one with the owner's model.
+ const pictures = require('./pictures').createPictures({ registry, pool, fetcher, config, db, clock, mediaStore, routeFor, research: megasource, log });
/**
/**
* Everything an llm step needs before its provider call: the template it pinned, the rendered system and
* Everything an llm step needs before its provider call: the template it pinned, the rendered system and
@@ -569,7 +571,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
@@ -569,7 +571,7 @@ function createEngine({ registry, pool, fetcher, quotas = null, config = {}, spa
return { output, exec: { ...last.exec, usage, cost, savedUsd }, route: last.route };
return { output, exec: { ...last.exec, usage, cost, savedUsd }, route: last.route };
}
}
- const STEP = { devblog: devblogStep, llm: llmStep, passthrough: passthroughStep, complete: completeStep, messages: messagesStep, transcribe: transcribeStep, embed: embedStep, image: imageStep, media_analysis: mediaAnalysis.step, research: researchStep };
+ const STEP = { devblog: devblogStep, llm: llmStep, passthrough: passthroughStep, complete: completeStep, messages: messagesStep, transcribe: transcribeStep, embed: embedStep, image: imageStep, picture: pictures.step, media_analysis: mediaAnalysis.step, research: researchStep };
/**
/**
* Run a workflow. ctx: { signal, logRequest, debugRaw, inputHash }
* Run a workflow. ctx: { signal, logRequest, debugRaw, inputHash }
new file mode 100644
new file mode 100644
@@ -0,0 +1,328 @@
@@ -0,0 +1,328 @@
+'use strict';
+/**
+ * The image task (rich-content plan §4/§5): one workflow, `image.picture`, with two modes.
+ *
+ * find licensed pictures from Wikimedia Commons and Openverse, with the licence, the author and the
+ * source page kept. Nothing without a licence that allows reuse is fetched or uploaded. Each
+ * picture goes to OpenVibe.Media; the answer names the Media object, never inline bytes.
+ * make the owner's configured image model, through the ordinary provider routing: a dry run first
+ * (image count, model, per-image price, total), then a priced reserve/estimate, the provider
+ * call, and one Media object per returned image labelled "Made with AI (<model>)".
+ *
+ * The task is the one Blog's editor and Wiki's editor/writer share. It never publishes and never
+ * attaches anything: a Media id is a candidate an editor chooses.
+ */
+const { AiError, sha256 } = require('../util');
+const { canonicalOf } = require('../exchange/catalog');
+
+const MODES = ['find', 'make'];
+const SOURCES = ['commons', 'openverse'];
+const STR = (n) => ({ type: 'string', maxLength: n });
+const NULLABLE = (n) => ({ type: ['string', 'null'], maxLength: n });
+const INT = (lo, hi) => ({ type: 'integer', minimum: lo, maximum: hi });
+
+const MEDIA = {
+ type: 'object', additionalProperties: false,
+ required: ['media_id', 'url', 'ai'],
+ properties: {
+ media_id: STR(80), url: STR(2048), width: { type: ['integer', 'null'] }, height: { type: ['integer', 'null'] },
+ title: NULLABLE(300), alt: NULLABLE(400), caption: NULLABLE(500), credit: NULLABLE(300),
+ author: NULLABLE(200), license: NULLABLE(200), license_url: NULLABLE(2048), source_url: NULLABLE(2048),
+ provider: STR(80), ai: { type: 'boolean' }, model: NULLABLE(200),
+ },
+};
+
+const ESTIMATE = {
+ type: 'object', additionalProperties: false, required: ['model', 'source', 'images', 'per_image_usd', 'price_usd'],
+ properties: {
+ model: STR(200), source: STR(10), images: INT(1, 4),
+ per_image_usd: { type: ['number', 'null'], minimum: 0 }, price_usd: { type: ['number', 'null'], minimum: 0 },
+ },
+};
+
+const oneLine = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
+const creditOf = (img) => [oneLine(img.author, 120), oneLine(img.license, 80)].filter(Boolean).join(', ');
+const sourceOf = (provider) => (String(provider || '').toLowerCase().startsWith('openverse') ? 'openverse' : 'commons');
+const slug = (s) => oneLine(s, 80).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || 'picture';
+
+/** The bytes' real type, from their first bytes: a provider or a source is not asked what it sent. */
+function mimeOf(bytes) {
+ if (bytes && bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'image/png';
+ if (bytes && bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg';
+ if (bytes && bytes.length >= 6 && bytes.subarray(0, 3).toString('ascii') === 'GIF') return 'image/gif';
+ if (bytes && bytes.length >= 12 && bytes.subarray(0, 4).toString('ascii') === 'RIFF' && bytes.subarray(8, 12).toString('ascii') === 'WEBP') return 'image/webp';
+ return 'image/png';
+}
+
+/** A provider whose capacity is already paid for: never metered, so a picture costs nothing. */
+function freeProvider(provider) {
+ return Boolean(provider) && (provider.kind === 'stub' || provider.kind === 'whisper'
+ || Boolean(provider.metadata && (provider.metadata.local || provider.metadata.paid === false))
+ || ['subscription', 'free', 'byok'].includes(provider.billing_profile));
+}
+
+const perImagePrice = (row) => {
+ const n = Number(row && row.metadata && row.metadata.price_per_image_usd);
+ return Number.isFinite(n) && n >= 0 ? n : null;
+};
+
+const workflows = [{
+ key: 'image.picture', name: 'Find or make a picture', namespace: 'image',
+ description: 'One image task with two modes: find a licensed picture on Wikimedia Commons or Openverse (licence, author and source kept), or make one with the owner\'s configured image model. Every picture is stored in OpenVibe.Media and returned by reference.',
+ input_schema: {
+ type: 'object', additionalProperties: false, required: ['mode'],
+ properties: {
+ mode: { enum: MODES },
+ query: STR(200), providers: { type: 'array', maxItems: SOURCES.length, uniqueItems: true, items: { enum: SOURCES } },
+ count: INT(1, 12), prompt: STR(4000), alt: STR(300), n: INT(1, 4),
+ // The Media tenant the calling product keeps its pictures in (Blog/Wiki pass theirs so their own
+ // Media attach panel can see the object); absent, the AI tenant.
+ media_app: { enum: ['ai', 'blog', 'wiki', 'media', 'pics'] },
+ size: { type: 'string', pattern: '^[0-9]{3,4}x[0-9]{3,4}$' }, dry_run: { type: 'boolean' },
+ },
+ },
+ output_schema: {
+ type: 'object', additionalProperties: false,
+ required: ['mode', 'task', 'media', 'count', 'gaps'],
+ properties: {
+ mode: { enum: MODES }, task: STR(40), query: NULLABLE(200), prompt: NULLABLE(4000), dry_run: { type: 'boolean' },
+ estimate: { anyOf: [ESTIMATE, { type: 'null' }] },
+ media: { type: 'array', maxItems: 12, items: MEDIA },
+ count: INT(0, 12), gaps: { type: 'array', maxItems: 24, items: STR(300) }, note: NULLABLE(500),
+ },
+ },
+ // Find reads our own picture sources (no route); make resolves default.image itself, so the workflow can be
+ // created and find can run when no image model is configured yet.
+ steps: [{ kind: 'picture' }], default_route: null, cache_mode: 'none',
+ metadata: {
+ creation: {
+ id: 'picture', product: 'media', name: 'Find or make a picture', workflow: 'image.picture', task: 'image.picture',
+ gives: 'A search for a licensed picture, or a description of one to make.',
+ returns: 'Pictures in OpenVibe.Media, with their licence and author or the model that made them.',
+ access: { guest: false, signed_in: true, floor_18: false }, costs_coins: true,
+ start_url: 'https://openvibe.media/',
+ },
+ },
+}];
+
+function createPictures({ registry, pool, fetcher, config = {}, db = null, clock = { now: () => Date.now() }, mediaStore = null, research = null, routeFor = null, log = console } = {}) {
+ const pictureConfig = config.pictures || {};
+ const subjectOf = (ctx) => {
+ const v = ctx && ctx.onBehalfOf;
+ const id = typeof v === 'string' ? v : (v && v.id ? String(v.id) : null);
+ return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id || '')) ? String(id) : null;
+ };
+
+ /** The task's own choice (the owner's image model for make; the engines for find), or null. */
+ const planOf = (ctx) => (ctx && ctx.taskChoice) || null;
+
+ function enginesFor(plan, input) {
+ const asked = Array.isArray(input.providers) && input.providers.length ? input.providers : null;
+ const owner = plan && Array.isArray(plan.search_engines)
+ ? plan.search_engines.filter((e) => e && e.enabled !== false && SOURCES.includes(e.engine)).map((e) => e.engine)
+ : [];
+ return (asked || (owner.length ? owner : SOURCES)).filter((e, i, a) => SOURCES.includes(e) && a.indexOf(e) === i);
+ }
+
+ /**
+ * The model offers the owner's image choice names, cheapest first, with a per-image price. A paid
+ * offer without metadata.price_per_image_usd is refused rather than guessed at; a prepaid, free or
+ * local offer costs 0.
+ */
+ async function pricedOffers(picked) {
+ const [rows, providers] = await Promise.all([registry.listModels(), registry.listProviders()]);
+ const byKey = new Map(providers.map((p) => [p.key, p]));
+ const dynamic = String(picked.model || '').startsWith('openvibe/');
+ const out = [];
+ for (const row of rows) {
+ if (row.type !== 'image' || row.status !== 'active') continue;
+ const provider = byKey.get(row.provider_key);
+ if (!provider || provider.status !== 'active') continue;
+ const id = canonicalOf(row, provider);
+ if (!dynamic && id !== picked.model && row.model_key !== picked.model) continue;
+ const adult = Boolean(row.metadata && row.metadata.uncensored) || /uncensor|abliterat/i.test(`${id} ${(row.metadata && row.metadata.variant) || ''}`);
+ if (dynamic && adult) continue;
+ const price = perImagePrice(row);
+ out.push({ id, provider: provider.key, model: row.model_key, row, free: freeProvider(provider), price });
+ }
+ out.sort((a, b) => (a.price == null ? Infinity : a.price) - (b.price == null ? Infinity : b.price));
+ if (!out.length) throw new AiError(422, 'choice.unavailable', `no deployment of ${picked.model} is available`);
+ return out;
+ }
+
+ /** Pictures the subject has made today (UTC), in images: the owner's daily cap counts what was actually made. */
+ async function madeToday(subject) {
+ if (!db || !subject) return 0;
+ const day = new Date(clock.now()).toISOString().slice(0, 10);
+ const row = await db.prepare(
+ "SELECT COALESCE(SUM(tokens_out), 0) AS n FROM runs WHERE workflow_key = 'image.picture' AND status = 'succeeded' AND created_at >= ? AND (on_behalf_of::jsonb->>'id') = ?",
+ ).get(`${day}T00:00:00.000Z`, subject).catch(() => null);
+ return Number(row && row.n) || 0;
+ }
+
+ async function find(input, plan, ctx) {
+ const query = oneLine(input.query, 200);
+ if (!query) throw new AiError(422, 'input.invalid', 'find needs a query');
+ const limit = Math.max(1, Math.min(12, Number(input.count) || 8));
+ const engines = enginesFor(plan, input);
+ if (!engines.length) throw new AiError(422, 'choice.unavailable', 'no licensed picture source is enabled for this task');
+ if (!research) throw new AiError(503, 'source.unavailable', 'picture research is not available');
+ let gathered = null;
+ const gaps = [];
+ try {
+ gathered = await research().gather({ query, images: true, providers: engines, max_images: Math.max(limit * 2, limit), content_chars: 800 }, { signal: ctx && ctx.signal, allowPaid: false, allowFabric: false });
+ } catch (err) {
+ if (ctx && ctx.signal && ctx.signal.aborted) throw err;
+ throw new AiError(502, 'source.unavailable', `no picture source answered for "${query}": ${oneLine(err && err.message, 200)}`);
+ }
+ const found = (gathered && Array.isArray(gathered.images) ? gathered.images : [])
+ .filter((img) => img && img.free === true && img.license && oneLine(img.license, 200))
+ .filter((img) => SOURCES.includes(sourceOf(img.provider)));
+ for (const [name, p] of Object.entries((gathered && gathered.providers) || {})) {
+ if (p && p.ok === false) gaps.push(`${name}: ${oneLine(p.skipped || p.error, 160) || 'did not answer'}`);
+ }
+ const media = [];
+ const seen = new Set();
+ for (const img of found) {
+ if (media.length >= limit) break;
+ const source = sourceOf(img.provider);
+ const page = img.source_url || img.original || img.url;
+ if (!page || seen.has(page)) continue;
+ seen.add(page);
+ try {
+ const got = await fetcher.fetchPicture(img.thumbnail || img.url, { signal: ctx && ctx.signal });
+ const mime = got.mime && /^image\//.test(got.mime) ? got.mime : mimeOf(got.bytes);
+ const stored = await mediaStore.put({
+ bytes: got.bytes, mime, filename: `${slug(img.title || query)}.${mime === 'image/jpeg' ? 'jpg' : mime.split('/')[1] || 'png'}`,
+ owner: subjectOf(ctx), app: input.media_app || null,
+ metadata: { title: oneLine(img.title, 200) || query, source_url: page, license: oneLine(img.license, 200), license_url: img.license_url || null, author: oneLine(img.author, 200) || null, provider: img.provider || source, origin: 'image.picture.find', free: true },
+ });
+ media.push({
+ media_id: stored.media_id, url: stored.url || mediaStore.publicUrl(stored.media_id),
+ width: Number.isInteger(stored.width) ? stored.width : (Number.isInteger(img.width) ? img.width : null),
+ height: Number.isInteger(stored.height) ? stored.height : (Number.isInteger(img.height) ? img.height : null),
+ title: oneLine(img.title, 300) || null, alt: oneLine(img.description || img.title, 400) || null,
+ caption: oneLine(img.description || img.title, 500) || null, credit: creditOf(img) || null,
+ author: oneLine(img.author, 200) || null, license: oneLine(img.license, 200),
+ license_url: img.license_url || null, source_url: page, provider: img.provider || source,
+ ai: false, model: null,
+ });
+ } catch (err) {
+ if (ctx && ctx.signal && ctx.signal.aborted) throw err;
+ gaps.push(`${img.title || source}: ${oneLine(err && err.message, 200) || 'not fetched'}`);
+ }
+ }
+ const exec = { provider: 'research', model: engines.join('+'), usage: { input: 0, output: 0, cached: 0 }, cost: 0, synthetic: false, fallbackUsed: false, explain: null };
+ return {
+ output: {
+ mode: 'find', task: 'image.picture', query, prompt: null, dry_run: false, estimate: null,
+ media, count: media.length, gaps: gaps.slice(0, 24),
+ note: media.length ? null : 'No licensed picture was found for this search.',
+ },
+ exec,
+ };
+ }
+
+ async function make(input, plan, ctx, run) {
+ const prompt = String(input.prompt || '').trim();
+ if (!prompt) throw new AiError(422, 'input.invalid', 'make needs a prompt');
+ const n = Math.max(1, Math.min(4, Number(input.n) || 1));
+ const subject = subjectOf(ctx);
+ if (!subject) throw new AiError(401, 'image.sign_in', 'Sign in to make an image');
+ // The owner's switch: until an owner picks an image model for this task, make is off. The default
+ // resolution never falls back to a signed-in model for image.picture's make mode.
+ const picked = plan && plan.image_model ? plan.image_model : null;
+ if (!picked) throw new AiError(503, 'image.make_unconfigured', 'The owner has not picked an image model for the picture task; set image_model in the image.picture task defaults.');
+ const offers = await pricedOffers(picked);
+ const chosen = offers[0];
+ const perImage = chosen.price == null ? (chosen.free ? 0 : null) : chosen.price;
+ if (perImage == null) throw new AiError(402, 'image.price_required', `the image model ${chosen.id} has no per-image price; set metadata.price_per_image_usd on the model before a paid picture can be made`);
+ const estimate = { model: chosen.id, source: 'openvibe', images: n, per_image_usd: perImage, price_usd: perImage * n };
+ if (plan.max_cost_usd != null && estimate.price_usd > plan.max_cost_usd) throw new AiError(402, 'choice.cost_ceiling', `the picture would cost about $${estimate.price_usd} and this task's per-run ceiling is $${plan.max_cost_usd}`);
+ const cap = plan.daily_cap == null ? pictureConfig.defaultDailyCap : Number(plan.daily_cap);
+ if (Number.isFinite(cap) && cap >= 0) {
+ const made = await madeToday(subject);
+ if (made + n > cap) throw new AiError(429, 'image.daily_cap', `this task's daily cap is ${cap} picture${cap === 1 ? '' : 's'} and ${made} ${made === 1 ? 'has' : 'have'} been made today`, { retry_after_seconds: 3600 });
+ }
+ if (input.dry_run) {
+ return {
+ output: {
+ mode: 'make', task: 'image.picture', query: null, prompt, dry_run: true, estimate, media: [], count: 0, gaps: [],
+ note: `Dry run: ${n} image${n === 1 ? '' : 's'} with ${chosen.id} at $${perImage} each, about $${estimate.price_usd}.`,
+ },
+ exec: { provider: null, model: chosen.model, usage: { input: 0, output: 0, cached: 0 }, cost: 0, synthetic: false, fallbackUsed: false, explain: null },
+ };
+ }
+ const route = await ((routeFor || (async () => { throw new AiError(503, 'route.unavailable', 'the image route is not available'); }))('default.image'));
+ const req = { prompt, size: input.size || ((config.image && config.image.size) || null), n, timeoutMs: route.timeout_ms || 180000 };
+ const exec = await pool.execute(route, 'image', req, {
+ ...ctx, taskChoice: null, exact: { id: chosen.id, offers: [{ provider: chosen.provider, model: chosen.model }] }, imagePriceUsd: perImage,
+ routeKey: route.key, routeVersion: route.version, promptHash: sha256({ p: prompt, n }),
+ });
+ const images = ((exec.result && exec.result.images) || []).filter((i) => i && (i.b64 || i.url));
+ if (!images.length) throw new AiError(502, 'output.invalid', 'no image was returned');
+ exec.explain = {
+ ...(exec.explain || {}),
+ task_choice: {
+ task: plan.task && plan.task.name ? plan.task.name : 'image.picture',
+ requested: plan.choice, answered: { model: exec.model || chosen.model, source: 'openvibe' },
+ why: plan.why || 'owner', fallback_index: 0,
+ },
+ };
+ const model = exec.model || chosen.model;
+ const label = `Made with AI (${model})`;
+ const media = [];
+ const gaps = [];
+ for (const image of images) {
+ try {
+ let bytes; let mime;
+ if (image.b64) {
+ bytes = Buffer.from(String(image.b64), 'base64');
+ mime = mimeOf(bytes);
+ } else {
+ const got = await fetcher.fetchPicture(image.url, { signal: ctx && ctx.signal });
+ bytes = got.bytes;
+ mime = got.mime && /^image\//.test(got.mime) ? got.mime : mimeOf(bytes);
+ }
+ const alt = oneLine(input.alt, 300) || oneLine(prompt, 300);
+ const stored = await mediaStore.put({
+ bytes, mime, filename: `${slug(input.alt || prompt)}.${mime === 'image/jpeg' ? 'jpg' : mime.split('/')[1] || 'png'}`, owner: subject, app: input.media_app || null,
+ metadata: { title: alt, alt, caption: label, ai: true, model, generated_with: model, prompt: prompt.slice(0, 1000), origin: 'image.picture.make' },
+ });
+ media.push({
+ media_id: stored.media_id, url: stored.url || mediaStore.publicUrl(stored.media_id),
+ width: Number.isInteger(stored.width) ? stored.width : null, height: Number.isInteger(stored.height) ? stored.height : null,
+ title: alt, alt, caption: alt ? `${alt} (${label})` : label, credit: label,
+ author: null, license: null, license_url: null, source_url: null, provider: exec.provider || 'image', ai: true, model,
+ });
+ } catch (err) {
+ if (ctx && ctx.signal && ctx.signal.aborted) throw err;
+ gaps.push(`the picture could not be stored: ${oneLine(err && err.message, 240)}`);
+ }
+ }
+ if (!media.length) throw new AiError(502, 'media.upload_refused', `the image was made but no picture reached Media${gaps.length ? `: ${gaps[0]}` : ''}`);
+ return {
+ output: {
+ mode: 'make', task: 'image.picture', query: null, prompt, dry_run: false, estimate, media,
+ count: media.length, gaps: gaps.slice(0, 24), note: null,
+ },
+ exec,
+ };
+ }
+
+ const step = async (s, input, wf, run, ctx) => {
+ const plan = planOf(ctx);
+ if (input.mode === 'find') {
+ // Guest find is the owner's switch: a run with no person may read Commons and Openverse only when
+ // the owner allowed it; the pictures then belong to the AI tenant.
+ if (!subjectOf(ctx) && !(plan && plan.guest_find === true)) throw new AiError(401, 'image.guest_forbidden', 'Guests may not find pictures for this task; the owner can allow it in the image.picture task defaults.');
+ return await find(input, plan, ctx);
+ }
+ if (input.mode === 'make') return await make(input, plan, ctx, run);
+ throw new AiError(422, 'input.invalid', 'mode must be find or make');
+ };
+
+ return { step, modes: MODES, sources: SOURCES };
+}
+
+module.exports = { templates: [], workflows, createPictures, mimeOf, freeProvider, perImagePrice };
@@ -15,7 +15,7 @@ const PROVIDERS = ['wikipedia', 'fandom', 'wikidata', 'deepseek', 'commons', 'op
@@ -15,7 +15,7 @@ const PROVIDERS = ['wikipedia', 'fandom', 'wikidata', 'deepseek', 'commons', 'op
const IMAGE = {
const IMAGE = {
type: 'object', additionalProperties: false, required: ['url', 'free', 'provider'],
type: 'object', additionalProperties: false, required: ['url', 'free', 'provider'],
properties: {
properties: {
- url: STR(2048), original: NULLABLE(2048), width: { type: ['integer', 'null'] }, height: { type: ['integer', 'null'] }, mime: NULLABLE(40),
+ url: STR(2048), original: NULLABLE(2048), thumbnail: NULLABLE(2048), width: { type: ['integer', 'null'] }, height: { type: ['integer', 'null'] }, mime: NULLABLE(40),
title: NULLABLE(300), description: NULLABLE(400), author: NULLABLE(200), license: NULLABLE(200), license_url: NULLABLE(2048),
title: NULLABLE(300), description: NULLABLE(400), author: NULLABLE(200), license: NULLABLE(200), license_url: NULLABLE(2048),
free: { type: 'boolean' }, source_url: NULLABLE(2048), provider: STR(80),
free: { type: 'boolean' }, source_url: NULLABLE(2048), provider: STR(80),
},
},
@@ -24,6 +24,7 @@ const chat = require('./chat');
@@ -24,6 +24,7 @@ const chat = require('./chat');
const research = require('./research');
const research = require('./research');
const meme = require('./meme');
const meme = require('./meme');
const changelog = require('./changelog');
const changelog = require('./changelog');
+const pictures = require('./pictures');
const poolSeed = require('../providers/pool-seed');
const poolSeed = require('../providers/pool-seed');
const { resolveSecret } = require('../util');
const { resolveSecret } = require('../util');
@@ -260,11 +261,11 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
@@ -260,11 +261,11 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
await registry.seedVersioned('route', 'chat.member', { primary: { provider: 'shared', model: null }, fallbacks: [], options: {}, max_output_tokens: 600, response_format: 'text', timeout_ms: null, alias_of: 'default.chat' });
await registry.seedVersioned('route', 'chat.member', { primary: { provider: 'shared', model: null }, fallbacks: [], options: {}, max_output_tokens: 600, response_format: 'text', timeout_ms: null, alias_of: 'default.chat' });
// Templates, then workflows (workflows reference templates)
// Templates, then workflows (workflows reference templates)
- for (const t of [...live.templates, ...core.templates, ...products.templates, ...devblog.templates, ...media.templates, ...chat.templates, ...space.templates, ...research.templates, ...meme.templates, ...changelog.templates]) {
+ for (const t of [...live.templates, ...core.templates, ...products.templates, ...devblog.templates, ...media.templates, ...chat.templates, ...space.templates, ...research.templates, ...meme.templates, ...changelog.templates, ...pictures.templates]) {
const { key, ...def } = t;
const { key, ...def } = t;
await registry.seedVersioned('template', key, { description: null, default_route: null, owner: 'ai', visibility: 'internal', metadata: {}, ...def });
await registry.seedVersioned('template', key, { description: null, default_route: null, owner: 'ai', visibility: 'internal', metadata: {}, ...def });
}
}
- for (const w of [...live.workflows, ...core.workflows, ...products.workflows, ...devblog.workflows, ...media.workflows, ...chat.workflows, ...space.workflows, ...research.workflows, ...meme.workflows, ...changelog.workflows]) {
+ for (const w of [...live.workflows, ...core.workflows, ...products.workflows, ...devblog.workflows, ...media.workflows, ...chat.workflows, ...space.workflows, ...research.workflows, ...meme.workflows, ...changelog.workflows, ...pictures.workflows]) {
const { key, ...def } = w;
const { key, ...def } = w;
await registry.seedVersioned('workflow', key, { description: null, default_route: null, cache_ttl_sec: null, metadata: {}, ...def });
await registry.seedVersioned('workflow', key, { description: null, default_route: null, cache_ttl_sec: null, metadata: {}, ...def });
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,207 @@
@@ -0,0 +1,207 @@
+'use strict';
+// The shared image task (image.picture, rich-content plan §4/§5): find licensed Commons/Openverse pictures
+// and make one with the owner's model. Pictures go to Media by reference, never inline; a paid offer must
+// carry a per-image price; make is off until the owner picks a model; the owner's daily cap holds.
+const assert = require('assert');
+const http = require('http');
+const { suite, boot, request, token, ALL, seamServer } = require('./helpers');
+const { createPictures, mimeOf, freeProvider } = require('../server/workflows/pictures');
+
+const t = suite('image picture');
+const SUBJECT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const MEDIA = 'med_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64');
+
+function store() {
+ const puts = [];
+ return {
+ puts,
+ configured: () => true,
+ publicUrl: (id) => `https://media.test/o/${id}`,
+ put: async (o) => { puts.push(o); return { media_id: MEDIA, url: `https://media.test/o/${MEDIA}`, width: 800, height: 600 }; },
+ };
+}
+const plan = (over = {}) => ({
+ task: { name: 'image.picture', kind: 'image' }, choice: { model: 'openvibe/auto', source: 'openvibe' }, why: 'owner',
+ fallbacks: [], max_cost_usd: null, search_engines: [{ engine: 'commons', enabled: true }, { engine: 'openverse', enabled: true }],
+ image_model: null, daily_cap: null, guest_find: false, ...over,
+});
+const ctxOf = (over = {}) => ({ taskChoice: plan(), onBehalfOf: { type: 'user', id: SUBJECT }, signal: new AbortController().signal, ...over });
+
+const image = (over = {}) => ({ url: 'https://upload.wikimedia.org/a.jpg', thumbnail: 'https://upload.wikimedia.org/thumb/a.jpg', original: 'https://upload.wikimedia.org/a.jpg', source_url: 'https://commons.wikimedia.org/wiki/File:A', title: 'A machine', description: 'A machine in a workshop', author: 'Ada Example', license: 'CC BY-SA 4.0', license_url: 'https://creativecommons.org/licenses/by-sa/4.0/', free: true, provider: 'commons', width: 800, height: 600, ...over });
+
+function pictures(over = {}) {
+ const calls = { fetches: [], executes: [] };
+ const p = createPictures({
+ registry: {
+ listModels: async () => [imageModel(over.price)],
+ listProviders: async () => [{ key: 'fixture', kind: 'openai', status: 'active', billing_profile: 'metered', metadata: {} }],
+ },
+ pool: { execute: async (route, op, req, ctx) => { calls.executes.push({ route, op, req, ctx }); return {
+ result: { images: [{ b64: PNG.toString('base64'), url: null }] }, provider: 'fixture', model: 'image',
+ usage: { input: 0, output: 1, cached: 0 }, cost: 0, synthetic: false, fallbackUsed: false, explain: {},
+ }; } },
+ fetcher: { fetchPicture: async (url) => { calls.fetches.push(url); return { bytes: PNG, mime: 'image/png', url }; } },
+ config: { image: { size: '1024x1024' }, pictures: { defaultDailyCap: 24 } },
+ db: { prepare: () => ({ get: async () => ({ n: over.made || 0 }) }) },
+ mediaStore: store(),
+ routeFor: async () => ({ key: 'default.image', version: 1, timeout_ms: 120000 }),
+ research: () => ({ gather: async () => ({ providers: {}, images: over.images || [image()] }) }),
+ ...over.deps,
+ });
+ return { p, calls };
+}
+const imageModel = (price = 0.04) => ({ provider_key: 'fixture', model_key: 'image', type: 'image', status: 'active', metadata: price == null ? {} : { price_per_image_usd: price } });
+
+t.test('find keeps licence, author and source, and uploads only free pictures to Media', async () => {
+ const { p, calls } = pictures({ images: [
+ image(),
+ image({ url: 'https://upload.wikimedia.org/b.jpg', thumbnail: null, source_url: 'https://commons.wikimedia.org/wiki/File:B', license: null }),
+ image({ provider: 'commons', free: false, source_url: 'https://commons.wikimedia.org/wiki/File:C' }),
+ image({ provider: 'openverse', url: 'https://api.openverse.org/v1/images/one/thumb/', thumbnail: 'https://api.openverse.org/v1/images/one/thumb/', source_url: 'https://flickr.com/p/1', author: 'Bo', license: 'CC BY 4.0' }),
+ ] });
+ const out = await p.step({}, { mode: 'find', query: 'a machine', count: 8 }, {}, {}, ctxOf());
+ assert.strictEqual(out.output.mode, 'find');
+ assert.strictEqual(out.output.task, 'image.picture');
+ assert.strictEqual(out.output.media.length, 2);
+ assert.deepStrictEqual(out.output.media.map((m) => m.provider), ['commons', 'openverse']);
+ assert.deepStrictEqual({ license: out.output.media[0].license, author: out.output.media[0].author, credit: out.output.media[0].credit, ai: out.output.media[0].ai },
+ { license: 'CC BY-SA 4.0', author: 'Ada Example', credit: 'Ada Example, CC BY-SA 4.0', ai: false });
+ assert.strictEqual(out.output.media[0].source_url, 'https://commons.wikimedia.org/wiki/File:A');
+ assert.strictEqual(out.output.media[0].url, `https://media.test/o/${MEDIA}`);
+ assert.deepStrictEqual(calls.fetches, ['https://upload.wikimedia.org/thumb/a.jpg', 'https://api.openverse.org/v1/images/one/thumb/']);
+ assert.ok(!JSON.stringify(out.output).includes(PNG.toString('base64')), 'never inline bytes');
+});
+
+t.test('find without a person is refused unless the owner allows guests', async () => {
+ const { p } = pictures();
+ await assert.rejects(() => p.step({}, { mode: 'find', query: 'x' }, {}, {}, ctxOf({ onBehalfOf: null })), (e) => e.status === 401 && e.code === 'image.guest_forbidden');
+ const allowed = await p.step({}, { mode: 'find', query: 'x' }, {}, {}, ctxOf({ onBehalfOf: null, taskChoice: plan({ guest_find: true }) }));
+ assert.strictEqual(allowed.output.count, 1);
+});
+
+t.test('make is off until the owner picks an image model, then a dry run quotes without calling a provider', async () => {
+ const off = pictures();
+ await assert.rejects(() => off.p.step({}, { mode: 'make', prompt: 'a goose' }, {}, {}, ctxOf()), (e) => e.status === 503 && e.code === 'image.make_unconfigured');
+ assert.strictEqual(off.calls.executes.length, 0);
+
+ const on = pictures();
+ const out = await on.p.step({}, { mode: 'make', prompt: 'a goose', n: 2, dry_run: true }, {}, {}, ctxOf({ taskChoice: plan({ image_model: { model: 'fixture/image', source: 'openvibe' }, daily_cap: 10 }) }));
+ assert.strictEqual(out.output.dry_run, true);
+ assert.deepStrictEqual(out.output.estimate, { model: 'fixture/image', source: 'openvibe', images: 2, per_image_usd: 0.04, price_usd: 0.08 });
+ assert.deepStrictEqual(out.output.media, []);
+ assert.strictEqual(on.calls.executes.length, 0, 'a dry run never calls the provider');
+});
+
+t.test('make uploads each picture to Media and labels it made with AI', async () => {
+ const { p, calls } = pictures();
+ const out = await p.step({}, { mode: 'make', prompt: 'a goose', alt: 'A goose', n: 1 }, {}, {}, ctxOf({ taskChoice: plan({ image_model: { model: 'fixture/image', source: 'openvibe' }, daily_cap: 10 }) }));
+ assert.strictEqual(out.output.media.length, 1);
+ assert.strictEqual(out.output.media[0].ai, true);
+ assert.strictEqual(out.output.media[0].model, 'image');
+ assert.strictEqual(out.output.media[0].credit, 'Made with AI (image)');
+ assert.match(out.output.media[0].caption, /Made with AI \(image\)/);
+ assert.strictEqual(out.output.media[0].media_id, MEDIA);
+ assert.strictEqual(calls.executes.length, 1);
+ assert.strictEqual(calls.executes[0].ctx.imagePriceUsd, 0.04, 'the per-image price is reserved for this call');
+ assert.strictEqual(calls.executes[0].ctx.taskChoice, null, 'the owner model is pinned, not re-resolved');
+ assert.strictEqual(out.exec.explain.task_choice.answered.model, 'image');
+});
+
+t.test('a paid offer without a per-image price is refused, and the daily cap holds', async () => {
+ const unpriced = pictures({ price: null });
+ await assert.rejects(() => unpriced.p.step({}, { mode: 'make', prompt: 'x' }, {}, {}, ctxOf({ taskChoice: plan({ image_model: { model: 'fixture/image', source: 'openvibe' } }) })), (e) => e.status === 402 && e.code === 'image.price_required');
+ assert.strictEqual(unpriced.calls.executes.length, 0);
+
+ const capped = pictures({ made: 4 });
+ await assert.rejects(() => capped.p.step({}, { mode: 'make', prompt: 'x', n: 2 }, {}, {}, ctxOf({ taskChoice: plan({ image_model: { model: 'fixture/image', source: 'openvibe' }, daily_cap: 5 }) })), (e) => e.status === 429 && e.code === 'image.daily_cap');
+ assert.strictEqual(capped.calls.executes.length, 0);
+});
+
+t.test('the task is in the catalogue, and the owner defaults take its model, cap and guest rule', async () => {
+ const h = await boot({ env: { AI_IMAGE_MODEL: 'gpt-image-1' } });
+ try {
+ const admin = token('person', [], { actorType: 'user', sub: SUBJECT, extra: { subject_id: SUBJECT, role: 'admin' } });
+ const list = await request(h.base, 'GET', '/api/v1/tasks');
+ assert.strictEqual(list.status, 200, list.text);
+ const task = list.body.tasks.find((x) => x.name === 'image.picture');
+ assert.ok(task, 'image.picture is seeded');
+ assert.strictEqual(task.workflow, 'image.picture');
+ const defaults = await request(h.base, 'GET', '/api/v1/admin/task-defaults', { tok: admin });
+ assert.strictEqual(defaults.status, 200, defaults.text);
+ assert.strictEqual(defaults.body.defaults['image.picture'].image_model, null, 'make starts off');
+ assert.strictEqual(defaults.body.defaults['image.picture'].guest_find, false);
+ assert.deepStrictEqual(defaults.body.defaults['image.picture'].search_engines.map((e) => e.engine), ['commons', 'openverse']);
+ const value = { signed_out: { model: 'openvibe/auto', source: 'openvibe' }, signed_in: { model: 'openvibe/auto', source: 'openvibe' }, fallbacks: [], image_model: { model: 'gpt-image-1', source: 'openvibe' }, search_engines: [{ engine: 'openverse', enabled: true }, { engine: 'commons', enabled: false }], max_cost_usd: null, daily_cap: 6, guest_find: true };
+ const put = await request(h.base, 'PUT', '/api/v1/admin/task-defaults', { tok: admin, body: { defaults: [{ task: 'image.picture', value }] } });
+ assert.strictEqual(put.status, 200, put.text);
+ assert.deepStrictEqual(put.body.defaults['image.picture'], value);
+ } finally { await h.stop(); }
+});
+
+t.test('a make dry run is a run of the task: the owner model, the daily cap query and the estimate', async () => {
+ const h = await boot({ env: { AI_IMAGE_MODEL: 'gpt-image-1' } });
+ try {
+ await h.registry.upsertModel({ provider_key: 'shared', model_key: 'gpt-image-1', type: 'image', metadata: { price_per_image_usd: 0.04 } });
+ const value = { signed_out: { model: 'openvibe/auto', source: 'openvibe' }, signed_in: { model: 'openvibe/auto', source: 'openvibe' }, fallbacks: [], image_model: { model: 'gpt-image-1', source: 'openvibe' }, search_engines: [], max_cost_usd: null, daily_cap: 4, guest_find: false };
+ await h.db.prepare("UPDATE ai_task_defaults SET value = ? WHERE task = 'image.picture'").run(JSON.stringify(value));
+ const tok = token('blog', ALL);
+ const r = await request(h.base, 'POST', '/api/v1/runs?wait=10000', { tok, body: { workflow: 'image.picture', on_behalf_of: { type: 'user', id: SUBJECT }, input: { mode: 'make', prompt: 'A goose', n: 2, dry_run: true, media_app: 'blog' } } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.body.run.status, 'succeeded', JSON.stringify(r.body.run.error));
+ assert.deepStrictEqual(r.body.run.output.estimate, { model: 'openai/gpt-image-1', source: 'openvibe', images: 2, per_image_usd: 0.04, price_usd: 0.08 });
+ assert.deepStrictEqual(r.body.run.output.media, []);
+ } finally { await h.stop(); }
+});
+
+t.test('a priced image run settles at the per-image price', async () => {
+ const up = await seamServer(() => ({ data: [{ b64_json: PNG.toString('base64') }, { b64_json: PNG.toString('base64') }] }));
+ const h = await boot({ env: { AI_IMAGE_MODEL: 'gpt-image-1', AI_BASE_URL: `${up.url}/v1`, AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0' } });
+ try {
+ await h.registry.upsertModel({ provider_key: 'shared', model_key: 'gpt-image-1', type: 'image', metadata: { price_per_image_usd: 0.04 } });
+ const route = await h.registry.resolveRoute('default.image');
+ const exec = await h.pool.execute(route, 'image', { prompt: 'x', n: 2, timeoutMs: 5000 }, { workflowKey: 'image.picture', imagePriceUsd: 0.04, routeKey: route.key, routeVersion: route.version });
+ assert.strictEqual(exec.usage.output, 2);
+ assert.ok(Math.abs(exec.cost - 0.08) < 1e-9, `cost 0.08, got ${exec.cost}`);
+ const plain = await h.pool.execute(route, 'image', { prompt: 'x', n: 1, timeoutMs: 5000 }, { workflowKey: 'ai.image', routeKey: route.key, routeVersion: route.version });
+ assert.strictEqual(plain.cost, 0, 'an unpriced image run keeps the old zero-cost behaviour');
+ } finally { await h.stop(); await up.close(); }
+});
+
+t.test('the Media client uploads into the calling product\'s tenant for the person', async () => {
+ const seen = [];
+ const srv = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => { raw += c; });
+ req.on('end', () => {
+ seen.push({ method: req.method, url: req.url, subject: req.headers['x-ov-subject'], body: raw });
+ const json = (st, o) => { res.writeHead(st, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
+ if (req.method === 'POST' && req.url === '/api/v2/wiki/objects') return json(201, { id: MEDIA, upload: { url: 'https://openvibe.media/o/x', token: 't', complete_url: 'https://openvibe.media/o/x/complete' } });
+ if (req.method === 'PUT' && req.url === '/o/x') return json(200, { ok: true });
+ if (req.method === 'POST' && req.url === '/o/x/complete') return json(200, { object: { id: MEDIA, lifecycle_status: 'ready', width: 40, height: 30 } });
+ json(404, {});
+ });
+ });
+ await new Promise((r) => srv.listen(0, '127.0.0.1', r));
+ try {
+ const { createMediaStore } = require('../server/media-store');
+ const media = { publicUrl: 'https://openvibe.media', internalUrl: `http://127.0.0.1:${srv.address().port}`, app: 'ai' };
+ const store = createMediaStore({ config: { media, networkInternalUrl: 'http://network.test' }, env: { OV_OAUTH_CLIENT_SECRET: 'not-a-secret' }, fetchImpl: fetch, tokens: { authHeaders: async () => ({ Authorization: 'Bearer test' }) } });
+ const r = await store.put({ bytes: PNG, mime: 'image/png', owner: SUBJECT, app: 'wiki', metadata: { license: 'CC BY-SA 4.0' } });
+ assert.strictEqual(r.media_id, MEDIA);
+ assert.strictEqual(r.width, 40);
+ assert.ok(seen.every((x) => x.subject === SUBJECT), 'the picture belongs to the person');
+ assert.strictEqual(seen[0].url, '/api/v2/wiki/objects', 'the calling product\'s tenant');
+ assert.match(seen[0].body, /"license":"CC BY-SA 4.0"/);
+ } finally { await new Promise((r) => srv.close(r)); }
+});
+
+t.test('bytes and providers are judged, not trusted', () => {
+ assert.strictEqual(mimeOf(PNG), 'image/png');
+ assert.strictEqual(mimeOf(Buffer.from([0xff, 0xd8, 0xff, 0])), 'image/jpeg');
+ assert.strictEqual(freeProvider({ kind: 'stub' }), true);
+ assert.strictEqual(freeProvider({ kind: 'openai', billing_profile: 'metered' }), false);
+ assert.strictEqual(freeProvider({ kind: 'openai', billing_profile: 'subscription' }), true);
+});
+
+t.run();
@@ -42,6 +42,7 @@ const EXAMPLES = {
@@ -42,6 +42,7 @@ const EXAMPLES = {
'ai.enrich': { record: { name: 'Widget' }, instructions: 'normalise the name' },
'ai.enrich': { record: { name: 'Widget' }, instructions: 'normalise the name' },
'ai.embed': { input: ['one', 'two'] },
'ai.embed': { input: ['one', 'two'] },
'ai.image': { prompt: 'A goose eating croutons, watercolour.', size: '1024x1024', n: 1 },
'ai.image': { prompt: 'A goose eating croutons, watercolour.', size: '1024x1024', n: 1 },
+ 'image.picture': { mode: 'find', query: 'SQLite', media_app: 'ai' },
'wiki.generate_space': { topic: 'SQLite', max_pages: 6, sources: SRC(2) },
'wiki.generate_space': { topic: 'SQLite', max_pages: 6, sources: SRC(2) },
'content.wiki': { title: 'SQLite', topic: 'SQLite', space: 'SQLite', query: 'SQLite', instruction: 'Write the article from its sources.', outline: ['History'], pages: ['SQLite'], sources: SRC(2) },
'content.wiki': { title: 'SQLite', topic: 'SQLite', space: 'SQLite', query: 'SQLite', instruction: 'Write the article from its sources.', outline: ['History'], pages: ['SQLite'], sources: SRC(2) },
'wiki.generate_page': { title: 'WAL mode', topic: 'SQLite', pages: ['SQLite', 'WAL mode', 'Journal'], images: [{ title: 'WAL diagram', description: 'How WAL writes' }], sources: SRC(3) },
'wiki.generate_page': { title: 'WAL mode', topic: 'SQLite', pages: ['SQLite', 'WAL mode', 'Journal'], images: [{ title: 'WAL diagram', description: 'How WAL writes' }], sources: SRC(3) },
@@ -144,6 +145,8 @@ t.test('every workflow has compiling, versioned input and output schemas and an
@@ -144,6 +145,8 @@ t.test('every workflow has compiling, versioned input and output schemas and an
t.test('every workflow runs end to end on the stub and returns schema-valid output', async () => {
t.test('every workflow runs end to end on the stub and returns schema-valid output', async () => {
for (const w of await h.registry.listWorkflows()) {
for (const w of await h.registry.listWorkflows()) {
if (w.key === 'media.analyze' && !HAS_FFMPEG) { console.log('workflow media.analyze end to end: skipped (no ffprobe)'); continue; }
if (w.key === 'media.analyze' && !HAS_FFMPEG) { console.log('workflow media.analyze end to end: skipped (no ffprobe)'); continue; }
+ // image.picture reads Commons/Openverse and writes Media: its own test brings a fake researcher and store.
+ if (w.key === 'image.picture') { console.log('workflow image.picture end to end: covered by test/image-picture.test.js (no live fetch)'); continue; }
const r = await request(h.base, 'POST', '/api/v1/runs?wait=10000', { tok, body: { workflow: w.key, input: EXAMPLES[w.key] } });
const r = await request(h.base, 'POST', '/api/v1/runs?wait=10000', { tok, body: { workflow: w.key, input: EXAMPLES[w.key] } });
assert.strictEqual(r.status, 201, `${w.key}: ${r.status} ${r.text.slice(0, 300)}`);
assert.strictEqual(r.status, 201, `${w.key}: ${r.status} ${r.text.slice(0, 300)}`);
const run = r.body.run;
const run = r.body.run;