openvibers/openvibe.exchange Commits

The owner's AI defaults: one row per task, the model inline, the fallback order (#155)

OP OpenVibers committed 4 days ago · ad5110fae0 · 8 files, +348 −3

* The owner's AI defaults: one row per task, the model inline, the fallback order

Exchange's console gains a Defaults page (owner rank): one form per task
from AI's catalogue, with the signed-out and signed-in picks, an orderable
fallback list, the image model for image tasks, the search engines in order
and a per-run cost ceiling. Saving one task posts only that task, and AI
audits the change and applies it within a minute.

The pickers are plain model lists, not the person-facing chip: an owner
default may name OpenVibe supply only, and the chip also offers a person's
own keys and machines, which a default never uses.

Exchange also proxies the chip's reads and writes for its own pages
(/api/ai/choices) and lets a signed-out visitor read the catalogue, so the
same control works beside any Generate button without a session.

The page is owner-only (everyone else gets 404), its save is CSRF-guarded,
and the whole page still renders when one task's model list cannot be read.

* A search task's defaults list engines, not models

openvibe/auto and openvibe/cheapest are model picks; a search task answers from an
engine, so its pickers list the engines only.

* Merge remote-tracking branch 'origin/main' into ai-choices/c1-exchange

* Merge remote-tracking branch 'origin/main' into ai-choices/c1-exchange

* Retry CI after incomplete GitHub workflow scheduling

* Restart CI after GitHub Actions outage

* AI defaults design: one row per task with both models inline and Save at the end; the rest in a counted fold

A card per task made a long page; now one list, a row each: the task and what it is for, signed out and signed in side by side, Save. More (2 fallbacks · ceiling $0.02) holds the fallback order (one line each: number, model, up, down, Remove), the image model, the search engines and the cost ceiling. Kit buttons (ovk-btn; ovk-button is not a kit class).

Changes

