openvibers/openvibe.ai Commits

Picture orientation: the detector, its owner budget and receipt (#494)

OP OpenVibers committed 1 days ago · 55221a3bc9 · 10 files, +399 −3

* Add the picture-orientation detector: a workflow, a budgeted endpoint and its receipt

* The orientation workflow has its example and its background class

* Document the orientation endpoint

* The owner's Exchange session can try the detector

Changes

ddiff --git a/server/api/orientation.js b/server/api/orientation.js
nnew file mode 100644
iindex 0000000..505f353
---- /dev/null
++++ b/server/api/orientation.js
@@@ -0,0 +1,194 @@
++'use strict';
++/**
++ * The picture-orientation detector (pictures, 2026-11-11 part 3): one small function that answers which way is up.
++ *
++ * POST /api/v1/vision/orientation a 256 px copy (data URL) or a Media reference → { rotation, confidence, receipt }
++ * GET /api/v1/admin/orientation the owner console's reading: the setting, today's count and spend, recent tries
++ *
++ * The owner turns it on for the whole network on Exchange (the `pics.orientation` task defaults): method off|vision,
++ * the model (OpenVibe's router by default, the cheapest vision model that can see), the confidence a picture must
++ * reach, a daily picture cap and a daily budget. A run is an ordinary AI run of the `pics.orientation` workflow, so
++ * it carries the same receipt, quota, audit and usage as every other run; the endpoint refuses before spending when
++ * today's count or cost would pass the owner's bounds.
++ */
++const express = require('express');
++const { verifyUserToken } = require('openvibe-sdk/auth');
++const { AiError, sendError } = require('../util');
++const { CAPS, namespaceAllowed, bearer } = require('../auth');
++const { receiptId } = require('../exchange/receipts');
++
++const PERSON_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const PRINCIPAL = /^(svc|app|mod|node):/;
++
++const DAY = 24 * 60 * 60 * 1000;
++const MAX_IMAGE = 2 * 1024 * 1024;
++
++const cleanNumber = (v) => {
++ const n = Number(v);
++ return Number.isFinite(n) ? n : null;
++};
++const dayStart = (ms) => `${new Date(ms).toISOString().slice(0, 10)}T00:00:00.000Z`;
++
++/** The setting as the console reads it: every field the detector obeys, never a secret. */
++function settingOf(d) {
++ return {
++ method: d && d.method ? d.method : 'off',
++ confidence: d && d.confidence != null ? Number(d.confidence) : 0.8,
++ daily_cap: d && d.daily_cap != null ? Number(d.daily_cap) : null,
++ budget_usd_per_day: d && d.budget_usd_per_day != null ? Number(d.budget_usd_per_day) : null,
++ model: d && d.signed_in ? d.signed_in : null,
++ max_cost_usd: d && d.max_cost_usd != null ? Number(d.max_cost_usd) : null,
++ };
++}
++
++function createOrientation({ choices, runs, registry = null, auth, db, config = {}, clock = { now: () => Date.now() }, log = console }) {
++ if (!auth) throw new TypeError('createOrientation: auth is required');
++ const r = express.Router();
++ // A service (Media) runs with its own token; the Exchange owner's Try it forwards the person's Network session,
++ // which AI's own person routes already accept from Exchange for audience openvibe.network. A person here must be
++ // owner/admin: the detector's budget is the network's, so a stranger does not spend it.
++ const serviceGuard = auth.requireCap(CAPS.runCreate);
++ async function personGuard(req, res, next) {
++ const token = bearer(req);
++ let claims;
++ try {
++ claims = await verifyUserToken(token, {
++ ...(config.networkPublicKey ? { publicKey: config.networkPublicKey } : { jwks: config.networkJwksUrl }),
++ issuer: config.issuer, audience: ['openvibe.network', 'openvibe.ai'], now: clock.now(), log,
++ });
++ } catch (err) {
++ return sendError(res, new AiError(401, 'token.invalid', 'a person or service token is required'), req.ov, log);
++ }
++ if (!claims || PRINCIPAL.test(String(claims.sub || '')) || ['service', 'app', 'mod', 'node'].includes(claims.actor_type)) {
++ return sendError(res, new AiError(403, 'capability.denied', 'a person token is required on this route'), req.ov, log);
++ }
++ if (!['owner', 'admin'].includes(String(claims.role || ''))) {
++ return sendError(res, new AiError(403, 'role.admin_required', 'the orientation detector is the owner\'s'), req.ov, log);
++ }
++ const subject = PERSON_SUBJECT.test(String(claims.subject_id || '')) ? claims.subject_id
++ : PERSON_SUBJECT.test(String(claims.sub || '')) ? claims.sub : null;
++ if (!subject) return sendError(res, new AiError(401, 'token.invalid', 'the person token has no user subject'), req.ov, log);
++ req.principal = { kind: 'user', sub: claims.sub, subject: { type: 'user', id: subject }, claims };
++ return next();
++ }
++ function guard(req, res, next) {
++ const token = bearer(req);
++ if (!token) return serviceGuard(req, res, next);
++ return Promise.resolve().then(async () => {
++ let service = null;
++ try { service = await auth.verify(token); } catch { service = null; }
++ if (service && service.ok) return serviceGuard(req, res, next);
++ return personGuard(req, res, next);
++ }).catch((err) => sendError(res, err, req.ov, log));
++ }
++
++ const json = (fn) => async (req, res) => {
++ try { res.set('Cache-Control', 'no-store').json(await fn(req)); }
++ catch (err) { sendError(res, err, req.ov, log); }
++ };
++
++ async function today() {
++ try {
++ const row = await db.prepare(`SELECT count(*) AS n, COALESCE(sum(cost_usd), 0) AS cost FROM runs
++ WHERE workflow_key = 'pics.orientation' AND status = 'succeeded' AND created_at >= ?`).get(dayStart(clock.now()));
++ return { runs: Number(row && row.n) || 0, cost_usd: Math.round((Number(row && row.cost) || 0) * 1e6) / 1e6 };
++ } catch { return { runs: 0, cost_usd: 0 }; }
++ }
++
++ async function defaults() {
++ return (await choices.defaults())['pics.orientation'] || {};
++ }
++
++ /**
++ * A Media/MediaHub service token runs any picture its grant covers; a person runs for themselves. An app may
++ * not turn another app's pictures (the run would be the project's), so it is refused here.
++ */
++ function checkCaller(req) {
++ if (req.principal.kind === 'app') throw new AiError(403, 'capability.denied', 'an app does not run the orientation detector');
++ if (req.principal.kind !== 'user' && !namespaceAllowed(req.principal, 'pics.orientation')) {
++ throw new AiError(403, 'capability.namespace_denied', 'this token may not run pics.orientation');
++ }
++ }
++
++ r.post('/api/v1/vision/orientation', express.json({ limit: '3mb' }), guard, json(async (req) => {
++ checkCaller(req);
++ const d = await defaults();
++ const setting = settingOf(d);
++ if (setting.method !== 'vision') {
++ // Off is an answer, not an error: a caller that always asks learns nothing new and spends nothing.
++ return { method: 'off', answered: false, rotation: null, confidence: null };
++ }
++ const image = req.body && req.body.image;
++ if (!image || typeof image !== 'object' || Array.isArray(image)) throw new AiError(422, 'input.invalid', 'image must be a data URL (a 256 px copy) or a Media reference');
++ if (image.data_url != null) {
++ if (typeof image.data_url !== 'string' || !/^data:image\/(jpeg|png|webp);base64,[A-Za-z0-9+/=\s]+$/i.test(image.data_url)) {
++ throw new AiError(422, 'input.invalid', 'image.data_url must be a base64 JPEG, PNG or WebP data URL');
++ }
++ if (image.data_url.length > MAX_IMAGE * 2) throw new AiError(413, 'source.too_large', 'send a 256 px copy (at most 2 MiB)');
++ } else if (!image.media || typeof image.media.media_id !== 'string') {
++ throw new AiError(422, 'input.invalid', 'image needs a data_url or a Media reference');
++ }
++ const spent = await today();
++ if (setting.daily_cap != null && spent.runs >= setting.daily_cap) {
++ throw new AiError(429, 'orientation.daily_cap', `the orientation detector has answered ${spent.runs} picture${spent.runs === 1 ? '' : 's'} today, its cap of ${setting.daily_cap}`, { retry_after_seconds: 3600 });
++ }
++ if (setting.budget_usd_per_day != null && spent.cost_usd >= setting.budget_usd_per_day) {
++ throw new AiError(429, 'orientation.daily_budget', `the orientation detector has spent $${spent.cost_usd.toFixed(4)} today, its daily budget of $${setting.budget_usd_per_day.toFixed(4)}`, { retry_after_seconds: 3600 });
++ }
++ const created = await runs.create({
++ workflow: 'pics.orientation',
++ input: { image: { ...(image.data_url != null ? { data_url: image.data_url } : { media: image.media }), max_width: 256 }, ...(typeof req.body.hint === 'string' ? { hint: req.body.hint.slice(0, 200) } : {}) },
++ // The owner's model choice is the task default; the cheapest can-see model is the router's ranking.
++ preset: { name: 'cheapest', policy: { objective: 'cheapest' } },
++ }, req.principal, { trace: req.ov.traceId, requestId: req.ov.requestId, abuse: req.abuse });
++ const run = await runs.wait(created, 45000);
++ if (!run || run.status === 'queued' || run.status === 'running') throw new AiError(503, 'orientation.slow', 'the model is still looking at the picture; try again in a minute', { retry_after_seconds: 60 });
++ if (run.status !== 'succeeded') {
++ const why = (run.error && (run.error.detail || run.error.code)) || 'the model did not answer';
++ throw new AiError(502, 'orientation.failed', String(why).slice(0, 300));
++ }
++ const out = run.output || {};
++ const rotation = cleanNumber(out.rotation);
++ const confidence = cleanNumber(out.confidence);
++ if (![0, 90, 180, 270].includes(rotation) || confidence == null) throw new AiError(502, 'orientation.unreadable', 'the model answered without a usable rotation');
++ const after = await today();
++ return {
++ method: 'vision', answered: true,
++ rotation, confidence: Math.max(0, Math.min(1, confidence)),
++ model: (run.provenance && run.provenance.model) || null,
++ provider: (run.provenance && run.provenance.provider) || null,
++ cost_usd: run.usage ? run.usage.cost_usd : null,
++ receipt_id: receiptId(run.id),
++ run_id: run.id,
++ setting: { confidence: setting.confidence },
++ budget: { runs_today: after.runs, daily_cap: setting.daily_cap, cost_usd_today: after.cost_usd, budget_usd_per_day: setting.budget_usd_per_day },
++ };
++ }));
++
++ // The owner console's reading: what the detector is set to, what it has spent today, and the last tries.
++ r.get('/api/v1/admin/orientation', auth.requireCap(CAPS.usageRead, CAPS.workflowManage), json(async () => {
++ const d = await defaults();
++ const spent = await today();
++ let recent = [];
++ try {
++ const rows = await db.prepare(`SELECT id, status, output, provider_key, model_key, cost_usd, created_at FROM runs
++ WHERE workflow_key = 'pics.orientation' ORDER BY created_at DESC LIMIT 20`).all();
++ recent = rows.map((row) => {
++ let out = null;
++ try { out = row.output ? JSON.parse(row.output) : null; } catch { out = null; }
++ return {
++ run_id: row.id, status: row.status, created_at: row.created_at,
++ rotation: out && [0, 90, 180, 270].includes(Number(out.rotation)) ? Number(out.rotation) : null,
++ confidence: out && Number.isFinite(Number(out.confidence)) ? Number(out.confidence) : null,
++ provider: row.provider_key || null, model: row.model_key || null,
++ cost_usd: row.cost_usd == null ? null : Number(row.cost_usd), receipt_id: receiptId(row.id),
++ };
++ });
++ } catch (err) { log.warn('[orientation] recent runs unreadable:', err.message); }
++ return { setting: settingOf(d), today: spent, recent };
++ }));
++
++ return r;
++}
++
++module.exports = { createOrientation, settingOf };
ddiff --git a/server/app.js b/server/app.js
iindex 1aefa65..c2d46ff 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -261,6 +261,7 @@ function createApp({ choices = null, searchFetch = null, config, db, registry, p
// Operator actions shared by the admin API and the operator console (one implementation, one audit row).
const ops = createOps({ db, registry, pool, quotas, cache, runs });
app.use(runsRouter({ choices, runs, registry, auth, config, log }));
++ app.use(require('./api/orientation').createOrientation({ choices, runs, registry, auth, db, config, log }));
app.use(actionsRouter({ runs, registry, auth, log }));
// People's own controls (2026-11-11): one task that turns words into a checked ui.layout@1.
app.use(require('./api/ui-layout').uiLayoutRouter({ runs, auth, log }));
ddiff --git a/server/choices/index.js b/server/choices/index.js
iindex f7973d5..695b93a 100644
---- a/server/choices/index.js
++++ b/server/choices/index.js
@@@ -27,6 +27,7 @@ const SEEDS = [
['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'],
++ ['pics.orientation', 'vision', 'Which way is up in a photograph: 0, 90, 180 or 270 degrees, with a confidence.', 'pics.orientation'],
['chat.reply', 'text', 'Answer a chat message.', 'ai.complete'],
['image.generate', 'image', 'Make an image from a description.', 'ai.image'],
['search.web', 'search', 'Find sources on the web.', 'research.gather'],
@@@ -66,7 +67,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', 'daily_cap', 'guest_find', 'effort'].includes(k))
++ || keys.some((k) => !['signed_out', 'signed_in', 'fallbacks', 'image_model', 'search_engines', 'max_cost_usd', 'daily_cap', 'guest_find', 'effort', 'method', 'confidence', 'budget_usd_per_day'].includes(k))
|| (value.fallbacks != null && !Array.isArray(value.fallbacks))) {
throw new AiError(422, 'defaults.invalid', 'invalid task defaults');
}
@@@ -83,6 +84,12 @@ function defaultValue(value) {
// 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,
++ // The picture-orientation detector (pictures/2026-11-11): off until the owner turns it on, the confidence a
++ // turn must reach, and a daily budget in USD beside the picture count. The keys stay absent from every other
++ // task's defaults, so a value round-trips exactly as it was saved.
++ ...(value.method !== undefined ? { method: value.method } : {}),
++ ...(value.confidence !== undefined ? { confidence: value.confidence } : {}),
++ ...(value.budget_usd_per_day !== undefined ? { budget_usd_per_day: value.budget_usd_per_day } : {}),
};
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');
@@@ -99,6 +106,14 @@ function defaultValue(value) {
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');
}
++ if (out.method !== undefined && !['off', 'vision'].includes(out.method)) throw new AiError(422, 'defaults.invalid', 'method must be off or vision');
++ if (out.confidence !== undefined && (typeof out.confidence !== 'number' || !Number.isFinite(out.confidence) || out.confidence < 0 || out.confidence > 1)) {
++ throw new AiError(422, 'defaults.invalid', 'confidence must be a number from 0 to 1');
++ }
++ if (out.budget_usd_per_day !== undefined && out.budget_usd_per_day !== null
++ && (typeof out.budget_usd_per_day !== 'number' || !Number.isFinite(out.budget_usd_per_day) || out.budget_usd_per_day < 0 || out.budget_usd_per_day > MAX_COST_USD)) {
++ throw new AiError(422, 'defaults.invalid', `budget_usd_per_day must be 0–${MAX_COST_USD} or null`);
++ }
return out;
}
@@@ -130,6 +145,11 @@ function createChoices({ db, registry, preferences, credentials, machines, catal
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()));
++ // OpenVibe.Media's rotation detector (pictures/2026-11-11): off until the owner turns it on. The model is
++ // OpenVibe's own router (the cheapest capable vision model, the detector asks for the cheapest preset), and
++ // the cap and daily budget bound what a site may spend before a receipt is written.
++ await db.prepare('INSERT INTO ai_task_defaults (task, value, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT (task) DO NOTHING')
++ .run('pics.orientation', JSON.stringify({ signed_out: built('openvibe/auto'), signed_in: built('openvibe/auto'), fallbacks: [], image_model: null, search_engines: [], max_cost_usd: 0.05, daily_cap: 50, budget_usd_per_day: 0.25, method: 'off', confidence: 0.8 }), 'seed', iso(clock.now()));
}
async function register(body, service) {
@@@ -165,7 +185,17 @@ function createChoices({ db, registry, preferences, credentials, machines, catal
for (const row of body.defaults) {
const t = await task(row.task);
if (!t) throw new AiError(404, 'task.not_found', 'unknown task');
-- const value = defaultValue(row.value);
++ // The generic defaults page does not draw the orientation detector's own fields; a save from it keeps the
++ // method, confidence and daily bounds it cannot show, rather than turning the detector off in silence.
++ let incoming = row.value;
++ if (row.task === 'pics.orientation') {
++ const prev = await db.prepare('SELECT value FROM ai_task_defaults WHERE task = ?').get(row.task);
++ const old = prev ? JSON.parse(prev.value) : null;
++ if (old && incoming && typeof incoming === 'object') for (const key of ['method', 'confidence', 'daily_cap', 'budget_usd_per_day']) {
++ if (incoming[key] === undefined && old[key] !== undefined) incoming = { ...incoming, [key]: old[key] };
++ }
++ }
++ const value = defaultValue(incoming);
for (const c of [value.signed_out, value.signed_in, ...value.fallbacks]) checkSearchChoice(t.kind, c);
list.push([row.task, value]);
}
@@@ -226,6 +256,10 @@ function createChoices({ db, registry, preferences, credentials, machines, catal
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),
++ // The orientation detector's own controls, read by server/api/orientation.js.
++ method: owner && owner.method ? owner.method : 'off',
++ confidence: owner && owner.confidence != null ? owner.confidence : 0.8,
++ budget_usd_per_day: owner && owner.budget_usd_per_day != null ? owner.budget_usd_per_day : null,
};
}
ddiff --git a/server/openapi.js b/server/openapi.js
iindex 8612a5f..4fc7fcd 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -343,6 +343,33 @@ route('post', '/runs', {
...errors([400, 401, 403, 404, 409, 413, 422, 503]),
},
});
++// The picture-orientation detector (pictures, 2026-11-11 part 3): one small picture in, the clockwise turn needed to
++// make it upright. The owner's setting decides whether it runs at all; media enforces the confidence.
++route('post', '/vision/orientation', {
++ operationId: 'detectPictureOrientation', summary: 'Which way is up in one picture: 0, 90, 180 or 270 degrees', tags: ['runs'], security: SERVICE,
++ description: 'ai.run.create. Runs the `pics.orientation` workflow on a 256 px copy (a data URL) or a Media reference. The owner\'s task defaults decide: method off answers `{ method: "off", answered: false }` and spends nothing; the daily picture cap and daily USD budget answer 429 before a model is called. A person may use it for the owner console\'s check; a service needs the `pics.orientation` namespace. The answer carries the run id, the model, the cost and the receipt id.',
++ requestBody: body({ type: 'object', additionalProperties: false, required: ['image'], properties: {
++ image: { type: 'object', additionalProperties: false, minProperties: 1, properties: {
++ data_url: str({ maxLength: 4000000, description: 'a base64 JPEG, PNG or WebP data URL (a 256 px copy)' }),
++ media: { type: 'object', additionalProperties: false, required: ['media_id'], properties: { media_id: str({ pattern: '^med_[0-9A-HJKMNP-TV-Z]{26}$' }) } },
++ } },
++ hint: str({ maxLength: 200 }),
++ } }),
++ responses: {
++ 200: json('The answer, or that the detector is off', {
++ type: 'object', required: ['method', 'answered'],
++ properties: {
++ method: { type: 'string', enum: ['off', 'vision'] }, answered: { type: 'boolean' },
++ rotation: { type: ['integer', 'null'], enum: [0, 90, 180, 270, null] }, confidence: { type: ['number', 'null'], minimum: 0, maximum: 1 },
++ model: { type: ['string', 'null'] }, provider: { type: ['string', 'null'] }, cost_usd: { type: ['number', 'null'] },
++ receipt_id: str(), run_id: str(), setting: { type: 'object' }, budget: { type: 'object' },
++ },
++ }),
++ 429: { ...problem, headers: { 'Retry-After': { schema: { type: 'integer' } } } },
++ ...errors([401, 403, 422, 502, 503]),
++ },
++});
++
route('get', '/runs', {
operationId: 'listRuns', summary: 'The caller\'s runs, newest first', tags: ['runs'], security: SERVICE,
description: 'ai.run.read (or ai.run.create). `all=1` needs ai.usage.read.',
ddiff --git a/server/workflows/hooks.js b/server/workflows/hooks.js
iindex 5d5c37e..200a1df 100644
---- a/server/workflows/hooks.js
++++ b/server/workflows/hooks.js
@@@ -364,6 +364,9 @@ const PREPARE = {
'pics.describe_image'(input) {
return { vars: { title: clean(input.title, 120) || '' } };
},
++ 'pics.orientation'(input) {
++ return { vars: { hint: clean(input.hint, 200) || '' } };
++ },
'food.read_pantry_photo'(input) {
const foods = (Array.isArray(input.foods) ? input.foods : []).slice(0, 80);
return { vars: { kind: input.kind === 'receipt' ? 'grocery receipt' : 'cupboard, fridge or shelf', foods_block: foods.map((f, i) => `${i}: ${clean(f, 120)}`).join('\n') } };
@@@ -396,6 +399,21 @@ const PREPARE = {
};
const POSTPROCESS = {
++ // The orientation answer is one of four numbers and a confidence; a model that says "90 degrees" or "high"
++ // is read, one that answers anything else makes the run fail rather than turn a picture.
++ 'pics.orientation'(r) {
++ const j = r.json || parseJsonLoose(r.text);
++ if (!j || typeof j !== 'object' || Array.isArray(j)) return null;
++ const raw = j.rotation != null ? j.rotation : (j.degrees != null ? j.degrees : (j.quarter_turns != null ? Number(j.quarter_turns) * 90 : NaN));
++ const n = Number(String(raw).replace(/[^0-9.+-]/g, ''));
++ if (!Number.isFinite(n)) return null;
++ const quarter = Math.round(n / 90);
++ if (Math.abs(n - quarter * 90) > 5) return null;
++ const rotation = ((quarter % 4) + 4) % 4 * 90;
++ const conf = { high: 0.9, medium: 0.6, low: 0.3 }[String(j.confidence || '').toLowerCase()] ?? Number(j.confidence);
++ if (!Number.isFinite(conf)) return null;
++ return { rotation, confidence: Math.max(0, Math.min(1, conf)), gaps: [] };
++ },
'live.viewers.plan'(r) {
const p = r.json || parseJsonLoose(r.text) || { skip: true, lines: [] };
const str = (v) => (v == null ? null : String(v));
ddiff --git a/server/workflows/products.js b/server/workflows/products.js
iindex d251b8f..b2ea040 100644
---- a/server/workflows/products.js
++++ b/server/workflows/products.js
@@@ -458,4 +458,30 @@ workflows.push({
metadata: { seed: true, internal: true },
});
++// OpenVibe.Media's rotation detector (pictures, 2026-11-11): one small picture in, the clockwise turn needed to make
++// it upright, and a confidence. It is a vision model through the ordinary routes, on a 256 px copy: an answer is
++// one of four numbers, so the run is cheap and carries its receipt like every other run.
++templates.push({
++ key: 'pics.orientation', name: 'Pics: which way is up',
++ description: 'Which clockwise quarter-turn makes a photograph upright: 0, 90, 180 or 270 degrees, with a confidence.',
++ input_schema: { type: 'object' }, output_schema: { type: 'object' },
++ system_prompt: 'You look at one photograph and say which way is up. rotation is the clockwise turn in degrees needed to make the picture stand upright: 0 if it is already upright, 90 if its top is on its left (turn it clockwise a quarter), 180 if it is upside down, 270 if its top is on its right (turn it clockwise three quarters). Judge only the picture itself: a landscape, a building, a person standing, text, the horizon and gravity are the clues. If the picture is square, abstract or genuinely unclear, answer 0 with a low confidence — never invent a turn. confidence is a number from 0 to 1. Output only JSON matching the schema.',
++ user_prompt: 'Which way is up? Answer the clockwise rotation needed (0, 90, 180 or 270) and your confidence.',
++ default_route: 'background.json', owner: 'media', metadata: { json_name: 'pics_orientation' },
++});
++workflows.push({
++ key: 'pics.orientation', name: 'Which way is up', namespace: 'pics',
++ input_schema: OBJ({ image: IMAGE, hint: STR(200) }, ['image']),
++ output_schema: OBJ({
++ rotation: { enum: [0, 90, 180, 270] },
++ confidence: { type: 'number', minimum: 0, maximum: 1 },
++ gaps: GAPS,
++ }),
++ steps: [{ kind: 'llm', template: 'pics.orientation', operation: 'generate', output: 'json', image: 'image', image_max_width: 256, prepare: 'pics.orientation', postprocess: 'pics.orientation', params: { max_tokens: 80, temperature: 0 } }],
++ default_route: 'background.json',
++ // A person's picture: never shared between callers or cached.
++ cache_mode: 'none',
++ metadata: { seed: true, internal: true },
++});
++
module.exports = { templates, workflows };
ddiff --git a/server/workflows/seed.js b/server/workflows/seed.js
iindex 120dee9..5916a7a 100644
---- a/server/workflows/seed.js
++++ b/server/workflows/seed.js
@@@ -302,6 +302,9 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
await registry.seedVersioned('route', 'background.vision', backgroundPool(BACKGROUND_TIMEOUT));
await registry.seedVersioned('route', 'background.director', backgroundPool(BACKGROUND_TIMEOUT));
await registry.seedVersioned('route', 'background.json', backgroundPool(BACKGROUND_TIMEOUT, { options: { temperature: 0.3 }, max_output_tokens: 2400, response_format: 'json' }));
++ // Media's rotation detector (pics.orientation): a 256 px picture and four possible numbers, so background work
++ // with a small answer. The owner's task default may pin a different model; the detector asks for the cheapest.
++ await registry.seedVersioned('route', 'pics.orientation', { primary: { provider: 'shared', model: null }, fallbacks: [], options: { temperature: 0 }, max_output_tokens: 80, response_format: 'json', timeout_ms: 60000, alias_of: 'background.json' });
await registry.seedVersioned('route', 'background.creative', backgroundPool(BACKGROUND_TIMEOUT, creativeModel ? { constraints: { objective: 'correctness', latency_class: 'background', model: creativeModel, authority: `shared:${creativeModel}` } } : {}));
// Chat's AI members (server/workflows/chat.js): conversation text, so an alias of the chat pool.
// Space's spaces (server/workflows/space.js): the builder writes whole apps, so it gets the creative model and room to
ddiff --git a/test/latency-classes.test.js b/test/latency-classes.test.js
iindex 1f6a422..3b0def4 100644
---- a/test/latency-classes.test.js
++++ b/test/latency-classes.test.js
@@@ -351,7 +351,7 @@ const BACKGROUND = ['trade.summarize_market_context', 'live.rundown.today', 'liv
'live.streamer.overview', 'live.person.overview', 'live.media.overview', 'live.viewers.plan', 'live.viewers.fold',
'live.hero.slogans', 'live.home.star', 'live.easter_egg', 'live.paste.describe_image', 'live.paste.summarize_text',
'blog.draft_post', 'blog.write_digest', 'blog.write_satire', 'news.summarize_story', 'food.suggest_recipes',
-- 'media.analyze', 'pics.describe_image'];
++ 'media.analyze', 'pics.describe_image', 'pics.orientation'];
const INTERACTIVE = ['chat.global', 'chat.member.reply', 'chat.member.turn', 'chat.profile', 'ai.chat', 'ai.complete',
'ai.generate', 'live.translate', 'live.viewers.reply', 'live.stream.titles', 'live.stream.recap', 'live.clips.confirm',
'games.generate_lore', 'space.app.ask', 'space.app.build', 'wiki.generate_page', 'news.compare_perspectives'];
ddiff --git a/test/orientation.test.js b/test/orientation.test.js
nnew file mode 100644
iindex 0000000..428f4d5
---- /dev/null
++++ b/test/orientation.test.js
@@@ -0,0 +1,92 @@
++'use strict';
++const assert = require('assert');
++const { boot, request, token, suite, seamServer, tmpDir, ALL } = require('./helpers');
++const { settingOf } = require('../server/api/orientation');
++
++const t = suite('picture orientation');
++let h;
++const subject = 'usr_01ARZ3NDEKTSV4RRFFQ69G5FAV';
++const person = (role) => token('person', [], { actorType: 'user', sub: subject, extra: { subject_id: subject, role } });
++const svc = token('media', ALL);
++const c = (model) => ({ model, source: 'openvibe' });
++const value = (over = {}) => ({
++ signed_out: c('seam/see'), signed_in: c('seam/see'), fallbacks: [], image_model: null, search_engines: [],
++ max_cost_usd: 0.05, daily_cap: 50, budget_usd_per_day: 0.25, method: 'vision', confidence: 0.8, ...over,
++});
++
++t.test('the setting is read back as the detector obeys it', () => {
++ assert.deepStrictEqual(settingOf({ method: 'vision', confidence: 0.7, daily_cap: 12, budget_usd_per_day: 0.5, signed_in: c('a/b'), max_cost_usd: 0.05 }), {
++ method: 'vision', confidence: 0.7, daily_cap: 12, budget_usd_per_day: 0.5, model: c('a/b'), max_cost_usd: 0.05,
++ });
++ assert.strictEqual(settingOf(null).method, 'off');
++ assert.strictEqual(settingOf(null).confidence, 0.8);
++});
++
++t.test('off costs nothing, vision answers a turn and carries a receipt; caps refuse before spending', async () => {
++ const seer = await seamServer(() => ({ text: '{"rotation":90,"confidence":0.93}', model: 'see', usage: { input: 40, output: 8 } }));
++ h = await boot({ env: { AI_STUB_FALLBACK: 'false', AI_PROVIDER_RETRIES: '0' } });
++ let r = await request(h.base, 'POST', '/api/v1/providers', { tok: token('live', ALL), body: { key: 'seam', kind: 'http', auth_mode: 'none', base_url: seer.url, capabilities: ['chat', 'generate', 'json', 'vision'], default_model: 'see', timeout_ms: 3000 } });
++ assert.strictEqual(r.status, 201, r.text);
++ r = await request(h.base, 'POST', '/api/v1/models', { tok: token('live', ALL), body: { provider_key: 'seam', model_key: 'see', type: 'chat', supports: { json: true, vision: true }, status: 'active' } });
++ assert.strictEqual(r.status, 201, r.text);
++ r = await request(h.base, 'POST', '/api/v1/routes/pics.orientation/versions', { tok: token('live', ALL), body: { primary: { provider: 'seam', model: 'see' }, fallbacks: [], timeout_ms: 3000, max_output_tokens: 80, response_format: 'json' } });
++ assert.strictEqual(r.status, 201, r.text);
++
++ // Off by default: an answer that says so and runs nothing.
++ r = await request(h.base, 'POST', '/api/v1/vision/orientation', { tok: svc, body: { image: { data_url: 'data:image/jpeg;base64,aGk=' } } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual([r.body.method, r.body.answered], ['off', false]);
++ assert.strictEqual(seer.calls, 0);
++
++ // The owner turns it on; a 256 px copy is looked at and the answer keeps its receipt.
++ const admin = person('owner');
++ r = await request(h.base, 'PUT', '/api/v1/admin/task-defaults', { tok: admin, body: { defaults: [{ task: 'pics.orientation', value: value() }] } });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await request(h.base, 'POST', '/api/v1/vision/orientation', { tok: svc, body: { image: { data_url: 'data:image/jpeg;base64,aGk=' } } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual([r.body.method, r.body.answered, r.body.rotation, r.body.confidence], ['vision', true, 90, 0.93]);
++ assert.match(r.body.receipt_id, /^rcpt_/);
++ assert.strictEqual(r.body.model, 'see');
++ assert.ok(r.body.run_id);
++ assert.strictEqual(seer.calls, 1);
++
++ // The Exchange owner's Try it forwards the person's Network session: that token is accepted here, a non-owner's
++ // is not (the detector's budget is the network's).
++ const ownerNet = token('person', [], { aud: 'openvibe.network', actorType: 'user', sub: subject, extra: { subject_id: subject, role: 'owner' } });
++ r = await request(h.base, 'POST', '/api/v1/vision/orientation', { tok: ownerNet, body: { image: { data_url: 'data:image/jpeg;base64,aGk=' } } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.answered, true);
++ const plainNet = token('person', [], { aud: 'openvibe.network', actorType: 'user', sub: subject, extra: { subject_id: subject, role: 'user' } });
++ r = await request(h.base, 'POST', '/api/v1/vision/orientation', { tok: plainNet, body: { image: { data_url: 'data:image/jpeg;base64,aGk=' } } });
++ assert.deepStrictEqual([r.status, r.body.code], [403, 'role.admin_required']);
++
++ // The console reads the same setting, today's spend and the try's receipt.
++ r = await request(h.base, 'GET', '/api/v1/admin/orientation', { tok: token('ops', ALL) });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.setting.method, 'vision');
++ assert.strictEqual(r.body.today.runs, 2);
++ assert.strictEqual(r.body.recent[0].rotation, 90);
++ assert.match(r.body.recent[0].receipt_id, /^rcpt_/);
++
++ // A cap of zero refuses before a model is called.
++ r = await request(h.base, 'PUT', '/api/v1/admin/task-defaults', { tok: admin, body: { defaults: [{ task: 'pics.orientation', value: value({ daily_cap: 0 }) }] } });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await request(h.base, 'POST', '/api/v1/vision/orientation', { tok: svc, body: { image: { data_url: 'data:image/jpeg;base64,aGk=' } } });
++ assert.deepStrictEqual([r.status, r.body.code], [429, 'orientation.daily_cap']);
++ assert.strictEqual(seer.calls, 2);
++
++ // A budget already spent refuses too.
++ await request(h.base, 'PUT', '/api/v1/admin/task-defaults', { tok: admin, body: { defaults: [{ task: 'pics.orientation', value: value({ budget_usd_per_day: 0 }) }] } });
++ r = await request(h.base, 'POST', '/api/v1/vision/orientation', { tok: svc, body: { image: { data_url: 'data:image/jpeg;base64,aGk=' } } });
++ assert.deepStrictEqual([r.status, r.body.code], [429, 'orientation.daily_budget']);
++ await seer.close();
++});
++
++t.test('a service without the pics.orientation namespace cannot run it', async () => {
++ const stranger = token('stranger', ALL, { ns: ['other.*'] });
++ const r = await request(h.base, 'POST', '/api/v1/vision/orientation', { tok: stranger, body: { image: { data_url: 'data:image/jpeg;base64,aGk=' } } });
++ assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.namespace_denied']);
++});
++
++t.test('finish', async () => h.stop());
++t.run();
ddiff --git a/test/workflows.test.js b/test/workflows.test.js
iindex c70dea5..b96b079 100644
---- a/test/workflows.test.js
++++ b/test/workflows.test.js
@@@ -66,6 +66,7 @@ const EXAMPLES = {
'chat.member.reply': { ai_name: 'Pantry', persona: 'A cheerful cook who keeps it cheap.', room: 'Kim and Lee\'s kitchen', memory: 'Lee is vegetarian.', context: 'Pantry: rice, onions, canned tomatoes, lentils.', context_source: 'OpenVibe.Food household', transcript: 'kim: home late tonight\nlee: same', asked_by: 'kim', message: '@Pantry what can we cook in 20 minutes?' },
'food.read_pantry_photo': { image: { data_url: PNG }, kind: 'receipt', foods: ['Large Eggs (1 dozen)', 'Whole Milk (1 gallon)'] },
'pics.describe_image': { image: { data_url: PNG }, title: 'The coast' },
++ 'pics.orientation': { image: { data_url: PNG } },
'chat.member.turn': { messages: [{ role: 'system', content: 'You are Pantry.' }, { role: 'user', content: 'kim: @Pantry roll a die' }], tools: [{ type: 'function', function: { name: 'roll_dice', parameters: { type: 'object', properties: { sides: { type: 'integer' } } } } }] },
'tools.describe': { tool: 'img.convert', sources: [{ source_type: 'tools.tool', title: 'img.convert descriptor', content: '{"id":"img.convert","inputs":["file","format"]}' }] },
};