tinywebui 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -13
- package/bin/tinywebui.js +69 -63
- package/package.json +25 -8
- package/public/app.js +40 -14
- package/public/chat/composer.js +1 -1
- package/public/chat/sidebar.js +8 -5
- package/public/index.html +88 -49
- package/public/panels/admin.js +40 -1
- package/public/panels/model-editor.js +336 -0
- package/public/panels/panels.js +37 -12
- package/public/panels/settings-home.js +125 -0
- package/public/panels/settings.js +124 -36
- package/public/shared/attachment_note.js +7 -1
- package/public/shared/events.js +1 -0
- package/public/shared/features.js +1 -0
- package/public/shared/origins.js +1 -0
- package/public/shared/usage.js +16 -1
- package/public/styles.css +83 -20
- package/src/access/auth.js +155 -155
- package/src/access/auth_gate.js +39 -38
- package/src/access/email.js +17 -0
- package/src/access/policy.js +123 -138
- package/src/audit.js +1 -1
- package/src/automations/automation.js +135 -123
- package/src/automations/scheduler.js +138 -127
- package/src/chat/compact.js +149 -156
- package/src/chat/llm.js +380 -382
- package/src/chat/runs.js +162 -151
- package/src/chat/tool_executor.js +229 -218
- package/src/cli.js +535 -433
- package/src/config/approval.js +35 -29
- package/src/config/config.js +546 -451
- package/src/config/data_dir.js +24 -0
- package/src/config/models.js +191 -115
- package/src/config/refs.js +32 -0
- package/src/config/secrets.js +153 -0
- package/src/files/documents.js +57 -68
- package/src/files/images.js +30 -31
- package/src/harness/agent.js +60 -60
- package/src/harness/attribution.js +66 -58
- package/src/harness/checkpoint.js +67 -62
- package/src/harness/history.js +28 -23
- package/src/harness/prompt.js +40 -57
- package/src/harness/provider.js +284 -283
- package/src/harness/types.js +1 -0
- package/src/http.js +98 -66
- package/src/log.js +11 -12
- package/src/mcp.js +398 -374
- package/src/retrieval/embedding.js +228 -240
- package/src/retrieval/retrieval.js +182 -173
- package/src/routes/admin.js +178 -138
- package/src/routes/auth.js +184 -100
- package/src/routes/automations.js +99 -82
- package/src/routes/chats.js +240 -218
- package/src/routes/documents.js +130 -120
- package/src/routes/library.js +43 -46
- package/src/routes/mcp.js +49 -49
- package/src/routes/settings.js +127 -122
- package/src/routes/types.js +1 -0
- package/src/server.js +428 -392
- package/src/store/automations.js +106 -106
- package/src/store/chats.js +217 -242
- package/src/store/documents.js +181 -185
- package/src/store/embeddings.js +52 -54
- package/src/store/index.js +95 -49
- package/src/store/messages.js +55 -80
- package/src/store/schema.js +128 -107
- package/src/store/scope.js +18 -17
- package/src/store/search.js +25 -27
- package/src/store/turns.js +53 -53
- package/src/store/usage.js +343 -269
- package/src/store/users.js +162 -87
- package/src/store/wire.js +50 -41
- package/src/tools/ask_tool.js +68 -68
- package/src/tools/chat_search_tool.js +38 -44
- package/src/tools/context_tool.js +131 -138
- package/src/tools/document_tool.js +73 -78
- package/src/tools/mcp_tool.js +274 -232
- package/src/tools/task_tool.js +53 -50
- package/src/tools/types.js +1 -0
- package/public/panels/connectors.js +0 -184
- package/src/set-password.js +0 -43
package/src/files/documents.js
CHANGED
|
@@ -5,87 +5,76 @@
|
|
|
5
5
|
* will be .txt/.md, and there's no reason to pay pdf-parse's or mammoth's
|
|
6
6
|
* load cost on every server start for the rare PDF or docx.
|
|
7
7
|
*/
|
|
8
|
-
|
|
9
8
|
const TEXT_EXTS = new Set([
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
'.txt', '.md', '.markdown', '.csv', '.tsv', '.json', '.log',
|
|
10
|
+
'.js', '.ts', '.jsx', '.tsx', '.py', '.rb', '.go', '.rs', '.java', '.c', '.cpp', '.h',
|
|
11
|
+
'.css', '.html', '.xml', '.yaml', '.yml', '.sh', '.sql'
|
|
13
12
|
]);
|
|
14
|
-
|
|
15
13
|
function extOf(filename) {
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
const i = filename.lastIndexOf('.');
|
|
15
|
+
return i === -1 ? '' : filename.slice(i).toLowerCase();
|
|
18
16
|
}
|
|
19
|
-
|
|
20
17
|
/**
|
|
21
18
|
* How to hand an uploaded file back to a browser. Decided from the name alone,
|
|
22
19
|
* never the client-supplied type: this is served from the app's own origin, so
|
|
23
20
|
* anything a browser would run (html, svg) goes out as inert text or a download.
|
|
24
21
|
*/
|
|
25
22
|
export function servingFor(filename) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
23
|
+
const ext = extOf(filename);
|
|
24
|
+
if (ext === '.pdf')
|
|
25
|
+
return { type: 'application/pdf', inline: true };
|
|
26
|
+
if (TEXT_EXTS.has(ext) || ext === '')
|
|
27
|
+
return { type: 'text/plain; charset=utf-8', inline: true };
|
|
28
|
+
return { type: 'application/octet-stream', inline: false };
|
|
30
29
|
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* @param {Buffer} buf raw file bytes
|
|
34
|
-
* @param {string} filename original filename, used only to pick a parser
|
|
35
|
-
* @returns {Promise<string>} extracted plain text
|
|
36
|
-
*/
|
|
30
|
+
/** Extracted plain text. `filename` is used only to pick a parser. */
|
|
37
31
|
export async function extractText(buf, filename) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
32
|
+
const ext = extOf(filename);
|
|
33
|
+
if (ext === '.pdf') {
|
|
34
|
+
// unpdf ships its own serverless PDF.js build and needs no native canvas
|
|
35
|
+
// binary for plain text extraction, unlike pdf-parse v2 (which pulls in
|
|
36
|
+
// @napi-rs/canvas even when nothing here ever renders a page).
|
|
37
|
+
let text;
|
|
38
|
+
try {
|
|
39
|
+
const { extractText, getDocumentProxy } = await import('unpdf');
|
|
40
|
+
const pdf = await getDocumentProxy(new Uint8Array(buf));
|
|
41
|
+
({ text } = await extractText(pdf, { mergePages: true }));
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
// pdf.js's own exception names (PasswordException, and a handful of
|
|
45
|
+
// "invalid structure" variants for anything that isn't a well-formed
|
|
46
|
+
// PDF) are not something a user should have to decode -- the two cases
|
|
47
|
+
// worth telling apart are password protection and a file that just
|
|
48
|
+
// isn't a readable PDF at all.
|
|
49
|
+
if (err?.name === 'PasswordException' || /password/i.test(err?.message || '')) {
|
|
50
|
+
throw new Error('this PDF is password-protected -- remove the password and re-upload');
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`could not read this PDF (${err?.message || 'corrupt file, or not actually a PDF'})`);
|
|
53
|
+
}
|
|
54
|
+
if (!text.trim()) {
|
|
55
|
+
// Parsed cleanly but yielded nothing: near-certainly a scanned or
|
|
56
|
+
// image-only PDF. There is no OCR here, so say that plainly instead of
|
|
57
|
+
// leaving it to read like the upload itself failed.
|
|
58
|
+
throw new Error('no extractable text -- this looks like a scanned or image-only PDF, which needs OCR that this app does not do');
|
|
59
|
+
}
|
|
60
|
+
return text;
|
|
61
|
+
}
|
|
62
|
+
if (ext === '.docx') {
|
|
63
|
+
let value;
|
|
64
|
+
try {
|
|
65
|
+
const { extractRawText } = await import('mammoth');
|
|
66
|
+
({ value } = await extractRawText({ buffer: buf }));
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
throw new Error(`could not read this .docx (${err?.message || 'corrupt file, or not actually a .docx'})`);
|
|
70
|
+
}
|
|
71
|
+
return value;
|
|
59
72
|
}
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
// image-only PDF. There is no OCR here, so say that plainly instead of
|
|
63
|
-
// leaving it to read like the upload itself failed.
|
|
64
|
-
throw new Error(
|
|
65
|
-
'no extractable text -- this looks like a scanned or image-only PDF, which needs OCR that this app does not do'
|
|
66
|
-
);
|
|
73
|
+
if (ext === '.doc') {
|
|
74
|
+
throw new Error('legacy .doc is not supported -- save it as .docx or .pdf and re-upload');
|
|
67
75
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (ext === '.docx') {
|
|
72
|
-
let value;
|
|
73
|
-
try {
|
|
74
|
-
const { extractRawText } = await import('mammoth');
|
|
75
|
-
({ value } = await extractRawText({ buffer: buf }));
|
|
76
|
-
} catch (err) {
|
|
77
|
-
throw new Error(`could not read this .docx (${err?.message || 'corrupt file, or not actually a .docx'})`);
|
|
76
|
+
if (TEXT_EXTS.has(ext) || ext === '') {
|
|
77
|
+
return buf.toString('utf8');
|
|
78
78
|
}
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
if (ext === '.doc') {
|
|
83
|
-
throw new Error('legacy .doc is not supported -- save it as .docx or .pdf and re-upload');
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
if (TEXT_EXTS.has(ext) || ext === '') {
|
|
87
|
-
return buf.toString('utf8');
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
throw new Error(`unsupported file type "${ext || filename}"`);
|
|
79
|
+
throw new Error(`unsupported file type "${ext || filename}"`);
|
|
91
80
|
}
|
package/src/files/images.js
CHANGED
|
@@ -3,60 +3,59 @@ import { fileURLToPath } from 'node:url';
|
|
|
3
3
|
import heicConvert from 'heic-convert';
|
|
4
4
|
import decodeAvif, { init as initAvif } from '@jsquash/avif/decode.js';
|
|
5
5
|
import jpeg from 'jpeg-js';
|
|
6
|
-
|
|
7
6
|
// @jsquash/avif is built for the browser and fetches its own .wasm by URL on
|
|
8
7
|
// init; Node's fetch can't load a file: URL, so the module is compiled and
|
|
9
8
|
// handed to it directly instead. Compiled once, lazily, and reused.
|
|
10
9
|
let avifReady;
|
|
11
10
|
function ensureAvif() {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
if (!avifReady) {
|
|
12
|
+
const wasmPath = fileURLToPath(import.meta.resolve('@jsquash/avif/codec/dec/avif_dec.wasm'));
|
|
13
|
+
// The typings describe browser init options; handing it the compiled module works the same.
|
|
14
|
+
avifReady = WebAssembly.compile(readFileSync(wasmPath)).then((mod) => initAvif(mod));
|
|
15
|
+
}
|
|
16
|
+
return avifReady;
|
|
17
17
|
}
|
|
18
|
-
|
|
19
18
|
/** Formats every OpenAI-compatible vision endpoint accepts on the wire. */
|
|
20
19
|
const WIRE_MIMES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
|
21
|
-
|
|
22
20
|
/**
|
|
23
21
|
* Browser-reported mime for HEIC/AVIF is unreliable -- Chrome often reports
|
|
24
22
|
* an empty string for HEIC, and some pickers report generic octet-stream for
|
|
25
23
|
* both. Sniffing the ISO-BMFF ftyp brand is what actually tells them apart.
|
|
26
24
|
*/
|
|
27
25
|
function sniff(buf) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
if (buf.length < 12 || buf.toString('ascii', 4, 8) !== 'ftyp')
|
|
27
|
+
return null;
|
|
28
|
+
const brand = buf.toString('ascii', 8, 12);
|
|
29
|
+
if (['avif', 'avis'].includes(brand))
|
|
30
|
+
return 'avif';
|
|
31
|
+
if (['heic', 'heix', 'hevc', 'hevx', 'heim', 'heis', 'hevm', 'hevs', 'mif1', 'msf1'].includes(brand))
|
|
32
|
+
return 'heic';
|
|
33
|
+
return null;
|
|
33
34
|
}
|
|
34
|
-
|
|
35
35
|
async function fromHeic(buf) {
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
const out = await heicConvert({ buffer: buf, format: 'JPEG', quality: 0.9 });
|
|
37
|
+
return Buffer.from(out);
|
|
38
38
|
}
|
|
39
|
-
|
|
40
39
|
async function fromAvif(buf) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
await ensureAvif();
|
|
41
|
+
const image = await decodeAvif(buf);
|
|
42
|
+
const out = jpeg.encode(image, 90);
|
|
43
|
+
return Buffer.from(out.data);
|
|
45
44
|
}
|
|
46
|
-
|
|
47
45
|
/**
|
|
48
46
|
* Normalizes one uploaded image to a wire-safe mime, converting HEIC/AVIF to
|
|
49
47
|
* JPEG in place. Returns null for anything else unrecognized rather than
|
|
50
48
|
* forwarding a format the provider can't decode.
|
|
51
49
|
*/
|
|
52
50
|
export async function normalizeImage(buf, reportedMime) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
51
|
+
const declared = String(reportedMime || '').toLowerCase();
|
|
52
|
+
if (WIRE_MIMES.has(declared))
|
|
53
|
+
return { mime: declared, data: buf };
|
|
54
|
+
const kind = sniff(buf) || (declared === 'image/heic' || declared === 'image/heif' ? 'heic'
|
|
55
|
+
: declared === 'image/avif' ? 'avif' : null);
|
|
56
|
+
if (kind === 'heic')
|
|
57
|
+
return { mime: 'image/jpeg', data: await fromHeic(buf) };
|
|
58
|
+
if (kind === 'avif')
|
|
59
|
+
return { mime: 'image/jpeg', data: await fromAvif(buf) };
|
|
60
|
+
return null;
|
|
62
61
|
}
|
package/src/harness/agent.js
CHANGED
|
@@ -27,61 +27,58 @@ import { EVENT } from '../../public/shared/events.js';
|
|
|
27
27
|
* Returns the messages appended during the run, in transcript order.
|
|
28
28
|
*/
|
|
29
29
|
export async function runAgentLoop({ messages, maxRounds, runtime, signal }) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
30
|
+
const working = [...messages];
|
|
31
|
+
const appended = [];
|
|
32
|
+
const emit = runtime.onEvent || (() => { });
|
|
33
|
+
// One extra pass past the budget: tools are mechanically refused there, so the
|
|
34
|
+
// model spends it writing an answer instead of leaving the turn unfinished.
|
|
35
|
+
for (let round = 0; round <= maxRounds; round++) {
|
|
36
|
+
const lastCall = round === maxRounds;
|
|
37
|
+
if (lastCall) {
|
|
38
|
+
emit({
|
|
39
|
+
type: EVENT.NOTICE,
|
|
40
|
+
text: `Tool budget spent (${maxRounds} rounds) — answering with what was gathered.`
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const assistant = await runtime.streamTurn({ messages: working, round, lastCall, signal });
|
|
44
|
+
// A provider that ignores tool_choice could still emit calls here. Dropping
|
|
45
|
+
// them keeps history valid, since nothing will produce their results.
|
|
46
|
+
if (lastCall)
|
|
47
|
+
delete assistant.tool_calls;
|
|
48
|
+
working.push(assistant);
|
|
49
|
+
appended.push(assistant);
|
|
50
|
+
const take = async () => {
|
|
51
|
+
const input = (await runtime.pendingInput?.({ round })) || [];
|
|
52
|
+
for (const { wire, message } of input) {
|
|
53
|
+
working.push(wire);
|
|
54
|
+
appended.push(message);
|
|
55
|
+
}
|
|
56
|
+
return input.length > 0;
|
|
57
|
+
};
|
|
58
|
+
const calls = assistant.tool_calls || [];
|
|
59
|
+
// The last pass takes no input: nothing would answer it. It stays queued.
|
|
60
|
+
if (lastCall)
|
|
61
|
+
return appended;
|
|
62
|
+
if (!calls.length) {
|
|
63
|
+
if (await take())
|
|
64
|
+
continue;
|
|
65
|
+
return appended;
|
|
66
|
+
}
|
|
67
|
+
const results = await runtime.executeToolBatch(calls, { messages: working, round, lastCall, signal });
|
|
68
|
+
for (const { wire, message } of results) {
|
|
69
|
+
working.push(wire);
|
|
70
|
+
appended.push(message);
|
|
71
|
+
}
|
|
72
|
+
// Checked only now: a Stop mid-batch still gets every call its result.
|
|
73
|
+
if (signal?.aborted)
|
|
74
|
+
throw new Error('Stopped.');
|
|
75
|
+
if (runtime.shouldContinue && !(await runtime.shouldContinue({ round, assistant, results }))) {
|
|
76
|
+
return appended;
|
|
77
|
+
}
|
|
78
|
+
await take();
|
|
43
79
|
}
|
|
44
|
-
|
|
45
|
-
const assistant = await runtime.streamTurn({ messages: working, round, lastCall, signal });
|
|
46
|
-
// A provider that ignores tool_choice could still emit calls here. Dropping
|
|
47
|
-
// them keeps history valid, since nothing will produce their results.
|
|
48
|
-
if (lastCall) delete assistant.tool_calls;
|
|
49
|
-
working.push(assistant);
|
|
50
|
-
appended.push(assistant);
|
|
51
|
-
|
|
52
|
-
const take = async () => {
|
|
53
|
-
const input = (await runtime.pendingInput?.({ round })) || [];
|
|
54
|
-
for (const { wire, message } of input) {
|
|
55
|
-
working.push(wire);
|
|
56
|
-
appended.push(message);
|
|
57
|
-
}
|
|
58
|
-
return input.length > 0;
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
const calls = assistant.tool_calls || [];
|
|
62
|
-
// The last pass takes no input: nothing would answer it. It stays queued.
|
|
63
|
-
if (lastCall) return appended;
|
|
64
|
-
if (!calls.length) {
|
|
65
|
-
if (await take()) continue;
|
|
66
|
-
return appended;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const results = await runtime.executeToolBatch(calls, { messages: working, round, lastCall, signal });
|
|
70
|
-
for (const { wire, message } of results) {
|
|
71
|
-
working.push(wire);
|
|
72
|
-
appended.push(message);
|
|
73
|
-
}
|
|
74
|
-
// Checked only now: a Stop mid-batch still gets every call its result.
|
|
75
|
-
if (signal?.aborted) throw new Error('Stopped.');
|
|
76
|
-
|
|
77
|
-
if (runtime.shouldContinue && !(await runtime.shouldContinue({ round, assistant, results }))) {
|
|
78
|
-
return appended;
|
|
79
|
-
}
|
|
80
|
-
await take();
|
|
81
|
-
}
|
|
82
|
-
return appended;
|
|
80
|
+
return appended;
|
|
83
81
|
}
|
|
84
|
-
|
|
85
82
|
/**
|
|
86
83
|
* Lifecycle hooks: small internal seams at the tool and turn boundaries, so
|
|
87
84
|
* cross-cutting policy (approvals, audit, result shaping, stopping early)
|
|
@@ -105,11 +102,14 @@ export async function runAgentLoop({ messages, maxRounds, runtime, signal }) {
|
|
|
105
102
|
* swallowed here; the caller turns it into a visible outcome.
|
|
106
103
|
*/
|
|
107
104
|
export async function runHooks(list, ctx, each = () => false) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
105
|
+
for (const hook of list || []) {
|
|
106
|
+
if (ctx.signal?.aborted)
|
|
107
|
+
throw new Error('Stopped.');
|
|
108
|
+
const out = await hook(ctx);
|
|
109
|
+
if (ctx.signal?.aborted)
|
|
110
|
+
throw new Error('Stopped.');
|
|
111
|
+
if (out && each(out))
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
return false;
|
|
115
115
|
}
|
|
@@ -1,75 +1,83 @@
|
|
|
1
1
|
import { inputTokens, outputTokens, cacheReads } from '../../public/shared/usage.js';
|
|
2
|
-
|
|
3
2
|
// Counts only: never retain another copy of prompts, schemas or tool output.
|
|
4
3
|
// The char/4 estimator matches compact.js; it is not provider tokenization.
|
|
5
4
|
const estimate = (value) => Math.ceil((typeof value === 'string' ? value : JSON.stringify(value ?? '')).length / 4);
|
|
6
5
|
const count = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
|
|
7
|
-
const bucket = (category, tokens, source = 'estimated', capability
|
|
6
|
+
const bucket = (category, tokens, source = 'estimated', capability) => ({ category, tokens, source, ...(capability ? { capability } : {}) });
|
|
8
7
|
const sum = (buckets) => buckets.reduce((n, b) => n + b.tokens, 0);
|
|
9
8
|
const text = (content) => typeof content === 'string' ? content : Array.isArray(content)
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
? content.filter((p) => p.type === 'text').map((p) => p.text || '').join('') : '';
|
|
12
10
|
export function attributeRequest(body, { systemParts = [], owner = () => 'built-ins' } = {}) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
11
|
+
const buckets = [];
|
|
12
|
+
const add = (category, value, capability) => {
|
|
13
|
+
if (value)
|
|
14
|
+
buckets.push(bucket(category, estimate(value), 'estimated', capability));
|
|
15
|
+
};
|
|
16
|
+
let systemAttributed = false;
|
|
17
|
+
for (const message of body.messages || []) {
|
|
18
|
+
if (message.role === 'system') {
|
|
19
|
+
const actual = text(message.content);
|
|
20
|
+
if (!systemAttributed && actual === systemParts.map((p) => p.text).filter(Boolean).join('\n\n')) {
|
|
21
|
+
for (const part of systemParts)
|
|
22
|
+
add(part.category, part.text, part.capability);
|
|
23
|
+
systemAttributed = true;
|
|
24
|
+
}
|
|
25
|
+
else
|
|
26
|
+
add('system_other', actual);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const category = message.role === 'user' ? 'user_messages'
|
|
30
|
+
: message.role === 'tool' ? 'tool_results' : 'assistant_history';
|
|
31
|
+
add(category, text(message.content));
|
|
32
|
+
for (const part of Array.isArray(message.content) ? message.content : []) {
|
|
33
|
+
if (part.type === 'image_url')
|
|
34
|
+
buckets.push(bucket('image_allowance', 1500));
|
|
35
|
+
else if (part.type !== 'text')
|
|
36
|
+
add('attachments_other', part);
|
|
37
|
+
}
|
|
38
|
+
if (message.tool_calls)
|
|
39
|
+
add('tool_history', message.tool_calls);
|
|
40
|
+
if (message.reasoning_details)
|
|
41
|
+
add('reasoning_history', message.reasoning_details);
|
|
42
|
+
if (message.reasoning_content)
|
|
43
|
+
add('reasoning_history', message.reasoning_content);
|
|
26
44
|
}
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
for (const
|
|
31
|
-
|
|
32
|
-
|
|
45
|
+
for (const tool of body.tools || [])
|
|
46
|
+
add('tool_schemas', tool, owner(tool.function?.name));
|
|
47
|
+
const compact = new Map();
|
|
48
|
+
for (const b of buckets) {
|
|
49
|
+
const key = JSON.stringify([b.category, b.source, b.capability]);
|
|
50
|
+
const entry = compact.get(key) || { ...b, tokens: 0 };
|
|
51
|
+
entry.tokens += b.tokens;
|
|
52
|
+
compact.set(key, entry);
|
|
33
53
|
}
|
|
34
|
-
|
|
35
|
-
if (message.reasoning_details) add('reasoning_history', message.reasoning_details);
|
|
36
|
-
if (message.reasoning_content) add('reasoning_history', message.reasoning_content);
|
|
37
|
-
}
|
|
38
|
-
for (const tool of body.tools || []) add('tool_schemas', tool, owner(tool.function?.name));
|
|
39
|
-
const compact = new Map();
|
|
40
|
-
for (const b of buckets) {
|
|
41
|
-
const key = JSON.stringify([b.category, b.source, b.capability]);
|
|
42
|
-
const entry = compact.get(key) || { ...b, tokens: 0 };
|
|
43
|
-
entry.tokens += b.tokens;
|
|
44
|
-
compact.set(key, entry);
|
|
45
|
-
}
|
|
46
|
-
return { buckets: [...compact.values()], estimatedTotal: sum(buckets) };
|
|
54
|
+
return { buckets: [...compact.values()], estimatedTotal: sum(buckets) };
|
|
47
55
|
}
|
|
48
|
-
|
|
49
56
|
function reconcile(buckets, total) {
|
|
50
|
-
|
|
51
|
-
|
|
57
|
+
return { total, source: total === null ? 'unreported' : 'provider', buckets: total === null ? buckets
|
|
58
|
+
: [...buckets, bucket('provider_delta', total - sum(buckets), 'estimated')] };
|
|
52
59
|
}
|
|
53
|
-
|
|
54
60
|
export function completeAttribution(input, usage, { content = '', reasoning = '', toolCalls = [], details = [] } = {}) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
61
|
+
const output = [];
|
|
62
|
+
const reportedReasoning = count(usage?.completion_tokens_details?.reasoning_tokens ?? usage?.output_tokens_details?.reasoning_tokens ?? usage?.reasoning_tokens);
|
|
63
|
+
if (reportedReasoning !== null)
|
|
64
|
+
output.push(bucket('reasoning', reportedReasoning, 'provider'));
|
|
65
|
+
else {
|
|
66
|
+
const visibleReasoning = reasoning || details.map((d) => d.text || d.summary || '').join('');
|
|
67
|
+
if (visibleReasoning)
|
|
68
|
+
output.push(bucket('reasoning', estimate(visibleReasoning)));
|
|
69
|
+
}
|
|
70
|
+
if (toolCalls.length)
|
|
71
|
+
output.push(bucket('tool_generation', estimate(toolCalls.map((c) => c.function))));
|
|
72
|
+
if (content)
|
|
73
|
+
output.push(bucket('intermediate_text', estimate(content)));
|
|
74
|
+
return {
|
|
75
|
+
version: 1, estimator: 'characters/4; images 1500 tokens each',
|
|
76
|
+
input: reconcile(input.buckets, inputTokens(usage)),
|
|
77
|
+
output: reconcile(output, outputTokens(usage)),
|
|
78
|
+
cached: cacheReads(usage)
|
|
79
|
+
};
|
|
70
80
|
}
|
|
71
|
-
|
|
72
81
|
export function markFinal(attribution) {
|
|
73
|
-
|
|
74
|
-
b.category === 'intermediate_text' ? { ...b, category: 'final_text' } : b) } };
|
|
82
|
+
return { ...attribution, output: { ...attribution.output, buckets: attribution.output.buckets.map((b) => b.category === 'intermediate_text' ? { ...b, category: 'final_text' } : b) } };
|
|
75
83
|
}
|