ddiff --git a/public/css/task-defaults.css b/public/css/task-defaults.css
nnew file mode 100644
iindex 0000000..f78d609
---- /dev/null
++++ b/public/css/task-defaults.css
@@@ -0,0 +1,60 @@
++.ex-task-defaults{max-width:1100px;margin:auto;padding:24px 16px}
++.ex-task-defaults h1{font-size:clamp(2rem,5vw,3.2rem);margin:.4em 0}
++.ex-task-defaults p{color:var(--text-secondary);line-height:1.5}
++.ex-default-tasks{display:grid;gap:20px;margin-top:28px}
++.ex-default-task{min-width:0;padding:24px;border:1px solid var(--border);border-radius:16px;background:var(--bg-secondary)}
++.ex-default-task>header{display:flex;justify-content:space-between;gap:16px}
++.ex-default-task h2{font-size:1.1rem;margin:0;overflow-wrap:anywhere}
++.ex-default-task select,.ex-default-task input,.ex-default-task textarea{box-sizing:border-box;max-width:100%;min-height:44px}
++.ex-default-task select{width:100%}
++.ex-default-picks{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
++.ex-default-pick{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:12px 0;min-width:0}
++.ex-default-pick>span{display:block;flex-basis:100%;font-size:.85rem}
++.ex-default-pick select{flex:1;min-width:0}
++.ex-default-task details{margin:16px 0}
++.ex-default-task summary{min-height:44px;cursor:pointer;align-content:center}
++.ex-default-task ol{padding:0;list-style:none}
++.ex-default-task li{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:8px;border-bottom:1px solid var(--border)}
++.ex-default-task li>span{flex:1;min-width:90px;overflow-wrap:anywhere}
++.ex-default-task li select{flex:2;min-width:140px;width:auto}
++.ex-default-task button{min-height:44px}
++.ex-default-cost{display:flex;flex-direction:column;gap:8px;margin:20px 0;max-width:300px}
++.ex-default-task fieldset{min-width:0}
++.ex-default-task textarea{width:100%}
++@media(max-width:600px){.ex-default-task{padding:16px}.ex-default-picks{grid-template-columns:minmax(0,1fr)}}
++
++/* One row per task (Opus, 11-09): task | the two models | Save, the details in a counted fold under the row; one list
++ with lines between rows instead of a card per task. */
++.ex-dt-list{gap:0;border:1px solid var(--border);border-radius:16px;background:var(--bg-secondary);overflow:hidden}
++.ex-dt-row{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,2fr) auto;gap:8px 20px;align-items:center;padding:16px 18px}
++.ex-dt-row+.ex-dt-row{border-top:1px solid var(--border)}
++.ex-dt-task{min-width:0;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 8px;align-items:start}
++.ex-dt-task h2{grid-column:1;margin:0;font-size:1rem;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;overflow-wrap:anywhere}
++.ex-dt-task p{grid-column:1;margin:0;font-size:.85rem}
++.ex-dt-task .ovk-badge{grid-column:2;grid-row:1}
++.ex-dt-picks{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;min-width:0}
++.ex-dt-picks .ex-default-pick{margin:0}
++.ex-dt-picks .ex-default-pick>span{font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--text-muted,var(--text-secondary))}
++.ex-dt-go .ovk-btn{min-height:44px}
++.ex-dt-more{grid-column:1/-1;margin:0}
++.ex-dt-more summary{display:inline-flex;align-items:center;gap:8px;min-height:36px;cursor:pointer;color:var(--text-secondary);font-size:.88rem;font-weight:600}
++.ex-dt-more summary span{font-weight:400;color:var(--text-muted,var(--text-secondary))}
++.ex-dt-more-body{display:grid;gap:14px;padding:12px 0 4px}
++.ex-dt-fallbacks h3{margin:0;font-size:.95rem}
++.ex-dt-fallbacks p{margin:2px 0 8px;font-size:.85rem}
++.ex-dt-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
++.ex-dt-add{margin:8px 0 0}
++@media(max-width:860px){.ex-dt-row{grid-template-columns:minmax(0,1fr) auto}.ex-dt-picks{grid-column:1/-1;grid-row:2}.ex-dt-go{grid-column:2;grid-row:1}}
++@media(max-width:520px){.ex-dt-picks{grid-template-columns:minmax(0,1fr)}.ex-dt-row{padding:14px}}
++.ex-dt-row ol{counter-reset:ex-fb;list-style:none;margin:0;padding:0;display:grid;gap:6px}
++.ex-dt-row ol li{counter-increment:ex-fb;display:grid;grid-template-columns:1.6em minmax(0,1fr) auto auto auto;gap:6px;align-items:center;padding:0;border:0}
++.ex-dt-row ol li::before{content:counter(ex-fb) ".";color:var(--text-muted,var(--text-secondary));font-variant-numeric:tabular-nums;text-align:right}
++.ex-dt-row ol li>[data-fallback-model]{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
++.ex-dt-row ol li select{width:100%;min-width:0}
++.ex-dt-row ol li .ovk-btn{min-height:40px;min-width:40px;padding:0 10px}
++.ex-dt-row ol:empty::after{content:"No fallbacks: when the chosen model is unavailable the request says so.";color:var(--text-muted,var(--text-secondary));font-size:.85rem}
++.ex-dt-add{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px;max-width:560px}
++.ex-dt-row select,.ex-dt-row input,.ex-dt-row textarea{box-sizing:border-box;max-width:100%;min-height:42px}
++.ex-dt-row textarea{width:100%;max-width:560px}
++.ex-dt-row .ex-default-cost{margin:0}
++@media(max-width:520px){.ex-dt-row ol li{grid-template-columns:1.4em minmax(0,1fr) auto auto;}.ex-dt-row ol li .ovk-btn:last-child{grid-column:2/-1}}
ddiff --git a/public/js/task-defaults.js b/public/js/task-defaults.js
nnew file mode 100644
iindex 0000000..3fd6dea
---- /dev/null
++++ b/public/js/task-defaults.js
@@@ -0,0 +1,77 @@
++'use strict';
++/**
++ * The owner's AI defaults: the fallback order moves by drag or by its arrow buttons, "Add fallback" appends the model
++ * chosen beside it, and changing a fallback's picker rewrites the value that form posts. One task posts at a time;
++ * the server reads the order as the list stands.
++ */
++document.querySelectorAll('[data-task-default]').forEach((form) => {
++ const list = form.querySelector('[data-fallback-list]');
++ const models = JSON.parse(list.dataset.models || '[]');
++ const byValue = new Map(models.map((m) => [JSON.stringify({ model: m.model, source: m.source }), m.model]));
++ let dragged = null;
++
++ const buttonsFor = (li) => {
++ for (const [move, text, label] of [['-1', '↑', 'Move fallback up'], ['1', '↓', 'Move fallback down']]) {
++ const button = document.createElement('button');
++ button.type = 'button';
++ button.className = 'ovk-btn ovk-btn--quiet';
++ button.textContent = text;
++ button.setAttribute('aria-label', label);
++ button.dataset.move = move;
++ li.append(button);
++ }
++ const remove = document.createElement('button');
++ remove.type = 'button';
++ remove.className = 'ovk-btn ovk-btn--quiet';
++ remove.textContent = 'Remove';
++ remove.setAttribute('aria-label', 'Remove fallback');
++ remove.dataset.remove = '';
++ li.append(remove);
++ };
++
++ list.addEventListener('change', (event) => {
++ const select = event.target.closest('[data-fallback-edit]');
++ if (!select) return;
++ const li = select.closest('li');
++ li.querySelector('[name=fallback]').value = select.value;
++ const label = li.querySelector('[data-fallback-model]');
++ if (label) label.textContent = byValue.get(select.value) || JSON.parse(select.value).model;
++ });
++ list.addEventListener('click', (event) => {
++ const button = event.target.closest('button');
++ if (!button) return;
++ const li = button.closest('li');
++ if (button.hasAttribute('data-remove')) li.remove();
++ else if (button.dataset.move === '-1' && li.previousElementSibling) list.insertBefore(li, li.previousElementSibling);
++ else if (button.dataset.move === '1' && li.nextElementSibling) list.insertBefore(li.nextElementSibling, li);
++ });
++ list.addEventListener('dragstart', (event) => {
++ dragged = event.target.closest('li');
++ event.dataTransfer.effectAllowed = 'move';
++ });
++ list.addEventListener('dragover', (event) => { if (dragged) event.preventDefault(); });
++ list.addEventListener('drop', (event) => {
++ event.preventDefault();
++ const target = event.target.closest('li');
++ if (dragged && target && target !== dragged) list.insertBefore(dragged, target);
++ dragged = null;
++ });
++
++ form.querySelector('[data-add-fallback]').addEventListener('click', () => {
++ const value = form.querySelector('[name=fallback_new]').value;
++ if (!value || list.children.length >= 10) return;
++ const choice = JSON.parse(value);
++ const li = document.createElement('li');
++ li.draggable = true;
++ const label = document.createElement('span');
++ label.dataset.fallbackModel = '';
++ label.textContent = byValue.get(value) || choice.model;
++ const input = document.createElement('input');
++ input.type = 'hidden';
++ input.name = 'fallback';
++ input.value = value;
++ li.append(label, input);
++ buttonsFor(li);
++ list.append(li);
++ });
++});
ddiff --git a/server/ai.js b/server/ai.js
iindex 33f6ade..9695e94 100644
---- a/server/ai.js
++++ b/server/ai.js
@@@ -312,8 +312,8 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
/** The person's own AI credentials: pass only their signed Network session, never the Exchange service token. */
async function meRequest(token, path, { method = 'GET', body = undefined } = {}) {
-- if (!token) throw Object.assign(new Error('Sign in is required'), { status: 401 });
-- const headers = { Accept: 'application/json', Authorization: `Bearer ${token}`, Origin: config.baseUrl };
++ if (!token && path !== '/api/v1/tasks' && !path.startsWith('/api/v1/choices?')) throw Object.assign(new Error('Sign in is required'), { status: 401 });
++ const headers = { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), Origin: config.baseUrl };
if (body !== undefined) headers['Content-Type'] = 'application/json';
const res = await fetchImpl(`${config.ai.url}${path}`, {
method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
ddiff --git a/server/http/console.js b/server/http/console.js
iindex 048ac86..aafdb6d 100644
---- a/server/http/console.js
++++ b/server/http/console.js
@@@ -4,6 +4,7 @@ const express = require('express');
const { raw } = require('../render/html');
const { send } = require('../render/layout');
const { consolePage } = require('../views/console');
++const { defaultsPage } = require('../views/task-defaults');
const READS = Object.freeze({
supply: '/api/v1/admin/console/supply',
@@@ -23,6 +24,51 @@ function createConsoleRoutes({ config, ai }) {
body: raw(consolePage({ section, data, error })),
});
r.use((req, res, next) => isOwner(req) ? next() : res.sendStatus(404));
++ // The owner's AI defaults: one row per task, the model inline, and the fallback order. Posting one task's form
++ // saves only that task, and AI resolves it within a minute.
++ const DEFAULTS_HEAD = raw('<link rel="stylesheet" href="/css/task-defaults.css"><script src="/js/task-defaults.js" defer></script>');
++ // Owner defaults are OpenVibe supply only, so each picker lists the network's offers for the task's kind: the
++ // chat (or code, vision) models, and for an image task its image models too. A failed read leaves that task's
++ // pickers empty rather than the page; the saved values still render from the defaults.
++ const networkModels = async (task) => {
++ try {
++ const view = await ai.meRequest(req.viewer.token, `/api/v1/choices?task=${encodeURIComponent(task.name)}`);
++ const list = ((view.options && view.options.openvibe) || []).filter((m) => !m.adult);
++ const models = list.map(({ model, source, name, price_estimate_usd }) => ({ model, source, name, price_estimate_usd }));
++ // A model task's two dynamic picks come first: "let OpenVibe choose" is the built-in every task starts
++ // with, and "the cheapest good model" is what Design does signed out. Neither is a single catalogue row,
++ // and a search task has neither: it names an engine.
++ if (task.kind !== 'search') models.unshift({ model: 'openvibe/auto', source: 'openvibe', name: 'Let OpenVibe choose', price_estimate_usd: null }, { model: 'openvibe/cheapest', source: 'openvibe', name: 'The cheapest good model', price_estimate_usd: null });
++ return { models, imageModels: task.kind === 'image' ? models : [] };
++ } catch { return { models: [], imageModels: [] }; }
++ };
++ r.get('/console/defaults', async (req, res) => {
++ try {
++ const data = await ai.meRequest(req.viewer.token, '/api/v1/admin/task-defaults');
++ const optionsByTask = {};
++ for (const task of data.tasks || []) optionsByTask[task.name] = await networkModels(task);
++ return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, index: false, title: 'AI defaults', head: DEFAULTS_HEAD, body: raw(defaultsPage({ ...data, optionsByTask })) });
++ } catch (err) { return page(req, res, 'defaults', {}, err.message, err.status || 502); }
++ });
++ r.post('/console/defaults', async (req, res) => {
++ if (!sameOrigin(req)) return res.sendStatus(403);
++ try {
++ const b = req.body;
++ const fallbacks = b.fallback == null ? [] : [].concat(b.fallback);
++ const value = {
++ signed_out: JSON.parse(b.signed_out),
++ signed_in: JSON.parse(b.signed_in),
++ fallbacks: fallbacks.map((v) => JSON.parse(v)),
++ image_model: b.image_model ? JSON.parse(b.image_model) : null,
++ // One engine a line; a leading - or − turns it off.
++ search_engines: String(b.search_engines || '').split(/\r?\n/).map((x) => x.trim()).filter(Boolean)
++ .map((x) => ({ engine: x.replace(/^[-−]/, ''), enabled: !/^[-−]/.test(x) })),
++ max_cost_usd: b.max_cost_usd === '' ? null : Number(b.max_cost_usd),
++ };
++ await ai.meRequest(req.viewer.token, '/api/v1/admin/task-defaults', { method: 'PUT', body: { defaults: [{ task: b.task, value }] } });
++ return res.redirect(303, '/console/defaults');
++ } catch (err) { return page(req, res, 'defaults', {}, err.message, err.status || 422); }
++ });
r.get('/console/runs/:id', async (req, res) => {
try { return page(req, res, 'runs', await ai.adminRequest(`/api/v1/admin/console/run?id=${encodeURIComponent(req.params.id)}`)); }
catch (err) { return page(req, res, 'runs', {}, err.message || 'AI admin API did not answer.', err.status === 404 ? 404 : 502); }
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 49e2449..d1a7170 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -437,6 +437,16 @@ ${m.meta && m.meta.preset ? `<dt>Preset</dt><dd>${esc(m.meta.preset)}</dd>` : ''
return page(req, res, { title: 'The API', index: false, body: raw(views.apiPage({ catalog, usage, viewer: req.viewer, keyPrivacy })) });
});
++ r.get('/api/ai/choices', async (req,res) => {
++ try { res.set('Cache-Control','private, no-store').json(await ai.meRequest(req.viewer?.token, '/api/v1/choices?task='+encodeURIComponent(String(req.query.task || '')))); }
++ catch(err) { res.status(err.status || 502).json({detail:err.message}); }
++ });
++ r.put('/api/ai/choices/:task', express.json({limit:'4kb'}), async (req,res) => {
++ if (req.get('origin') !== new URL(config.baseUrl).origin) return res.sendStatus(403);
++ try { res.json(await ai.meRequest(req.viewer?.token, '/api/v1/me/choices/'+encodeURIComponent(req.params.task), {method:'PUT',body:req.body})); }
++ catch(err) { res.status(err.status || 502).json({detail:err.message}); }
++ });
++
// A person's AI credentials live on Exchange. The user's signed Network token travels server-to-server to AI;
// the page and its responses contain metadata only, never a saved API key or cookie value.
r.get('/privacy/keys', async (req, res) => {
ddiff --git a/server/views/console.js b/server/views/console.js
iindex 250e1ba..b43c7f8 100644
---- a/server/views/console.js
++++ b/server/views/console.js
@@@ -3,7 +3,7 @@
const { esc } = require('../render/html');
const SECTIONS = [
-- ['Overview', '/console'], ['GPU supply (dry run)', '/console/supply'], ['Providers', '/console/providers'], ['Models', '/console/models'],
++ ['Overview', '/console'], ['Defaults', '/console/defaults'], ['GPU supply (dry run)', '/console/supply'], ['Providers', '/console/providers'], ['Models', '/console/models'],
['Routes', '/console/routes'], ['Templates', '/console/templates'], ['Workflows', '/console/workflows'],
['Runs', '/console/runs'], ['Quotas', '/console/quotas'], ['Usage', '/console/usage'],
['Cache', '/console/cache'], ['GPUs', '/console/gpus'], ['Price book', '/console/pricebook'],
ddiff --git a/server/views/task-defaults.js b/server/views/task-defaults.js
nnew file mode 100644
iindex 0000000..33e427e
---- /dev/null
++++ b/server/views/task-defaults.js
@@@ -0,0 +1,77 @@
++'use strict';
++/**
++ * The owner's AI defaults (plan §1, owner rank): one row per task with the model inline and the fallback order.
++ * Owner defaults name OpenVibe supply only (AI enforces that), so each picker is a plain model list rather than the
++ * person-facing chip: the chip carries "your keys" and "your machines", which an owner default may never use.
++ * Design and layout come from the Kit; Opus restyles on review.
++ */
++const { esc } = require('../render/html');
++
++const AUTO = { model: 'openvibe/auto', source: 'openvibe' };
++
++const LABEL = { 'openvibe/auto': 'Let OpenVibe choose' };
++
++const option = (value, label, selected) => `<option value="${esc(JSON.stringify(value))}"${selected ? ' selected' : ''}>${esc(label)}</option>`;
++
++/** One model picker: the current choice, and the models the task may use. */
++function picker(name, label, choice, models) {
++ const current = choice ? JSON.stringify(choice) : '';
++ const known = models.some((m) => JSON.stringify({ model: m.model, source: m.source }) === current);
++ return `<label class="ex-default-pick"><span>${esc(label)}</span><select name="${esc(name)}" required>
++ ${known || !choice ? '' : option(choice, `${LABEL[choice.model] || choice.model} (not listed)`, true)}
++ ${models.map((m) => option({ model: m.model, source: m.source }, `${LABEL[m.model] || m.name || m.model}${m.price_estimate_usd == null ? '' : ` · $${m.price_estimate_usd.toFixed(4)} a run`}`, !known && JSON.stringify({ model: m.model, source: m.source }) === current)).join('')}
++ </select></label>`;
++}
++
++function fallbackRow(choice, models) {
++ return `<li draggable="true"><span data-fallback-model>${esc(choice.model)}</span><input type="hidden" name="fallback" value="${esc(JSON.stringify(choice))}"><select data-fallback-edit aria-label="Change this fallback">
++ ${models.map((m) => option({ model: m.model, source: m.source }, m.name || m.model, m.model === choice.model)).join('')}
++ </select><button type="button" class="ovk-btn ovk-btn--quiet" data-move="-1" aria-label="Move fallback up">↑</button><button type="button" class="ovk-btn ovk-btn--quiet" data-move="1" aria-label="Move fallback down">↓</button><button type="button" class="ovk-btn ovk-btn--quiet" data-remove>Remove</button></li>`;
++}
++
++/**
++ * One task as one row (Opus's design, 11-09: "one row per task, the model inline"): the task and what it is for, the
++ * signed-out and signed-in models side by side, Save at the end; the fallback order, the image model, the search
++ * engines and the cost ceiling in a counted fold under the row.
++ */
++function taskForm(task, options) {
++ const d = options.current || { signed_out: task.default, signed_in: task.default, fallbacks: [], search_engines: [], image_model: null, max_cost_usd: null };
++ const models = options.models || [];
++ const more = [`${d.fallbacks.length} fallback${d.fallbacks.length === 1 ? '' : 's'}`, task.kind === 'image' ? 'image model' : '', task.kind === 'search' ? 'search engines' : '', d.max_cost_usd != null ? `ceiling $${Number(d.max_cost_usd).toFixed(4)}` : 'no ceiling'].filter(Boolean).join(' · ');
++ return `<form class="ex-dt-row ovk-form" method="post" action="/console/defaults" data-task-default>
++ <input type="hidden" name="task" value="${esc(task.name)}">
++ <div class="ex-dt-task"><h2>${esc(task.name)}</h2><p>${esc(task.sentence)}</p><span class="ovk-badge">${esc(task.kind)}</span></div>
++ <div class="ex-dt-picks">
++ ${picker('signed_out', 'Signed out', d.signed_out, models)}
++ ${picker('signed_in', 'Signed in', d.signed_in, models)}
++ </div>
++ <div class="ex-dt-go"><button class="ovk-btn ovk-btn--primary" type="submit" aria-label="Save ${esc(task.name)}">Save</button></div>
++ <details class="ex-dt-more">
++ <summary>More <span>${esc(more)}</span></summary>
++ <div class="ex-dt-more-body">
++ ${task.kind === 'image' ? picker('image_model', 'Image model', d.image_model || d.signed_in, options.imageModels || []) : ''}
++ <section class="ex-dt-fallbacks"><h3>Fallback order</h3><p>The first available model answers. Drag to reorder, or use the arrows.</p>
++ <ol data-fallback-list data-models="${esc(JSON.stringify(models))}">${d.fallbacks.map((c) => fallbackRow(c, models)).join('')}</ol>
++ <div class="ex-default-pick ex-dt-add"><span class="ex-dt-sr">Add a fallback</span><select name="fallback_new" aria-label="A model to add as a fallback">
++ ${models.map((m) => option({ model: m.model, source: m.source }, m.name || m.model, false)).join('')}
++ </select><button type="button" class="ovk-btn ovk-btn--quiet" data-add-fallback>Add fallback</button></div></section>
++ ${task.kind === 'search' ? `<fieldset class="ex-dt-engines"><legend>Search engines</legend><p>Enabled engines are tried in this order.</p><textarea name="search_engines" rows="4" aria-label="Search engines in order; prefix a disabled engine with a minus">${esc(d.search_engines.map((e) => (e.enabled ? '' : '-') + e.engine).join('\n'))}</textarea></fieldset>` : ''}
++ <label class="ex-default-cost">Per-run cost ceiling (USD)<input name="max_cost_usd" type="number" min="0" max="1000" step="0.0001" value="${esc(d.max_cost_usd ?? '')}" placeholder="No task ceiling"></label>
++ </div>
++ </details>
++ </form>`;
++}
++
++/**
++ * optionsByTask: { [task]: { current, models, imageModels } } — AI's resolved options for each task. A task whose
++ * options could not be read still renders, with the options it has, so one failing read never hides the page.
++ */
++function defaultsPage({ tasks = [], defaults = {}, optionsByTask = {}, error = null } = {}) {
++ return `<div class="ex-task-defaults">
++ <header><p class="ovk-eyebrow">Owner console</p><h1>AI defaults</h1><p>Choose what answers across OpenVibe. A person's own choice takes priority, and every change is audited and reaches services within a minute.</p></header>
++ ${error ? `<p role="alert">${esc(error)}</p>` : ''}
++ <div class="ex-default-tasks ex-dt-list">${tasks.map((task) => taskForm(task, { current: defaults[task.name] || null, ...(optionsByTask[task.name] || {}) })).join('')}</div>
++</div>`;
++}
++
++module.exports = { defaultsPage };
ddiff --git a/test/task-defaults.test.js b/test/task-defaults.test.js
nnew file mode 100644
iindex 0000000..fa9cf03
---- /dev/null
++++ b/test/task-defaults.test.js
@@@ -0,0 +1,75 @@
++'use strict';
++/**
++ * The owner's AI defaults page and its post: owner-only, CSRF-guarded, and the payload it sends AI carries the
++ * signed-out and signed-in picks, the fallback order as it stands, the search order and the cost ceiling.
++ */
++const assert = require('assert');
++const express = require('express');
++const { createServer } = require('http');
++const { createConsoleRoutes } = require('../server/http/console');
++
++const task = { name: 'design.icon', kind: 'code', sentence: 'Draw an original SVG icon.', default: { model: 'openvibe/auto', source: 'openvibe' } };
++const searchTask = { name: 'search.web', kind: 'search', sentence: 'Find sources on the web.', default: { model: 'search/wikipedia', source: 'openvibe' } };
++const pick = { model: 'anthropic/claude-opus-5-5', source: 'openvibe' };
++const defaults = { 'design.icon': { signed_out: pick, signed_in: pick, fallbacks: [pick], image_model: null, search_engines: [], max_cost_usd: 0.5 } };
++const choicesFor = (name) => ({ options: { openvibe: [{ model: name === 'search.web' ? 'search/wikipedia' : 'anthropic/claude-opus-5-5', source: 'openvibe', name: 'Claude Opus 5.5', price_estimate_usd: 0.02, adult: false }], keys: [], machines: [] } });
++
++const calls = [];
++const app = express();
++app.use((req, res, next) => {
++ if (req.get('x-role')) req.viewer = { kind: 'user', subject: 'usr_01ARZ3NDEKTSV4RRFFQ69G5FAV', role: req.get('x-role'), token: 'test-session' };
++ next();
++});
++app.use(createConsoleRoutes({
++ config: { baseUrl: 'https://openvibe.exchange' },
++ ai: {
++ meRequest: async (token, path, options) => {
++ calls.push({ token, path, options });
++ if (path === '/api/v1/admin/task-defaults') return { tasks: [task, searchTask], defaults };
++ if (path.startsWith('/api/v1/choices?task=')) return choicesFor(decodeURIComponent(path.split('=')[1]));
++ return { defaults };
++ },
++ },
++}));
++
++(async () => {
++ const server = createServer(app);
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = 'http://127.0.0.1:' + server.address().port;
++ try {
++ // Owner rank only: no viewer, a person and an admin all get 404.
++ for (const role of ['', 'user', 'admin']) {
++ assert.strictEqual((await fetch(base + '/console/defaults', { headers: role ? { 'x-role': role } : {} })).status, 404, `${role || 'signed out'} is not the owner`);
++ }
++ const page = await fetch(base + '/console/defaults', { headers: { 'x-role': 'owner' } });
++ const html = await page.text();
++ assert.strictEqual(page.status, 200);
++ assert.ok(html.includes('<h1>AI defaults</h1>'), 'the page names itself');
++ assert.ok(html.includes('data-fallback-list'), 'the fallback order is on the page');
++ assert.ok(html.includes('data-task-default'), 'each task has its own form');
++ assert.ok(html.includes('name="signed_out"') && html.includes('name="signed_in"'), 'both picks are on the page');
++ assert.ok(html.includes('name="search_engines"'), 'the search task carries its engines');
++ assert.ok(html.includes('data-models='), 'the list carries the models the page may offer');
++ assert.ok(!html.includes('data-ai-choice'), 'an owner default never offers the person-facing chip (keys, machines)');
++ assert.strictEqual(calls[0].token, 'test-session', 'the owner session reaches AI');
++
++ const body = new URLSearchParams({ task: task.name, signed_out: JSON.stringify(pick), signed_in: JSON.stringify(pick), fallback: JSON.stringify(pick), max_cost_usd: '0.1' });
++ // A cross-site post is refused even with the owner's session.
++ assert.strictEqual((await fetch(base + '/console/defaults', { method: 'POST', headers: { 'x-role': 'owner', 'Content-Type': 'application/x-www-form-urlencoded' }, body })).status, 403);
++ const saved = await fetch(base + '/console/defaults', { method: 'POST', redirect: 'manual', headers: { 'x-role': 'owner', Origin: 'https://openvibe.exchange', 'Content-Type': 'application/x-www-form-urlencoded' }, body });
++ assert.strictEqual(saved.status, 303, 'a save redirects back to the page');
++ const sent = calls.at(-1).options.body.defaults[0];
++ assert.deepStrictEqual([sent.task, sent.value.signed_in.model, sent.value.fallbacks.length, sent.value.max_cost_usd], [task.name, pick.model, 1, 0.1]);
++
++ // The search order is one engine a line, and a leading minus turns one off.
++ const searchBody = new URLSearchParams({ task: searchTask.name, signed_out: JSON.stringify({ model: 'search/wikipedia', source: 'openvibe' }), signed_in: JSON.stringify({ model: 'search/wikipedia', source: 'openvibe' }), search_engines: 'wikipedia\n-commons\nopenverse', max_cost_usd: '' });
++ const searchSaved = await fetch(base + '/console/defaults', { method: 'POST', redirect: 'manual', headers: { 'x-role': 'owner', Origin: 'https://openvibe.exchange', 'Content-Type': 'application/x-www-form-urlencoded' }, body: searchBody });
++ assert.strictEqual(searchSaved.status, 303);
++ const searchValue = calls.at(-1).options.body.defaults[0].value;
++ assert.deepStrictEqual(searchValue.search_engines, [{ engine: 'wikipedia', enabled: true }, { engine: 'commons', enabled: false }, { engine: 'openverse', enabled: true }]);
++ assert.strictEqual(searchValue.max_cost_usd, null, 'an empty ceiling is no ceiling');
++ console.log('Defaults: owner-only page, CSRF protection, and the payload AI receives');
++ } finally {
++ await new Promise((r) => server.close(r));
++ }
++})().catch((e) => { console.error(e); process.exitCode = 1; });