openvibers/openvibe.exchange Commits
Show cached input share from AI receipts (#209)
Changes
@@@ -8,6 +8,7 @@* machines serve it, never which ones* chat({...}) POST /api/v1/runs one ai.complete run, on behalf of the person, with this* service's token (ai.run.create, ns ai.complete)++ * GET /api/v1/receipts/:id cached input share for the answer's receipt (best effort)** The catalog and usage reads are cached in this process, so a page view does not cost AI a request. When* AI cannot be reached the reads answer an empty catalog carrying `unavailable: true`: the pages say the@@@ -217,6 +218,30 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (err.status = 504;throw err;}++ // AI's run summary has total tokens; its per-run receipt has the cached input count. Read it with the++ // same caller token, and leave the answer usable if an older AI or a slow receipt store cannot serve it.++ let cachedTokens = null;++ let cachedInputPercent = null;++ if (run.id) {++ try {++ const receiptId = `rcpt_${String(run.id).replace(/^run_/, '')}`;++ const receiptRes = await fetchImpl(`${config.ai.url}/api/v1/receipts/${encodeURIComponent(receiptId)}`, {++ headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },++ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(3000)]) : AbortSignal.timeout(3000),++ });++ if (receiptRes.ok) {++ const receipt = (await jsonOf(receiptRes) || {}).receipt;++ if (receipt && receipt.run_id === run.id) {++ const input = Number(receipt.usage && receipt.usage.tokens_in);++ const cached = Number(receipt.usage && receipt.usage.tokens_cached);++ if (Number.isFinite(input) && input > 0 && Number.isFinite(cached) && cached >= 0) {++ cachedTokens = Math.min(cached, input);++ cachedInputPercent = Math.round(100 * cachedTokens / input);++ }++ }++ }++ } catch { /* The receipt is optional for the answer. */ }++ }const choice = run.output && Array.isArray(run.output.choices) ? run.output.choices[0] : null;const content = choice && choice.message && typeof choice.message.content === 'string' ? choice.message.content : '';const usage = run.usage || null;@@@ -239,6 +264,8 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (usage,tokens_in: usage ? usage.tokens_in || 0 : 0,tokens_out: usage ? usage.tokens_out || 0 : 0,++ tokens_cached: cachedTokens,++ cached_input_percent: cachedInputPercent,latency_ms: Number.isFinite(started) && Number.isFinite(finished) && finished >= started ? finished - started : null,why: why == null ? null : (Array.isArray(why) ? why : [String(why)]),privacy: privacy ? privacy.label || null : null,@@@ -267,6 +267,7 @@ ${kit.chips({ name: 'models', label: 'Compare up to three models', options: mode${m.meta && m.meta.preset ? `<dt>Preset</dt><dd>${esc(m.meta.preset)}</dd>` : ''}<dt>Price</dt><dd>${cost == null ? '—' : Number(cost) === 0 ? 'free' : `$${Number(cost).toFixed(6)}`}</dd><dt>Tokens</dt><dd>${m.meta && (m.meta.tokens_in != null || m.meta.tokens_out != null) ? `${Number(m.meta.tokens_in || 0).toLocaleString('en-US')} in · ${Number(m.meta.tokens_out || 0).toLocaleString('en-US')} out` : '—'}</dd>++${m.meta && m.meta.cached_input_percent != null ? `<dt>Cached input</dt><dd>${Number(m.meta.cached_input_percent)}% (${Number(m.meta.tokens_cached || 0).toLocaleString('en-US')} tokens)</dd>` : ''}<dt>Latency</dt><dd>${latency == null ? '—' : `${Number(latency).toLocaleString('en-US')} ms`}</dd><dt>Why this offer</dt><dd>${m.meta && Array.isArray(m.meta.why) && m.meta.why.length ? esc(m.meta.why.join(' · ')) : 'The router picked the healthiest offer for your floor, ceiling and policy.'}</dd></dl></details>`;@@@ -444,7 +445,8 @@ ${m.meta && m.meta.preset ? `<dt>Preset</dt><dd>${esc(m.meta.preset)}</dd>` : ''return res.json({answer: {content: out.content || '(no text)', model: out.model, provider: out.provider, run_id: out.run_id,-- cost_usd: out.cost_usd, tokens_in: out.tokens_in, tokens_out: out.tokens_out, latency_ms: out.latency_ms,++ cost_usd: out.cost_usd, tokens_in: out.tokens_in, tokens_out: out.tokens_out, tokens_cached: out.tokens_cached,++ cached_input_percent: out.cached_input_percent, latency_ms: out.latency_ms,why: out.why, privacy: out.privacy, privacy_detail: out.privacy_detail, preset: out.preset,},});@@@ -661,7 +663,8 @@ ${m.meta && m.meta.preset ? `<dt>Preset</dt><dd>${esc(m.meta.preset)}</dd>` : ''role: 'assistant', model: slug, content: out.content || '(no text)',meta: {run_id: out.run_id, provider: out.provider, answered_model: out.model, cost_usd: out.cost_usd,-- tokens_in: out.tokens_in, tokens_out: out.tokens_out, latency_ms: out.latency_ms, why: out.why,++ tokens_in: out.tokens_in, tokens_out: out.tokens_out, tokens_cached: out.tokens_cached,++ cached_input_percent: out.cached_input_percent, latency_ms: out.latency_ms, why: out.why,privacy: out.privacy, privacy_detail: out.privacy_detail, preset: out.preset,},});@@@ -490,6 +490,7 @@ function resultCard(result) {['Cost', result.cost_usd == null ? '–' : `$${Number(result.cost_usd).toFixed(6)}`],['Latency', result.latency_ms == null ? '–' : ms(result.latency_ms)],['Tokens', `${count(result.tokens_in)} in · ${count(result.tokens_out)} out`],++ ...(result.cached_input_percent == null ? [] : [['Cached input', `${result.cached_input_percent}% (${count(result.tokens_cached)} tokens)`]]),['Run', result.run_id || '–'],];return kit.receipt({@@@ -1157,6 +1158,7 @@ ${result.content ? `<div class="ex-answer-body"><p class="ex-answer-text">${esc(<dt>Chosen</dt><dd>${esc(selected || '—')}${result.provider ? ` · ${esc(providerLabel({ provider: result.provider, provider_name: result.provider }))}` : ''}</dd><dt>Objective</dt><dd>${esc(x.objective || 'balanced')}</dd><dt>Tokens</dt><dd>${count(result.tokens_in)} in · ${count(result.tokens_out)} out</dd>++${result.cached_input_percent == null ? '' : `<dt>Cached input</dt><dd>${result.cached_input_percent}% (${count(result.tokens_cached)} tokens)</dd>`}<dt>Privacy</dt><dd>${esc(result.privacy || '—')}</dd></dl>${reasons.length ? `<h3 class="ex-h3">Why</h3><ul class="ex-list">${reasons.map((r) => `<li>${esc(r)}</li>`).join('')}</ul>` : ''}nnew file mode 100644@@@ -0,0 +1,46 @@++'use strict';++const assert = require('node:assert/strict');++const { createAi } = require('../server/ai');++const views = require('../server/views/exchange');++++const response = (status, value) => new Response(JSON.stringify(value), {++ status, headers: { 'Content-Type': 'application/json' },++});++const config = {++ networkInternalUrl: 'http://network.test',++ oauth: { clientId: 'exchange', clientSecret: 'test-client-secret', consoleScope: 'ai.usage.read' },++ ai: { url: 'http://ai.test', audience: 'openvibe.ai', scope: 'ai.run.create', waitMs: 5000, timeoutMs: 10000 },++};++++async function one(receiptStatus) {++ const calls = [];++ const ai = createAi({ config, fetchImpl: async (url, opts = {}) => {++ const address = String(url);++ calls.push({ address, method: opts.method || 'GET' });++ if (address.endsWith('/oauth/token')) return response(200, { access_token: 'test-token', token_type: 'Bearer', expires_in: 300 });++ if (address.includes('/api/v1/runs?wait=')) return response(200, { run: {++ id: 'run_TEST', status: 'succeeded', output: { model: 'small', choices: [{ message: { content: 'Hello' } }] },++ provenance: { provider: 'local' }, usage: { tokens_in: 100, tokens_out: 20, cost_usd: 0.01 },++ } });++ if (address.endsWith('/api/v1/receipts/rcpt_TEST')) return response(receiptStatus, receiptStatus === 200++ ? { receipt: { run_id: 'run_TEST', usage: { tokens_in: 100, tokens_cached: 30 } } }++ : { code: 'ai.receipt_not_found' });++ throw new Error('unexpected request');++ }, log: { warn() {} } });++ const result = await ai.chat({ model: 'small', messages: [{ role: 'user', content: 'Hi' }], subject: 'usr_TEST' });++ return { result, calls };++}++++(async () => {++ const { result, calls } = await one(200);++ assert.equal(result.content, 'Hello');++ assert.equal(result.tokens_cached, 30);++ assert.equal(result.cached_input_percent, 30);++ assert.ok(calls.some((c) => c.address.endsWith('/api/v1/receipts/rcpt_TEST')));++ assert.match(views.whyPage({ result, catalog: { models: [] } }), /Cached input<\/dt><dd>30%/);++++ const missing = await one(404);++ assert.equal(missing.result.content, 'Hello', 'a missing receipt must not lose the answer');++ assert.equal(missing.result.cached_input_percent, null);++ console.log('AI receipt: cached input share is shown; a missing receipt leaves the answer usable');++})().catch((error) => { console.error(error); process.exitCode = 1; });
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
* machines serve it, never which ones
* machines serve it, never which ones
* chat({...}) POST /api/v1/runs one ai.complete run, on behalf of the person, with this
* chat({...}) POST /api/v1/runs one ai.complete run, on behalf of the person, with this
* service's token (ai.run.create, ns ai.complete)
* service's token (ai.run.create, ns ai.complete)
+ * GET /api/v1/receipts/:id cached input share for the answer's receipt (best effort)
*
*
* The catalog and usage reads are cached in this process, so a page view does not cost AI a request. When
* The catalog and usage reads are cached in this process, so a page view does not cost AI a request. When
* AI cannot be reached the reads answer an empty catalog carrying `unavailable: true`: the pages say the
* AI cannot be reached the reads answer an empty catalog carrying `unavailable: true`: the pages say the
@@ -217,6 +218,30 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
@@ -217,6 +218,30 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
err.status = 504;
err.status = 504;
throw err;
throw err;
}
}
+ // AI's run summary has total tokens; its per-run receipt has the cached input count. Read it with the
+ // same caller token, and leave the answer usable if an older AI or a slow receipt store cannot serve it.
+ let cachedTokens = null;
+ let cachedInputPercent = null;
+ if (run.id) {
+ try {
+ const receiptId = `rcpt_${String(run.id).replace(/^run_/, '')}`;
+ const receiptRes = await fetchImpl(`${config.ai.url}/api/v1/receipts/${encodeURIComponent(receiptId)}`, {
+ headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(3000)]) : AbortSignal.timeout(3000),
+ });
+ if (receiptRes.ok) {
+ const receipt = (await jsonOf(receiptRes) || {}).receipt;
+ if (receipt && receipt.run_id === run.id) {
+ const input = Number(receipt.usage && receipt.usage.tokens_in);
+ const cached = Number(receipt.usage && receipt.usage.tokens_cached);
+ if (Number.isFinite(input) && input > 0 && Number.isFinite(cached) && cached >= 0) {
+ cachedTokens = Math.min(cached, input);
+ cachedInputPercent = Math.round(100 * cachedTokens / input);
+ }
+ }
+ }
+ } catch { /* The receipt is optional for the answer. */ }
+ }
const choice = run.output && Array.isArray(run.output.choices) ? run.output.choices[0] : null;
const choice = run.output && Array.isArray(run.output.choices) ? run.output.choices[0] : null;
const content = choice && choice.message && typeof choice.message.content === 'string' ? choice.message.content : '';
const content = choice && choice.message && typeof choice.message.content === 'string' ? choice.message.content : '';
const usage = run.usage || null;
const usage = run.usage || null;
@@ -239,6 +264,8 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
@@ -239,6 +264,8 @@ function createAi({ config, fetchImpl = globalThis.fetch, log = console, now = (
usage,
usage,
tokens_in: usage ? usage.tokens_in || 0 : 0,
tokens_in: usage ? usage.tokens_in || 0 : 0,
tokens_out: usage ? usage.tokens_out || 0 : 0,
tokens_out: usage ? usage.tokens_out || 0 : 0,
+ tokens_cached: cachedTokens,
+ cached_input_percent: cachedInputPercent,
latency_ms: Number.isFinite(started) && Number.isFinite(finished) && finished >= started ? finished - started : null,
latency_ms: Number.isFinite(started) && Number.isFinite(finished) && finished >= started ? finished - started : null,
why: why == null ? null : (Array.isArray(why) ? why : [String(why)]),
why: why == null ? null : (Array.isArray(why) ? why : [String(why)]),
privacy: privacy ? privacy.label || null : null,
privacy: privacy ? privacy.label || null : null,
@@ -267,6 +267,7 @@ ${kit.chips({ name: 'models', label: 'Compare up to three models', options: mode
@@ -267,6 +267,7 @@ ${kit.chips({ name: 'models', label: 'Compare up to three models', options: mode
${m.meta && m.meta.preset ? `<dt>Preset</dt><dd>${esc(m.meta.preset)}</dd>` : ''}
${m.meta && m.meta.preset ? `<dt>Preset</dt><dd>${esc(m.meta.preset)}</dd>` : ''}
<dt>Price</dt><dd>${cost == null ? '—' : Number(cost) === 0 ? 'free' : `$${Number(cost).toFixed(6)}`}</dd>
<dt>Price</dt><dd>${cost == null ? '—' : Number(cost) === 0 ? 'free' : `$${Number(cost).toFixed(6)}`}</dd>
<dt>Tokens</dt><dd>${m.meta && (m.meta.tokens_in != null || m.meta.tokens_out != null) ? `${Number(m.meta.tokens_in || 0).toLocaleString('en-US')} in · ${Number(m.meta.tokens_out || 0).toLocaleString('en-US')} out` : '—'}</dd>
<dt>Tokens</dt><dd>${m.meta && (m.meta.tokens_in != null || m.meta.tokens_out != null) ? `${Number(m.meta.tokens_in || 0).toLocaleString('en-US')} in · ${Number(m.meta.tokens_out || 0).toLocaleString('en-US')} out` : '—'}</dd>
+${m.meta && m.meta.cached_input_percent != null ? `<dt>Cached input</dt><dd>${Number(m.meta.cached_input_percent)}% (${Number(m.meta.tokens_cached || 0).toLocaleString('en-US')} tokens)</dd>` : ''}
<dt>Latency</dt><dd>${latency == null ? '—' : `${Number(latency).toLocaleString('en-US')} ms`}</dd>
<dt>Latency</dt><dd>${latency == null ? '—' : `${Number(latency).toLocaleString('en-US')} ms`}</dd>
<dt>Why this offer</dt><dd>${m.meta && Array.isArray(m.meta.why) && m.meta.why.length ? esc(m.meta.why.join(' · ')) : 'The router picked the healthiest offer for your floor, ceiling and policy.'}</dd>
<dt>Why this offer</dt><dd>${m.meta && Array.isArray(m.meta.why) && m.meta.why.length ? esc(m.meta.why.join(' · ')) : 'The router picked the healthiest offer for your floor, ceiling and policy.'}</dd>
</dl></details>`;
</dl></details>`;
@@ -444,7 +445,8 @@ ${m.meta && m.meta.preset ? `<dt>Preset</dt><dd>${esc(m.meta.preset)}</dd>` : ''
@@ -444,7 +445,8 @@ ${m.meta && m.meta.preset ? `<dt>Preset</dt><dd>${esc(m.meta.preset)}</dd>` : ''
return res.json({
return res.json({
answer: {
answer: {
content: out.content || '(no text)', model: out.model, provider: out.provider, run_id: out.run_id,
content: out.content || '(no text)', model: out.model, provider: out.provider, run_id: out.run_id,
- cost_usd: out.cost_usd, tokens_in: out.tokens_in, tokens_out: out.tokens_out, latency_ms: out.latency_ms,
+ cost_usd: out.cost_usd, tokens_in: out.tokens_in, tokens_out: out.tokens_out, tokens_cached: out.tokens_cached,
+ cached_input_percent: out.cached_input_percent, latency_ms: out.latency_ms,
why: out.why, privacy: out.privacy, privacy_detail: out.privacy_detail, preset: out.preset,
why: out.why, privacy: out.privacy, privacy_detail: out.privacy_detail, preset: out.preset,
},
},
});
});
@@ -661,7 +663,8 @@ ${m.meta && m.meta.preset ? `<dt>Preset</dt><dd>${esc(m.meta.preset)}</dd>` : ''
@@ -661,7 +663,8 @@ ${m.meta && m.meta.preset ? `<dt>Preset</dt><dd>${esc(m.meta.preset)}</dd>` : ''
role: 'assistant', model: slug, content: out.content || '(no text)',
role: 'assistant', model: slug, content: out.content || '(no text)',
meta: {
meta: {
run_id: out.run_id, provider: out.provider, answered_model: out.model, cost_usd: out.cost_usd,
run_id: out.run_id, provider: out.provider, answered_model: out.model, cost_usd: out.cost_usd,
- tokens_in: out.tokens_in, tokens_out: out.tokens_out, latency_ms: out.latency_ms, why: out.why,
+ tokens_in: out.tokens_in, tokens_out: out.tokens_out, tokens_cached: out.tokens_cached,
+ cached_input_percent: out.cached_input_percent, latency_ms: out.latency_ms, why: out.why,
privacy: out.privacy, privacy_detail: out.privacy_detail, preset: out.preset,
privacy: out.privacy, privacy_detail: out.privacy_detail, preset: out.preset,
},
},
});
});
@@ -490,6 +490,7 @@ function resultCard(result) {
@@ -490,6 +490,7 @@ function resultCard(result) {
['Cost', result.cost_usd == null ? '–' : `$${Number(result.cost_usd).toFixed(6)}`],
['Cost', result.cost_usd == null ? '–' : `$${Number(result.cost_usd).toFixed(6)}`],
['Latency', result.latency_ms == null ? '–' : ms(result.latency_ms)],
['Latency', result.latency_ms == null ? '–' : ms(result.latency_ms)],
['Tokens', `${count(result.tokens_in)} in · ${count(result.tokens_out)} out`],
['Tokens', `${count(result.tokens_in)} in · ${count(result.tokens_out)} out`],
+ ...(result.cached_input_percent == null ? [] : [['Cached input', `${result.cached_input_percent}% (${count(result.tokens_cached)} tokens)`]]),
['Run', result.run_id || '–'],
['Run', result.run_id || '–'],
];
];
return kit.receipt({
return kit.receipt({
@@ -1157,6 +1158,7 @@ ${result.content ? `<div class="ex-answer-body"><p class="ex-answer-text">${esc(
@@ -1157,6 +1158,7 @@ ${result.content ? `<div class="ex-answer-body"><p class="ex-answer-text">${esc(
<dt>Chosen</dt><dd>${esc(selected || '—')}${result.provider ? ` · ${esc(providerLabel({ provider: result.provider, provider_name: result.provider }))}` : ''}</dd>
<dt>Chosen</dt><dd>${esc(selected || '—')}${result.provider ? ` · ${esc(providerLabel({ provider: result.provider, provider_name: result.provider }))}` : ''}</dd>
<dt>Objective</dt><dd>${esc(x.objective || 'balanced')}</dd>
<dt>Objective</dt><dd>${esc(x.objective || 'balanced')}</dd>
<dt>Tokens</dt><dd>${count(result.tokens_in)} in · ${count(result.tokens_out)} out</dd>
<dt>Tokens</dt><dd>${count(result.tokens_in)} in · ${count(result.tokens_out)} out</dd>
+${result.cached_input_percent == null ? '' : `<dt>Cached input</dt><dd>${result.cached_input_percent}% (${count(result.tokens_cached)} tokens)</dd>`}
<dt>Privacy</dt><dd>${esc(result.privacy || '—')}</dd>
<dt>Privacy</dt><dd>${esc(result.privacy || '—')}</dd>
</dl>
</dl>
${reasons.length ? `<h3 class="ex-h3">Why</h3><ul class="ex-list">${reasons.map((r) => `<li>${esc(r)}</li>`).join('')}</ul>` : ''}
${reasons.length ? `<h3 class="ex-h3">Why</h3><ul class="ex-list">${reasons.map((r) => `<li>${esc(r)}</li>`).join('')}</ul>` : ''}
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+'use strict';
+const assert = require('node:assert/strict');
+const { createAi } = require('../server/ai');
+const views = require('../server/views/exchange');
+
+const response = (status, value) => new Response(JSON.stringify(value), {
+ status, headers: { 'Content-Type': 'application/json' },
+});
+const config = {
+ networkInternalUrl: 'http://network.test',
+ oauth: { clientId: 'exchange', clientSecret: 'test-client-secret', consoleScope: 'ai.usage.read' },
+ ai: { url: 'http://ai.test', audience: 'openvibe.ai', scope: 'ai.run.create', waitMs: 5000, timeoutMs: 10000 },
+};
+
+async function one(receiptStatus) {
+ const calls = [];
+ const ai = createAi({ config, fetchImpl: async (url, opts = {}) => {
+ const address = String(url);
+ calls.push({ address, method: opts.method || 'GET' });
+ if (address.endsWith('/oauth/token')) return response(200, { access_token: 'test-token', token_type: 'Bearer', expires_in: 300 });
+ if (address.includes('/api/v1/runs?wait=')) return response(200, { run: {
+ id: 'run_TEST', status: 'succeeded', output: { model: 'small', choices: [{ message: { content: 'Hello' } }] },
+ provenance: { provider: 'local' }, usage: { tokens_in: 100, tokens_out: 20, cost_usd: 0.01 },
+ } });
+ if (address.endsWith('/api/v1/receipts/rcpt_TEST')) return response(receiptStatus, receiptStatus === 200
+ ? { receipt: { run_id: 'run_TEST', usage: { tokens_in: 100, tokens_cached: 30 } } }
+ : { code: 'ai.receipt_not_found' });
+ throw new Error('unexpected request');
+ }, log: { warn() {} } });
+ const result = await ai.chat({ model: 'small', messages: [{ role: 'user', content: 'Hi' }], subject: 'usr_TEST' });
+ return { result, calls };
+}
+
+(async () => {
+ const { result, calls } = await one(200);
+ assert.equal(result.content, 'Hello');
+ assert.equal(result.tokens_cached, 30);
+ assert.equal(result.cached_input_percent, 30);
+ assert.ok(calls.some((c) => c.address.endsWith('/api/v1/receipts/rcpt_TEST')));
+ assert.match(views.whyPage({ result, catalog: { models: [] } }), /Cached input<\/dt><dd>30%/);
+
+ const missing = await one(404);
+ assert.equal(missing.result.content, 'Hello', 'a missing receipt must not lose the answer');
+ assert.equal(missing.result.cached_input_percent, null);
+ console.log('AI receipt: cached input share is shown; a missing receipt leaves the answer usable');
+})().catch((error) => { console.error(error); process.exitCode = 1; });