openvibers/openvibe.design Commits

Design asks for Claude as a preference and acts for the person (L37) (#57)

OP OpenVibers committed 13 days ago · ec324aa2a4 · 4 files, +279 −88

A design's three takes now ask OpenVibe.AI for Claude Opus 5.5 (or Sonnet 5.5 for who=sonnet, nothing for who=any) with prefer, never a pinned route, and send on_behalf_of so AI tries the person's own key first. Compare keeps its pins, since it really wants different providers. The run's explain (paid_by and prefer.honoured) is carried on each variant for the page.

The preference path uses the chat passthrough: AI's ai.generate step does not pass prefer to the router, while ai.chat does (same default.chat route and text output).

Changes

ddiff --git a/server/arena/ai.js b/server/arena/ai.js
iindex c427234..79b9658 100644
---- a/server/arena/ai.js
++++ b/server/arena/ai.js
@@@ -185,27 +185,35 @@ function createArenaAi({ config, fetchImpl = globalThis.fetch, log = console })
/**
* Who draws a design on openvibe.design (plan L37, the owner: "default to Opus 5.5… most of the site
-- * Anthropic-powered, but configurable"). `claude`, the default, asks for Claude Opus 5.5, Claude Sonnet 5.5 and
-- * a second take from Opus, from the offers AI makes this service; a model AI does not offer is replaced by the
-- * Arena's next pick, and `claude: false` in the answer says no Claude model was offered at all. `compare` is the
-- * Arena's three, one per provider.
++ * Anthropic-powered, but configurable"). `claude`, the default, prefers Claude Opus 5.5, `sonnet` prefers Claude
++ * Sonnet 5.5 and `any` prefers nothing; the preference is soft (AI may use another offer when the person's own key
++ * or the named model cannot serve), never a route pin, so the person's own key is tried first. `compare` is the
++ * one mode that really asks for different providers, and keeps the Arena's pinned three.
++ *
++ * The three slots are three takes of the chosen model; when AI does not offer it they are the Arena's next picks
++ * and `claude: false` says so. `prefer` is what each call sends, and `pin` says whether the slot is a route pin.
*/
-- const CLAUDE = [/^claude-opus-5-5\b/, /^claude-sonnet-5-5\b/, /^claude-opus-5-5\b/];
++ const CLAUDE_PREFER = {
++ claude: { provider: 'anthropic', model: 'claude-opus-5-5' },
++ sonnet: { provider: 'anthropic', model: 'claude-sonnet-5-5' },
++ };
++ function preferFor(who) {
++ if (who === 'any' || who === 'compare') return null;
++ return CLAUDE_PREFER[who] || CLAUDE_PREFER.claude; // anything else stays Claude, as before
++ }
async function designPlan(who = 'claude') {
-- if (who === 'compare') return { ...(await slotPlan()), who: 'compare', claude: null };
++ if (who === 'compare') return { ...(await slotPlan()), who: 'compare', claude: null, prefer: null, pin: true };
let list = configuredModels();
if (!list.length) list = await offeredModels();
const offers = rankOffers(list);
-- const slots = [];
-- for (const re of CLAUDE) {
-- const o = offers.find((x) => x.model && re.test(x.model));
-- if (o) slots.push(o);
-- }
-- const claude = slots.length > 0;
-- for (const o of offers) if (slots.length < 3 && !slots.some((x) => x.provider === o.provider && x.model === o.model)) slots.push(o);
++ const prefer = preferFor(who);
++ const match = prefer ? offers.find((x) => x.provider === prefer.provider && x.model === prefer.model) : null;
++ const slots = (match ? [match, match, match] : offers.slice(0, 3)).map((x) => ({ ...x }));
const poolFallback = slots.length < 3;
while (slots.length < 3) slots.push({ provider: 'ai', model: null });
-- return { slots, pool_fallback: poolFallback, offered: offers.length, who: 'claude', claude };
++ return { slots, pool_fallback: poolFallback, offered: offers.length,
++ who: who === 'sonnet' ? 'sonnet' : who === 'any' ? 'any' : 'claude',
++ claude: prefer ? Boolean(match) : null, prefer, pin: false };
}
function systemPrompt(category) {
@@@ -260,35 +268,77 @@ function createArenaAi({ config, fetchImpl = globalThis.fetch, log = console })
return { run: r, ms: msOf(r, Date.now() - startedAt) };
}
-- async function generate({ prompt, category, slot, roundId, index }) {
++ /**
++ * Start one AI run. The run API is what carries `prefer` and the person (`on_behalf_of`); a deployment that
++ * predates it still has POST /api/v1/generate, which takes a route pin but no preference.
++ */
++ async function createRun(body, legacy, wait) {
++ try {
++ return await call('POST', `/api/v1/runs?wait=${wait}`, body);
++ } catch (err) {
++ if (!err || err.status !== 404) throw err;
++ return call('POST', `/api/v1/generate?wait=${wait}`, legacy);
++ }
++ }
++
++ /** AI reads the person a service acts for as a SubjectRef in the run body. */
++ function onBehalfOf(person) {
++ return person ? { type: 'user', id: String(person) } : null;
++ }
++
++ /** A person's own key answers as `byo:self:<subject>:<provider>`; the frame names the provider, not the key. */
++ function providerName(raw) {
++ const s = String(raw || 'ai');
++ return s.startsWith('byo:self:') ? s.split(':').pop() : s;
++ }
++
++ /** Which key paid, and whether the preference was honoured (the run's explain; absent on old/cached runs). */
++ function paidFrom(run) {
++ const explain = run && run.explain && typeof run.explain === 'object' ? run.explain : {};
++ return {
++ paid_by: explain.paid_by != null ? String(explain.paid_by).slice(0, 160) : null,
++ prefer: explain.prefer && typeof explain.prefer === 'object' ? explain.prefer : null,
++ };
++ }
++
++ async function generate({ prompt, category, slot, roundId, index, prefer = null, person = null, pin = true }) {
if (!enabled) throw new ArenaAiError(503, 'ai.not_configured', 'OpenVibe.AI is not configured for the Arena.');
-- // One offer from GET /api/v1/routes: AI runs that provider/model alone, or refuses it in words.
-- // A pool slot (fewer than three offered) sends no route and lets the routing choose.
-- const pinned = slot && slot.provider && slot.provider !== 'ai' ? { provider: slot.provider, model: slot.model || null } : null;
-- const body = {
++ // A route pin is the Arena's compare mode (and its background rounds): AI runs that provider/model alone.
++ // Everything else is a soft preference, with no route, so AI tries the person's own key first.
++ const pinned = pin && slot && slot.provider && slot.provider !== 'ai' ? { provider: slot.provider, model: slot.model || null } : null;
++ const base = {
prompt: `${prompt}\n\nCategory: ${category}\nReturn one complete HTML document.`,
system: systemPrompt(category),
max_tokens: 8000,
temperature: 0.7,
-- options: { cache: false },
-- ...(pinned ? { route: pinned } : {}),
-- target: { service: 'design', type: 'arena_output', id: `${roundId}_${index}`.slice(0, 128) },
};
++ const target = { service: 'design', type: 'arena_output', id: `${roundId}_${index}`.slice(0, 128) };
++ const who = onBehalfOf(person);
const startedAt = Date.now();
const wait = Math.min(ai.waitMs, 60_000);
++ const legacy = { ...base, options: { cache: false }, ...(pinned ? { route: pinned } : {}), ...(who ? { on_behalf_of: who } : {}), target };
let created;
-- try {
-- created = await call('POST', `/api/v1/generate?wait=${wait}`, body);
-- } catch (err) {
-- // A deployment that exposes the run API but not the convenience operation: the same run, by workflow.
-- if (!err || err.status !== 404) throw err;
-- created = await call('POST', `/api/v1/runs?wait=${wait}`, {
++ if (pinned) {
++ created = await createRun({
workflow: 'ai.generate',
-- input: { prompt: body.prompt, system: body.system, max_tokens: body.max_tokens, temperature: body.temperature },
-- options: body.options,
-- ...(body.route ? { route: body.route } : {}),
-- target: body.target,
-- });
++ input: { prompt: base.prompt, system: base.system, max_tokens: base.max_tokens, temperature: base.temperature },
++ options: { cache: false },
++ route: pinned,
++ ...(prefer ? { prefer } : {}),
++ ...(who ? { on_behalf_of: who } : {}),
++ target,
++ }, legacy, wait);
++ } else {
++ // The llm step of ai.generate does not pass `prefer` to the router; the chat passthrough does, and it
++ // answers the same `{ text }` on the same default.chat route. A preference is therefore a chat run.
++ created = await createRun({
++ workflow: 'ai.chat',
++ input: { system: base.system, messages: [{ role: 'user', content: base.prompt }], max_tokens: base.max_tokens, temperature: base.temperature },
++ options: { cache: false },
++ ...(prefer ? { prefer } : {}),
++ ...(who ? { on_behalf_of: who } : {}),
++ target,
++ }, legacy, wait);
}
const first = created && created.run ? created.run : created;
const { run, ms } = await waitFor(first, startedAt);
@@@ -314,32 +364,34 @@ function createArenaAi({ config, fetchImpl = globalThis.fetch, log = console })
ms,
tokens: Math.max(0, Math.round(tokens)),
model: String(prov.model || run.model || (run.output && run.output.model) || (slot && slot.model) || 'unknown').slice(0, 120),
-- provider: String(prov.provider || run.provider || (slot && slot.provider) || 'ai').slice(0, 80),
++ provider: providerName(prov.provider || run.provider || (slot && slot.provider) || 'ai').slice(0, 80),
ui_check: run.ui_check || (run.output && run.output.ui_check) || null,
run_id: run.id || null,
++ ...paidFrom(run),
};
}
/**
* Plain text from OpenVibe.AI's routing (the glyph drafts, L32): the same token and run as the Arena, one answer.
-- * → { text, model, provider, run_id }
++ * → { text, model, provider, run_id, paid_by, prefer }
*/
-- async function text({ prompt, system, maxTokens = 2000, target = null, prefer = null }) {
++ async function text({ prompt, system, maxTokens = 2000, target = null, prefer = null, person = null }) {
const startedAt = Date.now();
-- // `prefer` (a model pattern) pins the first offer that matches it (L37: Claude first); no match lets AI's
-- // routing choose, as before.
-- let route = null;
-- if (prefer) {
-- const list = configuredModels().length ? configuredModels() : await offeredModels().catch(() => []);
-- const o = list.find((x) => x.model && prefer.test(x.model));
-- if (o) route = { provider: o.provider, model: o.model };
-- }
-- const created = await call('POST', `/api/v1/generate?wait=${Math.min(ai.waitMs, 60_000)}`, {
-- prompt, system, max_tokens: maxTokens, options: { cache: false }, ...(target ? { target } : {}), ...(route ? { route } : {}),
-- });
++ const who = onBehalfOf(person);
++ const wait = Math.min(ai.waitMs, 60_000);
++ const legacy = { prompt, max_tokens: maxTokens, options: { cache: false },
++ ...(system ? { system } : {}), ...(who ? { on_behalf_of: who } : {}), ...(target ? { target } : {}) };
++ const created = await createRun({
++ workflow: 'ai.chat',
++ input: { ...(system ? { system } : {}), messages: [{ role: 'user', content: String(prompt == null ? '' : prompt) }], max_tokens: maxTokens },
++ options: { cache: false },
++ ...(prefer ? { prefer } : {}),
++ ...(who ? { on_behalf_of: who } : {}),
++ ...(target ? { target } : {}),
++ }, legacy, wait);
const { run } = await waitFor(created && created.run ? created.run : created, startedAt);
const prov = run.provenance || {};
-- return { text: textOf(run.output), model: String(prov.model || run.model || 'unknown').slice(0, 120), provider: String(prov.provider || run.provider || 'ai').slice(0, 80), run_id: run.id || null };
++ return { text: textOf(run.output), model: String(prov.model || run.model || 'unknown').slice(0, 120), provider: providerName(prov.provider || run.provider || 'ai').slice(0, 80), run_id: run.id || null, ...paidFrom(run) };
}
return { enabled, slots, slotPlan, designPlan, offeredModels, rankOffers, generate, systemPrompt, text, CATEGORY_BRIEF };
ddiff --git a/server/designs.js b/server/designs.js
iindex 10cee6d..fd60ab1 100644
---- a/server/designs.js
++++ b/server/designs.js
@@@ -107,11 +107,14 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
const count = Number(await db.value(`SELECT COUNT(*) FROM designs WHERE ${owner ? 'owner' : 'guest_ip'}=$1 AND created_at >= $2::timestamptz`, [owner || ip, cutoff]));
if (count >= (owner ? 10 : 2)) throw fail(429, 'design.daily_limit', 'Daily design limit reached.');
const selected = await theme(themeId);
-- // Claude first (L37); `compare` asks the Arena's three. An AI client without designPlan (tests, older) uses slots.
-- const plan = ai.designPlan ? await ai.designPlan(who === 'compare' ? 'compare' : 'claude') : await ai.slotPlan();
++ // Claude first (L37) as a soft preference: claude -> Opus 5.5, sonnet -> Sonnet 5.5, any -> no preference;
++ // `compare` is the one pinned mode. An AI client without designPlan (tests, older) uses its slots.
++ const plan = ai.designPlan ? await ai.designPlan(who) : await ai.slotPlan();
++ const prefer = plan.prefer || null, pin = plan.pin === true;
const slots = plan.slots.slice(0, 3);
const variants = slots.map((slot, index) => ({ id: s.newId('dva'), slot: index, provider: slot.provider,
-- model: slot.model, status: 'pending', html: '', ms: 0, costUsd: 0, error: null }));
++ model: slot.model, status: 'pending', html: '', ms: 0, costUsd: 0, error: null,
++ paid_by: null, prefer: prefer ? { ...prefer } : null, pin }));
const id = s.newId('dsn'), editKey = owner ? null : random();
await db.exec('INSERT INTO designs(id,prompt,kind,theme_id,variants,owner,guest_hash,guest_ip,created_at,updated_at) VALUES($1,$2,$3,$4,$5::jsonb,$6,$7,$8,$9::timestamptz,$9::timestamptz)',
[id, prompt, kind, selected.id, JSON.stringify(variants), owner, editKey ? hash(editKey).toString('hex') : null, owner ? null : ip, s.iso()]);
@@@ -134,11 +137,13 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
const t = await theme(d.theme_id);
const prompt = `${d.prompt}\nUse ONLY these theme CSS custom properties for all colors: ${Object.keys(t.variables || {}).join(', ')}. No external requests. Mobile first. Return one complete HTML document.`;
const out = await ai.generate({ prompt, category: d.kind === 'component' ? 'logo' : d.kind === 'screen' ? 'mobile_app' : 'landing',
-- slot: { provider: v.provider, model: v.model }, roundId: id, index });
++ slot: { provider: v.provider, model: v.model }, roundId: id, index,
++ prefer: v.prefer || null, person: d.owner || null, pin: v.pin === true });
const current = await db.maybe('SELECT theme_id FROM designs WHERE id=$1', [id]);
const currentTheme = await theme(current.theme_id);
await updateVariant(id, index, { ...running, status: 'done', html: wrap(out.html, currentTheme.variables || {}),
-- provider: out.provider, model: out.model, ms: out.ms, costUsd: out.cost_usd, error: null });
++ provider: out.provider, model: out.model, ms: out.ms, costUsd: out.cost_usd, error: null,
++ paid_by: out.paid_by || null, prefer: out.prefer || v.prefer || null });
} catch (err) {
await updateVariant(id, index, { ...running, status: 'failed', error: String(err.message).slice(0, 300) });
}
@@@ -277,7 +282,10 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
const p = await pickRow(id);
const versions = p ? await db.many('SELECT * FROM design_versions WHERE design_id=$1 ORDER BY seq', [id]) : [];
const current = p ? versions.find(v => Number(v.seq) === Number(p.current_seq)) : null;
-- const variants = d.variants.map((v, index) => {
++ const variants = d.variants.map((full, index) => {
++ // `pin` is how the run was asked to route, not part of the frame; `prefer` is the request before the run
++ // and the run's explain (with `honoured`) after it.
++ const { pin, ...v } = full;
if (p && index === Number(p.variant_index) && current) {
return { ...v, html: wrap(current.html, selected.variables || {}), currentSeq: Number(current.seq), edited: current.kind !== 'generated' };
}
@@@ -350,7 +358,14 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
`<<<CURRENT_DESIGN\n${String(current.html || variant.html || '').slice(0, 80000)}\nCURRENT_DESIGN>>>`,
`Use ONLY these theme CSS custom properties for every colour: ${names || '(the document already uses them)'}. Do not add scripts, event-handler attributes, links, web fonts, remote images or any external request. Mobile first.`,
].join('\n\n');
-- const out = await ai.generate({ prompt, category: categoryOf(d.kind), slot: { provider: variant.provider, model: variant.model }, roundId: d.id, index: variant.slot });
++ // An edit stays on the model that drew the frame, as a soft preference (the person's own key first), never a pin.
++ const asked = variant.prefer && variant.prefer.provider ? variant.prefer : null;
++ const prefer = asked && asked.provider && !String(asked.provider).startsWith('byo:self:')
++ ? { provider: asked.provider, model: asked.model || null }
++ : variant.provider && variant.provider !== 'ai' && !String(variant.provider).startsWith('byo:self:') && variant.model
++ ? { provider: variant.provider, model: variant.model } : null;
++ const out = await ai.generate({ prompt, category: categoryOf(d.kind), slot: { provider: variant.provider, model: variant.model },
++ roundId: d.id, index: variant.slot, prefer, person: subject || d.owner || null, pin: false });
const html = wrap(out.html, variables);
const version = await addVersion(d, p, { kind: 'chat', html, instruction, model: out.model || variant.model,
provider: out.provider || variant.provider, costUsd: out.cost_usd, ms: out.ms, createdBy: subject || (d.owner ? 'owner' : 'guest') });
ddiff --git a/server/glyphs/drafts.js b/server/glyphs/drafts.js
iindex d84e152..fc421df 100644
---- a/server/glyphs/drafts.js
++++ b/server/glyphs/drafts.js
@@@ -39,7 +39,8 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() } }) {
prompt: `The icon: "${n}".${what ? ` What it stands for: ${what}` : ''}`,
maxTokens: 1800,
target: { service: 'design', type: 'glyph_draft', id: n },
-- prefer: /^claude-opus-5-5\b/, // Claude first on openvibe.design (L37)
++ prefer: { provider: 'anthropic', model: 'claude-opus-5-5' }, // Claude first, soft, on the person's key (L37)
++ person: subject,
});
let list = [];
try {
ddiff --git a/test/claude-first.test.js b/test/claude-first.test.js
iindex e508d6a..90e2e53 100644
---- a/test/claude-first.test.js
++++ b/test/claude-first.test.js
@@@ -1,68 +1,191 @@
'use strict';
--// openvibe.design is Claude-first (plan L37, 2026-10-31): a design's three takes are Claude Opus 5.5, Claude Sonnet 5.5
--// and Opus again when OpenVibe.AI offers them; a missing one is filled by the Arena's next pick, and no Claude at all
--// is said in the answer. "Compare models" is the Arena's three providers. The create form offers both, Claude checked.
++// openvibe.design is Claude-first (plan L37, 2026-10-31) as a soft preference, not a pinned route: a signed-in
++// person's own key is tried first, and a design's three takes are three takes of the chosen model. `claude` (the
++// default) prefers Claude Opus 5.5, `sonnet` prefers Claude Sonnet 5.5, `any` prefers nothing; `compare` is the one
++// mode that really asks for different providers and keeps the Arena's pinned three. The run's explain says which key
++// paid and whether the preference was honoured, and the design's variants carry that answer to the page.
const assert = require('node:assert/strict');
const { createArenaAi } = require('../server/arena/ai');
const { boot, check, done } = require('./helpers/boot');
++const OPUS = { provider: 'anthropic', model: 'claude-opus-5-5' };
++const SONNET = { provider: 'anthropic', model: 'claude-sonnet-5-5' };
++const ORIGIN = 'https://openvibe.design';
++const respond = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
++
const clientOf = (models) => createArenaAi({
config: { ai: { url: 'https://ai.test', models, waitMs: 1000 }, oauth: { clientId: 'design', clientSecret: 'x' }, networkUrl: 'https://network.test' },
fetchImpl: async () => { throw new Error('no network in this test'); },
log: { warn() {}, info() {}, log() {}, error() {} },
});
++const settle = async (t, id, opts = {}) => {
++ for (let i = 0; i < 100; i++) {
++ const r = await t.get(`/api/v1/designs/${id}`, opts);
++ if (r.status !== 200) throw new Error(`read ${r.status}: ${r.text}`);
++ const body = r.json();
++ if (body.design.status !== 'running') return body;
++ await new Promise((resolve) => setTimeout(resolve, 10));
++ }
++ throw new Error('the design did not settle');
++};
++
(async () => {
-- await check('Claude first: Opus 5.5, Sonnet 5.5, then Opus again', async () => {
-- const plan = await clientOf(['openai:gpt-5.5', 'anthropic:claude-sonnet-5-5', 'deepseek:deepseek-chat', 'anthropic:claude-opus-5-5']).designPlan();
-- assert.deepEqual(plan.slots, [
-- { provider: 'anthropic', model: 'claude-opus-5-5' },
-- { provider: 'anthropic', model: 'claude-sonnet-5-5' },
-- { provider: 'anthropic', model: 'claude-opus-5-5' },
-- ]);
++ await check('claude: three Opus 5.5 takes, a soft preference and no pin', async () => {
++ const plan = await clientOf(['openai:gpt-5.5', 'anthropic:claude-opus-5-5', 'deepseek:deepseek-chat']).designPlan();
++ assert.deepEqual(plan.slots, [OPUS, OPUS, OPUS]);
++ assert.deepEqual(plan.prefer, OPUS);
assert.equal(plan.who, 'claude');
assert.equal(plan.claude, true);
++ assert.equal(plan.pin, false);
++ });
++
++ await check('sonnet: three Sonnet 5.5 takes, preferred', async () => {
++ const plan = await clientOf(['openai:gpt-5.5', 'anthropic:claude-sonnet-5-5']).designPlan('sonnet');
++ assert.deepEqual(plan.slots, [SONNET, SONNET, SONNET]);
++ assert.deepEqual(plan.prefer, SONNET);
++ assert.equal(plan.who, 'sonnet');
++ assert.equal(plan.pin, false);
});
-- await check('a Claude model AI does not offer is filled by the Arena\'s next pick', async () => {
-- const plan = await clientOf(['openai:gpt-5.5', 'anthropic:claude-opus-5-5', 'deepseek:deepseek-chat']).designPlan('claude');
-- assert.deepEqual(plan.slots.map((s) => s.model), ['claude-opus-5-5', 'claude-opus-5-5', 'gpt-5.5']);
++ await check('any: the Arena\'s picks and no preference', async () => {
++ const plan = await clientOf(['openai:gpt-5.5', 'deepseek:deepseek-chat']).designPlan('any');
++ assert.equal(plan.who, 'any');
++ assert.equal(plan.prefer, null);
++ assert.equal(plan.pin, false);
++ assert.deepEqual(plan.slots.map((s) => s.model), ['gpt-5.5', 'deepseek-chat', null]);
});
-- await check('no Claude offered: the Arena\'s picks, and the answer says so', async () => {
-- const plan = await clientOf(['openai:gpt-5.5', 'deepseek:deepseek-chat']).designPlan();
++ await check('a model AI does not offer keeps the Arena\'s next picks and says no Claude', async () => {
++ const plan = await clientOf(['openai:gpt-5.5', 'deepseek:deepseek-chat']).designPlan('claude');
assert.equal(plan.claude, false);
++ assert.deepEqual(plan.prefer, OPUS);
assert.deepEqual(plan.slots, [{ provider: 'openai', model: 'gpt-5.5' }, { provider: 'deepseek', model: 'deepseek-chat' }, { provider: 'ai', model: null }]);
assert.equal(plan.pool_fallback, true);
});
-- await check('compare: one per provider, as the Arena asks', async () => {
++ await check('compare: one per provider, pinned', async () => {
const plan = await clientOf(['anthropic:claude-opus-5-5', 'anthropic:claude-sonnet-5-5', 'openai:gpt-5.5', 'deepseek:deepseek-chat']).designPlan('compare');
assert.equal(plan.who, 'compare');
assert.deepEqual(plan.slots.map((s) => s.provider), ['anthropic', 'openai', 'deepseek']);
++ assert.equal(plan.prefer, null);
++ assert.equal(plan.pin, true);
});
-- const asked = [];
++ const plans = [], generates = [];
++ const planFor = (who) => {
++ const prefer = who === 'sonnet' ? SONNET : who === 'any' || who === 'compare' ? null : OPUS;
++ const slot = prefer || { provider: 'openai', model: 'gpt-5.5' };
++ return { slots: [slot, slot, slot], prefer, pin: who === 'compare',
++ who: who === 'sonnet' ? 'sonnet' : who === 'any' ? 'any' : who === 'compare' ? 'compare' : 'claude',
++ claude: prefer ? true : null };
++ };
const ai = {
-- designPlan: async (who) => { asked.push(who); return { slots: [{ provider: 'anthropic', model: 'claude-opus-5-5' }, { provider: 'anthropic', model: 'claude-sonnet-5-5' }, { provider: 'anthropic', model: 'claude-opus-5-5' }] }; },
-- generate: () => new Promise(() => {}),
++ designPlan: async (who) => { plans.push(who); return planFor(who || 'claude'); },
++ generate: async (args) => {
++ generates.push(args);
++ const prefer = args.prefer || null;
++ return { html: '<!doctype html><html><body><h1>ok</h1></body></html>', model: prefer ? prefer.model : 'gpt-5.5',
++ provider: prefer ? prefer.provider : 'openai', cost_usd: 0, ms: 1, tokens: 1,
++ paid_by: args.person ? 'your anthropic key …-key' : 'openvibe',
++ prefer: prefer ? { ...prefer, honoured: true, why: args.person ? 'your own anthropic key answered, as you asked' : 'the network offer answered' } : null };
++ },
};
const t = await boot({ ai });
++ const create = (as, body) => t.get('/api/v1/designs', { ...(as ? { as } : {}), json: body, origin: ORIGIN });
try {
-- await check('the create form offers Claude Opus 5.5 (checked) or a comparison, and passes the choice on', async () => {
-- const page = await t.get('/create');
-- assert.ok(page.text.includes('<input type="radio" name="who" value="claude" checked>') && page.text.includes('value="compare"'));
++ await check('a signed-in create asks for Opus as a preference and acts as the person', async () => {
const ana = t.network.addUser('ana');
-- const form = (who) => ({ method: 'POST', as: ana, body: `prompt=A+coffee+roaster&who=${who}`, headers: { 'content-type': 'application/x-www-form-urlencoded' }, origin: 'https://openvibe.design' });
-- await t.get('/create', form('compare'));
-- await t.get('/create', form('claude'));
-- await t.get('/create', form('anything'));
-- assert.deepEqual(asked, ['compare', 'claude', 'claude'], 'anything else is Claude');
-- const api = await t.get('/api/v1/designs', { as: ana, json: { prompt: 'Pricing section', kind: 'page' }, origin: 'https://openvibe.design' });
-- assert.equal(api.status, 201);
-- assert.equal(asked[asked.length - 1], 'claude', 'the API defaults to Claude too');
-- const read = await t.get(`/api/v1/designs/${api.json().design.id}`, { as: ana });
-- assert.deepEqual(read.json().variants.map((v) => v.model), ['claude-opus-5-5', 'claude-sonnet-5-5', 'claude-opus-5-5']);
++ const r = await create(ana, { prompt: 'A coffee roaster pricing page', kind: 'page' });
++ assert.equal(r.status, 201, r.text);
++ const data = await settle(t, r.json().design.id, { as: ana });
++ const asked = generates.filter((g) => g.roundId === data.design.id);
++ assert.equal(asked.length, 3);
++ for (const g of asked) {
++ assert.deepEqual(g.prefer, OPUS);
++ assert.equal(g.person, ana.subject);
++ assert.equal(g.pin, false, 'a preference never pins a route');
++ }
++ assert.ok(data.variants.every((v) => v.status === 'done'));
++ assert.equal(data.variants[0].paid_by, 'your anthropic key …-key');
++ assert.equal(data.variants[0].prefer.honoured, true);
++ assert.equal(data.variants[0].prefer.provider, 'anthropic');
++ assert.ok(!('pin' in data.variants[0]), 'the routing plan is not part of the frame');
++ });
++
++ await check('a signed-out create asks for Opus but acts as Design itself', async () => {
++ const r = await create(null, { prompt: 'A guest pricing page', kind: 'page' });
++ assert.equal(r.status, 201, r.text);
++ const { design, editKey } = r.json();
++ assert.ok(editKey);
++ const data = await settle(t, design.id, { headers: { 'x-design-key': editKey } });
++ const asked = generates.filter((g) => g.roundId === design.id);
++ assert.equal(asked.length, 3);
++ assert.deepEqual(asked[0].prefer, OPUS);
++ assert.equal(asked[0].person, null, 'no person, so Design runs as itself');
++ assert.equal(asked[0].pin, false);
++ assert.equal(data.variants[0].paid_by, 'openvibe');
++ });
++
++ await check('who=sonnet asks for Sonnet; who=any asks for nothing', async () => {
++ const ana = t.network.addUser('ana2');
++ const sonnet = await create(ana, { prompt: 'A sonnet page', kind: 'page', who: 'sonnet' });
++ assert.equal(sonnet.status, 201, sonnet.text);
++ const sData = await settle(t, sonnet.json().design.id, { as: ana });
++ const sAsk = generates.filter((g) => g.roundId === sData.design.id);
++ assert.deepEqual(sAsk[0].prefer, SONNET);
++ assert.equal(sAsk[0].person, ana.subject);
++
++ const any = await create(ana, { prompt: 'Any model page', kind: 'page', who: 'any' });
++ assert.equal(any.status, 201, any.text);
++ const aData = await settle(t, any.json().design.id, { as: ana });
++ const aAsk = generates.filter((g) => g.roundId === aData.design.id);
++ assert.equal(aAsk[0].prefer, null);
++ assert.equal(aAsk[0].pin, false);
++ });
++
++ await check('who=compare is the one pinned mode', async () => {
++ const ana = t.network.addUser('ana3');
++ const r = await create(ana, { prompt: 'Three providers page', kind: 'page', who: 'compare' });
++ assert.equal(r.status, 201, r.text);
++ const data = await settle(t, r.json().design.id, { as: ana });
++ const asked = generates.filter((g) => g.roundId === data.design.id);
++ assert.equal(asked.length, 3);
++ assert.ok(asked.every((g) => g.pin === true && g.prefer === null));
++ assert.deepEqual(plans, ['claude', 'claude', 'sonnet', 'any', 'compare']);
++ });
++
++ await check('the run request carries prefer and the person, no route, and returns paid_by', async () => {
++ const seen = [];
++ const http = createArenaAi({
++ config: { ai: { url: 'https://ai.test', models: ['anthropic:claude-opus-5-5'], waitMs: 1000, pollMs: 1, timeoutMs: 5000 },
++ oauth: { clientId: 'design', clientSecret: 'x' }, networkUrl: 'https://network.test', networkInternalUrl: 'https://network.test' },
++ fetchImpl: async (url, init = {}) => {
++ if (String(url).endsWith('/oauth/token')) return respond({ access_token: 'test-token', expires_in: 3600 });
++ seen.push({ url: String(url), body: init.body ? JSON.parse(init.body) : null });
++ return respond({ run: { id: 'run_test', status: 'succeeded',
++ output: { text: '<!doctype html><html><body><h1>ok</h1></body></html>' },
++ usage: { tokens_in: 3, tokens_out: 4, cost_usd: 0 },
++ provenance: { provider: 'byo:self:usr_x:anthropic', model: 'claude-opus-5-5' },
++ explain: { paid_by: 'your anthropic key …-key', prefer: { provider: 'anthropic', model: 'claude-opus-5-5', honoured: true, why: 'your own anthropic key answered, as you asked' } } } });
++ },
++ log: { warn() {}, info() {}, log() {}, error() {} },
++ });
++ const out = await http.generate({ prompt: 'A page', category: 'landing', slot: OPUS, roundId: 'dsn_test', index: 0, prefer: OPUS, person: 'usr_x', pin: false });
++ const run = seen.find((x) => x.url.includes('/api/v1/runs'));
++ assert.ok(run, 'the run API is used');
++ assert.equal(run.body.workflow, 'ai.chat', 'the step that honours prefer');
++ assert.deepEqual(run.body.prefer, OPUS);
++ assert.deepEqual(run.body.on_behalf_of, { type: 'user', id: 'usr_x' });
++ assert.ok(!('route' in run.body), 'no route is pinned');
++ assert.equal(out.paid_by, 'your anthropic key …-key');
++ assert.equal(out.prefer.honoured, true);
++ assert.equal(out.provider, 'anthropic', 'the key reference is not the provider name');
++
++ await http.generate({ prompt: 'A page', category: 'landing', slot: { provider: 'openai', model: 'gpt-5.5' }, roundId: 'dsn_cmp', index: 0, pin: true });
++ const pinned = seen.filter((x) => x.url.includes('/api/v1/runs')).at(-1);
++ assert.equal(pinned.body.workflow, 'ai.generate');
++ assert.deepEqual(pinned.body.route, { provider: 'openai', model: 'gpt-5.5' });
});
} finally {
await t.close();