openvibers/openvibe.exchange Commits
Free is said only when it is free now (#94)
Changes
@@@ -154,9 +154,12 @@ function createPageRoutes(ctx) {async function chatCatalog() {const catalog = await catalogOf();const notChat = new Set(['embedding', 'embeddings', 'stt', 'asr', 'tts', 'speech', 'image', 'images', 'rerank', 'moderation']);-- return { catalog, models: (catalog.models || []).filter((m) => !notChat.has(String(m.kind || m.type || 'chat').toLowerCase())) };++ const models = (catalog.models || []).filter((m) => !notChat.has(String(m.kind || m.type || 'chat').toLowerCase()));++ // AI says when no chat model can serve the free allowance right now (free_available false).++ models.freePaused = catalog.free_available === false;++ return { catalog, models };}-- const modelOptions = (models, selected) => models.slice(0, 40).map((m) => [m.slug, m.name + (m.uncensored ? ' · 18+' : '') + ((m.offers || []).some((o) => o.free_tier === true) ? ' · Free to try' : ''), (selected || []).includes(m.slug)]);++ const modelOptions = (models, selected) => models.slice(0, 40).map((m) => [m.slug, m.name + (m.uncensored ? ' · 18+' : '') + ((m.offers || []).some((o) => (o.free_now !== undefined ? o.free_now === true : o.free_tier === true)) ? ' · Free to try' : ''), (selected || []).includes(m.slug)]);function chatSidebar(chats, current) {const items = chats.map((c) => ({ href: `/chat/${c.id}`, title: c.title, current: c.id === current, at: c.updated_at }));@@@ -193,7 +196,9 @@ ${kit.chips({ name: 'models', label: 'Compare up to three models', options: modereturn thread.composer({action, name: 'text', placeholder: 'Ask anything…', top: settings, maxlength: config.chat.maxChars,hidden: { chat: chat ? chat.id : '' },-- note: `Your free daily allowance. Models marked Free to try run on it; other models need your own balance or key. Each message may run up to ${config.chat.models} models.`,++ note: models.freePaused++ ? `Free messages are paused right now: the free model\u2019s provider is out of credit. Your own key or Balance still works. Each message may run up to ${config.chat.models} models.`++ : `Your free daily allowance. Models marked Free to try run on it; other models need your own balance or key. Each message may run up to ${config.chat.models} models.`,starters: chat ? [] : ['Explain the difference between a mutex and a semaphore', 'Write a Python function that reads a CSV and sums a column', 'Which of you is cheaper for this?'],});}@@@ -98,13 +98,17 @@ function answerPrivacy(result) {return { label: name, kind, facts: facts.join(' · ') || (d.known === false ? 'the offer declared no policy' : '') };}--const freeTier = (o) => Boolean(o && o.free_tier === true);++// Free to try now: AI's free_now (a free-tier offer whose provider can answer); free_tier alone on an older AI.++const freeTier = (o) => Boolean(o && (o.free_now !== undefined ? o.free_now === true : o.free_tier === true));++// A free-tier offer whose provider cannot answer right now (out of credit, or down): said, never promised.++const freePaused = (o) => Boolean(o && o.free_tier === true && o.free_now === false);++const FREE_PAUSED = 'Free messages are paused right now: the free model\u2019s provider is out of credit. Your own key or Balance still works.';function badgesOf(m, best) {const out = [m.uncensored ? kit.badge('18+', 'bad') : '',m.community ? kit.badge('Community', 'accent') : '',-- (m.offers || []).some(freeTier) ? kit.badge('Free to try') : '',++ (m.offers || []).some(freeTier) ? kit.badge('Free to try') : ((m.offers || []).some(freePaused) ? kit.badge('Free paused') : ''),m.capabilities && m.capabilities.tools ? kit.badge('Tools') : '',];const p = privacyOf(best);@@@ -158,7 +162,8 @@ function home({ catalog, usage, viewer, q = '', config = null }) {if (models.length) stats.push([models.length, 'models']);if (providerCount(models)) stats.push([providerCount(models), 'providers']);if (bestPrice) stats.push([money(bestPrice.price.in), 'cheapest in, per million']);-- if (config) stats.push([config.chat.perDay, 'messages free every day']);++ // The free allowance is a stat only while some free model can answer it.++ if (config && catalog.free_available !== false) stats.push([config.chat.perDay, 'messages free every day']);if (usage.today && usage.today.requests >= 1000) stats.push([count(usage.today.requests), 'requests today']);return `<div class="ex-wrap ex-home">@@@ -280,7 +285,7 @@ ${panel({ title: '', body: modelList(models, { empty: 'No model matches that seafunction modelPage({ model, viewer, result = null, problem = null, text = '', adult = false }) {const offers = model.offers || [];const best = cheapest(model);-- const badges = [model.uncensored ? kit.badge('18+', 'bad') : '', model.community ? kit.badge('Community', 'accent') : '', offers.some(freeTier) ? kit.badge('Free to try') : ''].join('');++ const badges = [model.uncensored ? kit.badge('18+', 'bad') : '', model.community ? kit.badge('Community', 'accent') : '', offers.some(freeTier) ? kit.badge('Free to try') : (offers.some(freePaused) ? kit.badge('Free paused') : '')].join('');const stats = [best ? [money(best.price.in), 'cheapest in, per M'] : null,best ? [money(best.price.out), 'cheapest out, per M'] : null,@@@ -301,7 +306,7 @@ function modelPage({ model, viewer, result = null, problem = null, text = '', adconst rows = offers.map((o) => {const p = privacyOf(o);return {-- provider: `<a class="ex-offer-name" href="#try">${esc(providerLabel(o))}</a>${o.trust === 'community' ? ` ${kit.badge('Community', 'accent')}` : ''}${freeTier(o) ? ` ${kit.badge('Free to try')}` : ''}`,++ provider: `<a class="ex-offer-name" href="#try">${esc(providerLabel(o))}</a>${o.trust === 'community' ? ` ${kit.badge('Community', 'accent')}` : ''}${freeTier(o) ? ` ${kit.badge('Free to try')}` : (freePaused(o) ? ` ${kit.badge('Free paused')}` : '')}`,price: o.price ? `<b>${money(o.price.in)}</b> / ${money(o.price.out)}` : '<span class="ex-muted">free capacity</span>',speed: `${ms(o.speed.p50_ms)} <small>p50</small> · ${ms(o.speed.p95_ms)} <small>p95</small>`,uptime: o.uptime == null ? '–' : pct(o.uptime),@@@ -385,8 +390,9 @@ function tryBox(model, viewer, { result, problem, text, adult }) {if (!canTry) {return `<div class="ex-signin">${kit.button({ href: `/auth/login?next=${encodeURIComponent(hrefOf(model))}`, label: 'Sign in to try it' })}<span class="ex-muted">Free daily allowance, no card.</span></div>`;}++ const paused = (model.offers || []).some(freePaused) && !(model.offers || []).some(freeTier);return `<form class="ovk-form ex-try" method="post" action="${hrefOf(model)}/try">--${problem ? kit.notice({ kind: 'error', text: problem }) : ''}++${problem ? kit.notice({ kind: 'error', text: problem }) : ''}${paused ? kit.notice({ kind: 'info', text: FREE_PAUSED }) : ''}<label class="ex-label" for="try-text">Try ${esc(model.name)}</label><textarea id="try-text" name="text" rows="4" maxlength="4000" required placeholder="Ask it anything. The answer is one run on your own allowance.${model.uncensored ? ' The floor applies.' : ''}">${esc(text)}</textarea><div class="ex-try-row">${kit.button({ label: 'Send', type: 'submit' })}<span class="ex-muted">Free daily allowance; the run’s cost is shown with the answer.</span></div>@@@ -168,6 +168,26 @@ const { createFakeAi, downAi } = require('./helpers/ai');});} finally { await o.close(); }++ // The free model's provider is out of credit: AI marks its free-tier offer not free now (free_now false) and++ // says no chat model can serve the free allowance (free_available false). Nothing promises free capacity.++ const pausedAi = createFakeAi();++ pausedAi.catalogData.free_available = false;++ for (const m of pausedAi.catalogData.models) for (const o of m.offers || []) if (o.free_tier) { o.free_now = false; o.status = 'exhausted'; }++ const paused = await boot({ ai: pausedAi });++ try {++ await check('free paused: no Free to try, a plain note, and no free-messages stat', async () => {++ const home = await paused.get('/');++ assert.strictEqual(home.status, 200);++ assert.ok(!home.text.includes('Free to try'), 'no offer is promised as free');++ assert.ok(!home.text.includes('messages free every day'), 'the free allowance is not a stat while paused');++ const models = await paused.get('/models');++ assert.ok(models.text.includes('Free paused'), 'the paused offer says so');++ const freeModel = pausedAi.catalogData.models.find((m) => (m.offers || []).some((o) => o.free_tier));++ const page = await paused.get('/m/' + freeModel.slug);++ assert.ok(page.text.includes('Free paused'));++ });++ } finally { await paused.close(); }++const broken = await boot({ ai: createFakeAi(), openapiFetch: async () => { throw new Error('down'); } });try {await check('/openapi.json still answers when the API host is down', async () => {
@@ -154,9 +154,12 @@ function createPageRoutes(ctx) {
@@ -154,9 +154,12 @@ function createPageRoutes(ctx) {
async function chatCatalog() {
async function chatCatalog() {
const catalog = await catalogOf();
const catalog = await catalogOf();
const notChat = new Set(['embedding', 'embeddings', 'stt', 'asr', 'tts', 'speech', 'image', 'images', 'rerank', 'moderation']);
const notChat = new Set(['embedding', 'embeddings', 'stt', 'asr', 'tts', 'speech', 'image', 'images', 'rerank', 'moderation']);
- return { catalog, models: (catalog.models || []).filter((m) => !notChat.has(String(m.kind || m.type || 'chat').toLowerCase())) };
+ const models = (catalog.models || []).filter((m) => !notChat.has(String(m.kind || m.type || 'chat').toLowerCase()));
+ // AI says when no chat model can serve the free allowance right now (free_available false).
+ models.freePaused = catalog.free_available === false;
+ return { catalog, models };
}
}
- const modelOptions = (models, selected) => models.slice(0, 40).map((m) => [m.slug, m.name + (m.uncensored ? ' · 18+' : '') + ((m.offers || []).some((o) => o.free_tier === true) ? ' · Free to try' : ''), (selected || []).includes(m.slug)]);
+ const modelOptions = (models, selected) => models.slice(0, 40).map((m) => [m.slug, m.name + (m.uncensored ? ' · 18+' : '') + ((m.offers || []).some((o) => (o.free_now !== undefined ? o.free_now === true : o.free_tier === true)) ? ' · Free to try' : ''), (selected || []).includes(m.slug)]);
function chatSidebar(chats, current) {
function chatSidebar(chats, current) {
const items = chats.map((c) => ({ href: `/chat/${c.id}`, title: c.title, current: c.id === current, at: c.updated_at }));
const items = chats.map((c) => ({ href: `/chat/${c.id}`, title: c.title, current: c.id === current, at: c.updated_at }));
@@ -193,7 +196,9 @@ ${kit.chips({ name: 'models', label: 'Compare up to three models', options: mode
@@ -193,7 +196,9 @@ ${kit.chips({ name: 'models', label: 'Compare up to three models', options: mode
return thread.composer({
return thread.composer({
action, name: 'text', placeholder: 'Ask anything…', top: settings, maxlength: config.chat.maxChars,
action, name: 'text', placeholder: 'Ask anything…', top: settings, maxlength: config.chat.maxChars,
hidden: { chat: chat ? chat.id : '' },
hidden: { chat: chat ? chat.id : '' },
- note: `Your free daily allowance. Models marked Free to try run on it; other models need your own balance or key. Each message may run up to ${config.chat.models} models.`,
+ note: models.freePaused
+ ? `Free messages are paused right now: the free model\u2019s provider is out of credit. Your own key or Balance still works. Each message may run up to ${config.chat.models} models.`
+ : `Your free daily allowance. Models marked Free to try run on it; other models need your own balance or key. Each message may run up to ${config.chat.models} models.`,
starters: chat ? [] : ['Explain the difference between a mutex and a semaphore', 'Write a Python function that reads a CSV and sums a column', 'Which of you is cheaper for this?'],
starters: chat ? [] : ['Explain the difference between a mutex and a semaphore', 'Write a Python function that reads a CSV and sums a column', 'Which of you is cheaper for this?'],
});
});
}
}
@@ -98,13 +98,17 @@ function answerPrivacy(result) {
@@ -98,13 +98,17 @@ function answerPrivacy(result) {
return { label: name, kind, facts: facts.join(' · ') || (d.known === false ? 'the offer declared no policy' : '') };
return { label: name, kind, facts: facts.join(' · ') || (d.known === false ? 'the offer declared no policy' : '') };
}
}
-const freeTier = (o) => Boolean(o && o.free_tier === true);
+// Free to try now: AI's free_now (a free-tier offer whose provider can answer); free_tier alone on an older AI.
+const freeTier = (o) => Boolean(o && (o.free_now !== undefined ? o.free_now === true : o.free_tier === true));
+// A free-tier offer whose provider cannot answer right now (out of credit, or down): said, never promised.
+const freePaused = (o) => Boolean(o && o.free_tier === true && o.free_now === false);
+const FREE_PAUSED = 'Free messages are paused right now: the free model\u2019s provider is out of credit. Your own key or Balance still works.';
function badgesOf(m, best) {
function badgesOf(m, best) {
const out = [
const out = [
m.uncensored ? kit.badge('18+', 'bad') : '',
m.uncensored ? kit.badge('18+', 'bad') : '',
m.community ? kit.badge('Community', 'accent') : '',
m.community ? kit.badge('Community', 'accent') : '',
- (m.offers || []).some(freeTier) ? kit.badge('Free to try') : '',
+ (m.offers || []).some(freeTier) ? kit.badge('Free to try') : ((m.offers || []).some(freePaused) ? kit.badge('Free paused') : ''),
m.capabilities && m.capabilities.tools ? kit.badge('Tools') : '',
m.capabilities && m.capabilities.tools ? kit.badge('Tools') : '',
];
];
const p = privacyOf(best);
const p = privacyOf(best);
@@ -158,7 +162,8 @@ function home({ catalog, usage, viewer, q = '', config = null }) {
@@ -158,7 +162,8 @@ function home({ catalog, usage, viewer, q = '', config = null }) {
if (models.length) stats.push([models.length, 'models']);
if (models.length) stats.push([models.length, 'models']);
if (providerCount(models)) stats.push([providerCount(models), 'providers']);
if (providerCount(models)) stats.push([providerCount(models), 'providers']);
if (bestPrice) stats.push([money(bestPrice.price.in), 'cheapest in, per million']);
if (bestPrice) stats.push([money(bestPrice.price.in), 'cheapest in, per million']);
- if (config) stats.push([config.chat.perDay, 'messages free every day']);
+ // The free allowance is a stat only while some free model can answer it.
+ if (config && catalog.free_available !== false) stats.push([config.chat.perDay, 'messages free every day']);
if (usage.today && usage.today.requests >= 1000) stats.push([count(usage.today.requests), 'requests today']);
if (usage.today && usage.today.requests >= 1000) stats.push([count(usage.today.requests), 'requests today']);
return `<div class="ex-wrap ex-home">
return `<div class="ex-wrap ex-home">
@@ -280,7 +285,7 @@ ${panel({ title: '', body: modelList(models, { empty: 'No model matches that sea
@@ -280,7 +285,7 @@ ${panel({ title: '', body: modelList(models, { empty: 'No model matches that sea
function modelPage({ model, viewer, result = null, problem = null, text = '', adult = false }) {
function modelPage({ model, viewer, result = null, problem = null, text = '', adult = false }) {
const offers = model.offers || [];
const offers = model.offers || [];
const best = cheapest(model);
const best = cheapest(model);
- const badges = [model.uncensored ? kit.badge('18+', 'bad') : '', model.community ? kit.badge('Community', 'accent') : '', offers.some(freeTier) ? kit.badge('Free to try') : ''].join('');
+ const badges = [model.uncensored ? kit.badge('18+', 'bad') : '', model.community ? kit.badge('Community', 'accent') : '', offers.some(freeTier) ? kit.badge('Free to try') : (offers.some(freePaused) ? kit.badge('Free paused') : '')].join('');
const stats = [
const stats = [
best ? [money(best.price.in), 'cheapest in, per M'] : null,
best ? [money(best.price.in), 'cheapest in, per M'] : null,
best ? [money(best.price.out), 'cheapest out, per M'] : null,
best ? [money(best.price.out), 'cheapest out, per M'] : null,
@@ -301,7 +306,7 @@ function modelPage({ model, viewer, result = null, problem = null, text = '', ad
@@ -301,7 +306,7 @@ function modelPage({ model, viewer, result = null, problem = null, text = '', ad
const rows = offers.map((o) => {
const rows = offers.map((o) => {
const p = privacyOf(o);
const p = privacyOf(o);
return {
return {
- provider: `<a class="ex-offer-name" href="#try">${esc(providerLabel(o))}</a>${o.trust === 'community' ? ` ${kit.badge('Community', 'accent')}` : ''}${freeTier(o) ? ` ${kit.badge('Free to try')}` : ''}`,
+ provider: `<a class="ex-offer-name" href="#try">${esc(providerLabel(o))}</a>${o.trust === 'community' ? ` ${kit.badge('Community', 'accent')}` : ''}${freeTier(o) ? ` ${kit.badge('Free to try')}` : (freePaused(o) ? ` ${kit.badge('Free paused')}` : '')}`,
price: o.price ? `<b>${money(o.price.in)}</b> / ${money(o.price.out)}` : '<span class="ex-muted">free capacity</span>',
price: o.price ? `<b>${money(o.price.in)}</b> / ${money(o.price.out)}` : '<span class="ex-muted">free capacity</span>',
speed: `${ms(o.speed.p50_ms)} <small>p50</small> · ${ms(o.speed.p95_ms)} <small>p95</small>`,
speed: `${ms(o.speed.p50_ms)} <small>p50</small> · ${ms(o.speed.p95_ms)} <small>p95</small>`,
uptime: o.uptime == null ? '–' : pct(o.uptime),
uptime: o.uptime == null ? '–' : pct(o.uptime),
@@ -385,8 +390,9 @@ function tryBox(model, viewer, { result, problem, text, adult }) {
@@ -385,8 +390,9 @@ function tryBox(model, viewer, { result, problem, text, adult }) {
if (!canTry) {
if (!canTry) {
return `<div class="ex-signin">${kit.button({ href: `/auth/login?next=${encodeURIComponent(hrefOf(model))}`, label: 'Sign in to try it' })}<span class="ex-muted">Free daily allowance, no card.</span></div>`;
return `<div class="ex-signin">${kit.button({ href: `/auth/login?next=${encodeURIComponent(hrefOf(model))}`, label: 'Sign in to try it' })}<span class="ex-muted">Free daily allowance, no card.</span></div>`;
}
}
+ const paused = (model.offers || []).some(freePaused) && !(model.offers || []).some(freeTier);
return `<form class="ovk-form ex-try" method="post" action="${hrefOf(model)}/try">
return `<form class="ovk-form ex-try" method="post" action="${hrefOf(model)}/try">
-${problem ? kit.notice({ kind: 'error', text: problem }) : ''}
+${problem ? kit.notice({ kind: 'error', text: problem }) : ''}${paused ? kit.notice({ kind: 'info', text: FREE_PAUSED }) : ''}
<label class="ex-label" for="try-text">Try ${esc(model.name)}</label>
<label class="ex-label" for="try-text">Try ${esc(model.name)}</label>
<textarea id="try-text" name="text" rows="4" maxlength="4000" required placeholder="Ask it anything. The answer is one run on your own allowance.${model.uncensored ? ' The floor applies.' : ''}">${esc(text)}</textarea>
<textarea id="try-text" name="text" rows="4" maxlength="4000" required placeholder="Ask it anything. The answer is one run on your own allowance.${model.uncensored ? ' The floor applies.' : ''}">${esc(text)}</textarea>
<div class="ex-try-row">${kit.button({ label: 'Send', type: 'submit' })}<span class="ex-muted">Free daily allowance; the run’s cost is shown with the answer.</span></div>
<div class="ex-try-row">${kit.button({ label: 'Send', type: 'submit' })}<span class="ex-muted">Free daily allowance; the run’s cost is shown with the answer.</span></div>
@@ -168,6 +168,26 @@ const { createFakeAi, downAi } = require('./helpers/ai');
@@ -168,6 +168,26 @@ const { createFakeAi, downAi } = require('./helpers/ai');
});
});
} finally { await o.close(); }
} finally { await o.close(); }
+ // The free model's provider is out of credit: AI marks its free-tier offer not free now (free_now false) and
+ // says no chat model can serve the free allowance (free_available false). Nothing promises free capacity.
+ const pausedAi = createFakeAi();
+ pausedAi.catalogData.free_available = false;
+ for (const m of pausedAi.catalogData.models) for (const o of m.offers || []) if (o.free_tier) { o.free_now = false; o.status = 'exhausted'; }
+ const paused = await boot({ ai: pausedAi });
+ try {
+ await check('free paused: no Free to try, a plain note, and no free-messages stat', async () => {
+ const home = await paused.get('/');
+ assert.strictEqual(home.status, 200);
+ assert.ok(!home.text.includes('Free to try'), 'no offer is promised as free');
+ assert.ok(!home.text.includes('messages free every day'), 'the free allowance is not a stat while paused');
+ const models = await paused.get('/models');
+ assert.ok(models.text.includes('Free paused'), 'the paused offer says so');
+ const freeModel = pausedAi.catalogData.models.find((m) => (m.offers || []).some((o) => o.free_tier));
+ const page = await paused.get('/m/' + freeModel.slug);
+ assert.ok(page.text.includes('Free paused'));
+ });
+ } finally { await paused.close(); }
+
const broken = await boot({ ai: createFakeAi(), openapiFetch: async () => { throw new Error('down'); } });
const broken = await boot({ ai: createFakeAi(), openapiFetch: async () => { throw new Error('down'); } });
try {
try {
await check('/openapi.json still answers when the API host is down', async () => {
await check('/openapi.json still answers when the API host is down', async () => {