openvibers/openvibe.design Commits
Design asks for Claude as a preference and acts for the person (L37) (#57)
Changes
@@@ -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 };@@@ -107,11 +107,14 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consolconst 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 = consolconst 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 = consolconst 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') });@@@ -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 {@@@ -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();
@@ -185,27 +185,35 @@ function createArenaAi({ config, fetchImpl = globalThis.fetch, log = console })
@@ -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
* 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') {
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();
let list = configuredModels();
if (!list.length) list = await offeredModels();
if (!list.length) list = await offeredModels();
const offers = rankOffers(list);
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;
const poolFallback = slots.length < 3;
while (slots.length < 3) slots.push({ provider: 'ai', model: null });
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) {
function systemPrompt(category) {
@@ -260,35 +268,77 @@ function createArenaAi({ config, fetchImpl = globalThis.fetch, log = console })
@@ -260,35 +268,77 @@ function createArenaAi({ config, fetchImpl = globalThis.fetch, log = console })
return { run: r, ms: msOf(r, Date.now() - startedAt) };
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.');
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.`,
prompt: `${prompt}\n\nCategory: ${category}\nReturn one complete HTML document.`,
system: systemPrompt(category),
system: systemPrompt(category),
max_tokens: 8000,
max_tokens: 8000,
temperature: 0.7,
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 startedAt = Date.now();
const wait = Math.min(ai.waitMs, 60_000);
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;
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',
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 first = created && created.run ? created.run : created;
const { run, ms } = await waitFor(first, startedAt);
const { run, ms } = await waitFor(first, startedAt);
@@ -314,32 +364,34 @@ function createArenaAi({ config, fetchImpl = globalThis.fetch, log = console })
@@ -314,32 +364,34 @@ function createArenaAi({ config, fetchImpl = globalThis.fetch, log = console })
ms,
ms,
tokens: Math.max(0, Math.round(tokens)),
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),
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,
ui_check: run.ui_check || (run.output && run.output.ui_check) || null,
run_id: run.id || 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.
* 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();
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 { run } = await waitFor(created && created.run ? created.run : created, startedAt);
const prov = run.provenance || {};
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 };
return { enabled, slots, slotPlan, designPlan, offeredModels, rankOffers, generate, systemPrompt, text, CATEGORY_BRIEF };
@@ -107,11 +107,14 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
@@ -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]));
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.');
if (count >= (owner ? 10 : 2)) throw fail(429, 'design.daily_limit', 'Daily design limit reached.');
const selected = await theme(themeId);
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 slots = plan.slots.slice(0, 3);
const variants = slots.map((slot, index) => ({ id: s.newId('dva'), slot: index, provider: slot.provider,
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();
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)',
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()]);
[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
@@ -134,11 +137,13 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
const t = await theme(d.theme_id);
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 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',
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 current = await db.maybe('SELECT theme_id FROM designs WHERE id=$1', [id]);
const currentTheme = await theme(current.theme_id);
const currentTheme = await theme(current.theme_id);
await updateVariant(id, index, { ...running, status: 'done', html: wrap(out.html, currentTheme.variables || {}),
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) {
} catch (err) {
await updateVariant(id, index, { ...running, status: 'failed', error: String(err.message).slice(0, 300) });
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
@@ -277,7 +282,10 @@ function createDesigns({ s, ai, bench = null, catalog, config = {}, log = consol
const p = await pickRow(id);
const p = await pickRow(id);
const versions = p ? await db.many('SELECT * FROM design_versions WHERE design_id=$1 ORDER BY seq', [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 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) {
if (p && index === Number(p.variant_index) && current) {
return { ...v, html: wrap(current.html, selected.variables || {}), currentSeq: Number(current.seq), edited: current.kind !== 'generated' };
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
@@ -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>>>`,
`<<<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.`,
`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');
].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 html = wrap(out.html, variables);
const version = await addVersion(d, p, { kind: 'chat', html, instruction, model: out.model || variant.model,
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') });
provider: out.provider || variant.provider, costUsd: out.cost_usd, ms: out.ms, createdBy: subject || (d.owner ? 'owner' : 'guest') });
@@ -39,7 +39,8 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() } }) {
@@ -39,7 +39,8 @@ function createGlyphDrafts({ db, ai, clock = { now: () => Date.now() } }) {
prompt: `The icon: "${n}".${what ? ` What it stands for: ${what}` : ''}`,
prompt: `The icon: "${n}".${what ? ` What it stands for: ${what}` : ''}`,
maxTokens: 1800,
maxTokens: 1800,
target: { service: 'design', type: 'glyph_draft', id: n },
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 = [];
let list = [];
try {
try {
@@ -1,68 +1,191 @@
@@ -1,68 +1,191 @@
'use strict';
'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 assert = require('node:assert/strict');
const { createArenaAi } = require('../server/arena/ai');
const { createArenaAi } = require('../server/arena/ai');
const { boot, check, done } = require('./helpers/boot');
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({
const clientOf = (models) => createArenaAi({
config: { ai: { url: 'https://ai.test', models, waitMs: 1000 }, oauth: { clientId: 'design', clientSecret: 'x' }, networkUrl: 'https://network.test' },
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'); },
fetchImpl: async () => { throw new Error('no network in this test'); },
log: { warn() {}, info() {}, log() {}, error() {} },
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 () => {
(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.who, 'claude');
assert.equal(plan.claude, true);
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.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.deepEqual(plan.slots, [{ provider: 'openai', model: 'gpt-5.5' }, { provider: 'deepseek', model: 'deepseek-chat' }, { provider: 'ai', model: null }]);
assert.equal(plan.pool_fallback, true);
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');
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.equal(plan.who, 'compare');
assert.deepEqual(plan.slots.map((s) => s.provider), ['anthropic', 'openai', 'deepseek']);
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 = {
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 t = await boot({ ai });
+ const create = (as, body) => t.get('/api/v1/designs', { ...(as ? { as } : {}), json: body, origin: ORIGIN });
try {
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 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 {
} finally {
await t.close();
await t.close();