openvibers/openvibe.exchange Commits

Show cached input share from AI receipts (#209)

OP OpenVibers committed 2 days ago · 3bb0fbab68 · 4 files, +80 −2

Changes

ddiff --git a/server/ai.js b/server/ai.js
iindex 819a993..26a25ec 100644
---- a/server/ai.js
++++ b/server/ai.js
@@@ -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,
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex c92704a..2ce55bc 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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,
},
});
ddiff --git a/server/views/exchange.js b/server/views/exchange.js
iindex 33e90cc..a02f388 100644
---- a/server/views/exchange.js
++++ b/server/views/exchange.js
@@@ -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>` : ''}
ddiff --git a/test/ai-receipt-cache.test.js b/test/ai-receipt-cache.test.js
nnew file mode 100644
iindex 0000000..4bb3663
---- /dev/null
++++ b/test/ai-receipt-cache.test.js
@@@ -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; });