openvibers/openvibe.ai Commits

Honor personal image choices in picture task (#477)

OP OpenVibers committed 2 days ago · bc806a0d78 · 2 files, +70 −13

Changes

ddiff --git a/server/workflows/pictures.js b/server/workflows/pictures.js
iindex 5e4d813..df88d11 100644
---- a/server/workflows/pictures.js
++++ b/server/workflows/pictures.js
@@@ -229,16 +229,28 @@ function createPictures({ registry, pool, fetcher, config = {}, db = null, clock
const n = Math.max(1, Math.min(4, Number(input.n) || 1));
const subject = subjectOf(ctx);
if (!subject) throw new AiError(401, 'image.sign_in', 'Sign in to make an image');
-- // The owner's switch: until an owner picks an image model for this task, make is off. The default
-- // resolution never falls back to a signed-in model for image.picture's make mode.
-- const picked = plan && plan.image_model ? plan.image_model : null;
++ // A run, saved or per-kind choice takes precedence. The owner's image model is the default
++ // make switch; the built-in openvibe/auto never turns an unconfigured task on by accident.
++ const personalChoice = plan && !['owner', 'builtin'].includes(plan.why) ? plan.choice : null;
++ const picked = personalChoice || (plan && plan.image_model ? plan.image_model : null);
if (!picked) throw new AiError(503, 'image.make_unconfigured', 'The owner has not picked an image model for the picture task; set image_model in the image.picture task defaults.');
-- const offers = await pricedOffers(picked);
-- const chosen = offers[0];
-- const perImage = chosen.price == null ? (chosen.free ? 0 : null) : chosen.price;
-- if (perImage == null) throw new AiError(402, 'image.price_required', `the image model ${chosen.id} has no per-image price; set metadata.price_per_image_usd on the model before a paid picture can be made`);
-- const estimate = { model: chosen.id, source: 'openvibe', images: n, per_image_usd: perImage, price_usd: perImage * n };
-- if (plan.max_cost_usd != null && estimate.price_usd > plan.max_cost_usd) throw new AiError(402, 'choice.cost_ceiling', `the picture would cost about $${estimate.price_usd} and this task's per-run ceiling is $${plan.max_cost_usd}`);
++ if (picked.source === 'machines') throw new AiError(422, 'image.machine_unavailable', 'Link machines currently answer text only; choose your key or OpenVibe for an image');
++ let chosen = null; let credential = null; let perImage = null;
++ if (picked.source === 'keys') {
++ const [provider, ...parts] = String(picked.model).split('/');
++ const model = parts.join('/');
++ if (provider !== 'openai' || !model) throw new AiError(422, 'image.key_unsupported', 'image.picture supports an OpenAI image key; choose an OpenAI image model');
++ credential = (ctx.selfCredentials || []).find((c) => c.provider === provider && c.subject === subject) || null;
++ if (!credential) throw new AiError(422, 'ai.needs_key', 'add your OpenAI key on Exchange before making this image');
++ chosen = { id: picked.model, model, provider };
++ } else {
++ const offers = await pricedOffers(picked);
++ chosen = offers[0];
++ perImage = chosen.price == null ? (chosen.free ? 0 : null) : chosen.price;
++ if (perImage == null) throw new AiError(402, 'image.price_required', `the image model ${chosen.id} has no per-image price; set metadata.price_per_image_usd on the model before a paid picture can be made`);
++ }
++ const estimate = { model: chosen.id, source: picked.source, images: n, per_image_usd: perImage, price_usd: perImage == null ? null : perImage * n };
++ if (picked.source === 'openvibe' && plan.max_cost_usd != null && estimate.price_usd > plan.max_cost_usd) throw new AiError(402, 'choice.cost_ceiling', `the picture would cost about $${estimate.price_usd} and this task's per-run ceiling is $${plan.max_cost_usd}`);
const cap = plan.daily_cap == null ? pictureConfig.defaultDailyCap : Number(plan.daily_cap);
if (Number.isFinite(cap) && cap >= 0) {
const made = await madeToday(subject);
@@@ -248,15 +260,19 @@ function createPictures({ registry, pool, fetcher, config = {}, db = null, clock
return {
output: {
mode: 'make', task: 'image.picture', query: null, prompt, dry_run: true, estimate, media: [], count: 0, gaps: [],
-- note: `Dry run: ${n} image${n === 1 ? '' : 's'} with ${chosen.id} at $${perImage} each, about $${estimate.price_usd}.`,
++ note: credential ? `Dry run: ${n} image${n === 1 ? '' : 's'} with your OpenAI key (${chosen.id}); the provider's charge is not estimated here.`
++ : `Dry run: ${n} image${n === 1 ? '' : 's'} with ${chosen.id} at $${perImage} each, about $${estimate.price_usd}.`,
},
exec: { provider: null, model: chosen.model, usage: { input: 0, output: 0, cached: 0 }, cost: 0, synthetic: false, fallbackUsed: false, explain: null },
};
}
-- const route = await ((routeFor || (async () => { throw new AiError(503, 'route.unavailable', 'the image route is not available'); }))('default.image'));
++ const route = credential
++ ? (routeFor ? await routeFor('default.image').catch(() => null) : null) || { key: 'default.image', version: 0, timeout_ms: 180000 }
++ : await ((routeFor || (async () => { throw new AiError(503, 'route.unavailable', 'the image route is not available'); }))('default.image'));
const req = { prompt, size: input.size || ((config.image && config.image.size) || null), n, timeoutMs: route.timeout_ms || 180000 };
const exec = await pool.execute(route, 'image', req, {
-- ...ctx, taskChoice: null, exact: { id: chosen.id, offers: [{ provider: chosen.provider, model: chosen.model }] }, imagePriceUsd: perImage,
++ ...ctx, taskChoice: null, ...(credential ? { credential, defaultModel: chosen.model }
++ : { exact: { id: chosen.id, offers: [{ provider: chosen.provider, model: chosen.model }] }, imagePriceUsd: perImage }),
routeKey: route.key, routeVersion: route.version, promptHash: sha256({ p: prompt, n }),
});
const images = ((exec.result && exec.result.images) || []).filter((i) => i && (i.b64 || i.url));
@@@ -265,7 +281,7 @@ function createPictures({ registry, pool, fetcher, config = {}, db = null, clock
...(exec.explain || {}),
task_choice: {
task: plan.task && plan.task.name ? plan.task.name : 'image.picture',
-- requested: plan.choice, answered: { model: exec.model || chosen.model, source: 'openvibe' },
++ requested: picked, answered: { model: exec.model || chosen.model, source: picked.source },
why: plan.why || 'owner', fallback_index: 0,
},
};
ddiff --git a/test/image-picture.test.js b/test/image-picture.test.js
iindex ed33b90..f4f3e05 100644
---- a/test/image-picture.test.js
++++ b/test/image-picture.test.js
@@@ -108,6 +108,47 @@ t.test('make uploads each picture to Media and labels it made with AI', async ()
assert.strictEqual(out.exec.explain.task_choice.answered.model, 'image');
});
++t.test('a person can select their own OpenAI image key without the owner make switch', async () => {
++ const { p, calls } = pictures();
++ const personal = plan({ why: 'run', choice: { model: 'openai/gpt-image-1', source: 'keys' }, image_model: null });
++ const key = { provider: 'openai', subject: SUBJECT, providerKey: 'byo:fixture', models: { chat: 'other' } };
++ const ctx = ctxOf({ taskChoice: personal, selfCredentials: [key] });
++ const quote = await p.step({}, { mode: 'make', prompt: 'a goose', dry_run: true }, {}, {}, ctx);
++ assert.deepStrictEqual(quote.output.estimate, { model: 'openai/gpt-image-1', source: 'keys', images: 1, per_image_usd: null, price_usd: null });
++ assert.match(quote.output.note, /provider's charge is not estimated/);
++ assert.strictEqual(calls.executes.length, 0, 'a dry run does not call the personal key');
++ const made = await p.step({}, { mode: 'make', prompt: 'a goose' }, {}, {}, ctx);
++ assert.strictEqual(made.output.count, 1);
++ assert.strictEqual(calls.executes[0].ctx.credential, key);
++ assert.strictEqual(calls.executes[0].ctx.defaultModel, 'gpt-image-1');
++ assert.strictEqual(calls.executes[0].ctx.exact, undefined, 'the personal key is not a network offer');
++ assert.strictEqual(made.exec.explain.task_choice.answered.source, 'keys');
++});
++
++t.test('an unavailable Link image choice is refused instead of silently using the owner model', async () => {
++ const { p, calls } = pictures();
++ const selected = plan({ why: 'saved', choice: { model: 'local/image', source: 'machines', machine: 'any' }, image_model: { model: 'fixture/image', source: 'openvibe' } });
++ await assert.rejects(() => p.step({}, { mode: 'make', prompt: 'a goose' }, {}, {}, ctxOf({ taskChoice: selected })),
++ (e) => e.status === 422 && e.code === 'image.machine_unavailable');
++ assert.strictEqual(calls.executes.length, 0);
++});
++
++t.test('the personal OpenAI credential reaches the image provider path', async () => {
++ const up = await seamServer((_body, req) => req.url.endsWith('/images/generations')
++ ? { data: [{ b64_json: PNG.toString('base64') }] } : { status: 404, body: {} });
++ const h = await boot({ env: { AI_STUB_FALLBACK: 'false' }, credentialFetch: fetch });
++ try {
++ const route = { key: 'default.image', version: 0, timeout_ms: 10000 };
++ const credential = { provider: 'openai', providerKey: 'byo:test:image', owner: 'self', subject: SUBJECT,
++ base_url: `${up.url}/v1`, apiKey: 'test-only', models: { chat: 'gpt-image-1' } };
++ const result = await h.pool.execute(route, 'image', { prompt: 'a goose', n: 1, timeoutMs: 5000 },
++ { credential, defaultModel: 'gpt-image-1', routeKey: route.key, routeVersion: route.version });
++ assert.strictEqual(result.result.images.length, 1);
++ assert.strictEqual(result.explain.openvibe.provider_access, 'CUSTOMER_BYOK');
++ assert.strictEqual(up.calls, 1);
++ } finally { await h.stop(); await up.close(); }
++});
++
t.test('a paid offer without a per-image price is refused, and the daily cap holds', async () => {
const unpriced = pictures({ price: null });
await assert.rejects(() => unpriced.p.step({}, { mode: 'make', prompt: 'x' }, {}, {}, ctxOf({ taskChoice: plan({ image_model: { model: 'fixture/image', source: 'openvibe' } }) })), (e) => e.status === 402 && e.code === 'image.price_required');