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.
Files changed (82) hide show
  1. package/README.md +20 -13
  2. package/bin/tinywebui.js +69 -63
  3. package/package.json +25 -8
  4. package/public/app.js +40 -14
  5. package/public/chat/composer.js +1 -1
  6. package/public/chat/sidebar.js +8 -5
  7. package/public/index.html +88 -49
  8. package/public/panels/admin.js +40 -1
  9. package/public/panels/model-editor.js +336 -0
  10. package/public/panels/panels.js +37 -12
  11. package/public/panels/settings-home.js +125 -0
  12. package/public/panels/settings.js +124 -36
  13. package/public/shared/attachment_note.js +7 -1
  14. package/public/shared/events.js +1 -0
  15. package/public/shared/features.js +1 -0
  16. package/public/shared/origins.js +1 -0
  17. package/public/shared/usage.js +16 -1
  18. package/public/styles.css +83 -20
  19. package/src/access/auth.js +155 -155
  20. package/src/access/auth_gate.js +39 -38
  21. package/src/access/email.js +17 -0
  22. package/src/access/policy.js +123 -138
  23. package/src/audit.js +1 -1
  24. package/src/automations/automation.js +135 -123
  25. package/src/automations/scheduler.js +138 -127
  26. package/src/chat/compact.js +149 -156
  27. package/src/chat/llm.js +380 -382
  28. package/src/chat/runs.js +162 -151
  29. package/src/chat/tool_executor.js +229 -218
  30. package/src/cli.js +535 -433
  31. package/src/config/approval.js +35 -29
  32. package/src/config/config.js +546 -451
  33. package/src/config/data_dir.js +24 -0
  34. package/src/config/models.js +191 -115
  35. package/src/config/refs.js +32 -0
  36. package/src/config/secrets.js +153 -0
  37. package/src/files/documents.js +57 -68
  38. package/src/files/images.js +30 -31
  39. package/src/harness/agent.js +60 -60
  40. package/src/harness/attribution.js +66 -58
  41. package/src/harness/checkpoint.js +67 -62
  42. package/src/harness/history.js +28 -23
  43. package/src/harness/prompt.js +40 -57
  44. package/src/harness/provider.js +284 -283
  45. package/src/harness/types.js +1 -0
  46. package/src/http.js +98 -66
  47. package/src/log.js +11 -12
  48. package/src/mcp.js +398 -374
  49. package/src/retrieval/embedding.js +228 -240
  50. package/src/retrieval/retrieval.js +182 -173
  51. package/src/routes/admin.js +178 -138
  52. package/src/routes/auth.js +184 -100
  53. package/src/routes/automations.js +99 -82
  54. package/src/routes/chats.js +240 -218
  55. package/src/routes/documents.js +130 -120
  56. package/src/routes/library.js +43 -46
  57. package/src/routes/mcp.js +49 -49
  58. package/src/routes/settings.js +127 -122
  59. package/src/routes/types.js +1 -0
  60. package/src/server.js +428 -392
  61. package/src/store/automations.js +106 -106
  62. package/src/store/chats.js +217 -242
  63. package/src/store/documents.js +181 -185
  64. package/src/store/embeddings.js +52 -54
  65. package/src/store/index.js +95 -49
  66. package/src/store/messages.js +55 -80
  67. package/src/store/schema.js +128 -107
  68. package/src/store/scope.js +18 -17
  69. package/src/store/search.js +25 -27
  70. package/src/store/turns.js +53 -53
  71. package/src/store/usage.js +343 -269
  72. package/src/store/users.js +162 -87
  73. package/src/store/wire.js +50 -41
  74. package/src/tools/ask_tool.js +68 -68
  75. package/src/tools/chat_search_tool.js +38 -44
  76. package/src/tools/context_tool.js +131 -138
  77. package/src/tools/document_tool.js +73 -78
  78. package/src/tools/mcp_tool.js +274 -232
  79. package/src/tools/task_tool.js +53 -50
  80. package/src/tools/types.js +1 -0
  81. package/public/panels/connectors.js +0 -184
  82. package/src/set-password.js +0 -43
@@ -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
- '.txt', '.md', '.markdown', '.csv', '.tsv', '.json', '.log',
11
- '.js', '.ts', '.jsx', '.tsx', '.py', '.rb', '.go', '.rs', '.java', '.c', '.cpp', '.h',
12
- '.css', '.html', '.xml', '.yaml', '.yml', '.sh', '.sql'
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
- const i = filename.lastIndexOf('.');
17
- return i === -1 ? '' : filename.slice(i).toLowerCase();
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
- const ext = extOf(filename);
27
- if (ext === '.pdf') return { type: 'application/pdf', inline: true };
28
- if (TEXT_EXTS.has(ext) || ext === '') return { type: 'text/plain; charset=utf-8', inline: true };
29
- return { type: 'application/octet-stream', inline: false };
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
- const ext = extOf(filename);
39
-
40
- if (ext === '.pdf') {
41
- // unpdf ships its own serverless PDF.js build and needs no native canvas
42
- // binary for plain text extraction, unlike pdf-parse v2 (which pulls in
43
- // @napi-rs/canvas even when nothing here ever renders a page).
44
- let text;
45
- try {
46
- const { extractText, getDocumentProxy } = await import('unpdf');
47
- const pdf = await getDocumentProxy(new Uint8Array(buf));
48
- ({ text } = await extractText(pdf, { mergePages: true }));
49
- } catch (err) {
50
- // pdf.js's own exception names (PasswordException, and a handful of
51
- // "invalid structure" variants for anything that isn't a well-formed
52
- // PDF) are not something a user should have to decode -- the two cases
53
- // worth telling apart are password protection and a file that just
54
- // isn't a readable PDF at all.
55
- if (err?.name === 'PasswordException' || /password/i.test(err?.message || '')) {
56
- throw new Error('this PDF is password-protected -- remove the password and re-upload');
57
- }
58
- throw new Error(`could not read this PDF (${err?.message || 'corrupt file, or not actually a PDF'})`);
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 (!text.trim()) {
61
- // Parsed cleanly but yielded nothing: near-certainly a scanned or
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
- return text;
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
- return value;
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
  }
@@ -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
- if (!avifReady) {
13
- const wasmPath = fileURLToPath(import.meta.resolve('@jsquash/avif/codec/dec/avif_dec.wasm'));
14
- avifReady = WebAssembly.compile(readFileSync(wasmPath)).then((mod) => initAvif(mod));
15
- }
16
- return avifReady;
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
- if (buf.length < 12 || buf.toString('ascii', 4, 8) !== 'ftyp') return null;
29
- const brand = buf.toString('ascii', 8, 12);
30
- if (['avif', 'avis'].includes(brand)) return 'avif';
31
- if (['heic', 'heix', 'hevc', 'hevx', 'heim', 'heis', 'hevm', 'hevs', 'mif1', 'msf1'].includes(brand)) return 'heic';
32
- return null;
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
- const out = await heicConvert({ buffer: buf, format: 'JPEG', quality: 0.9 });
37
- return Buffer.from(out);
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
- await ensureAvif();
42
- const image = await decodeAvif(buf);
43
- const out = jpeg.encode(image, 90);
44
- return Buffer.from(out.data);
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
- const declared = String(reportedMime || '').toLowerCase();
54
- if (WIRE_MIMES.has(declared)) return { mime: declared, data: buf };
55
-
56
- const kind = sniff(buf) || (declared === 'image/heic' || declared === 'image/heif' ? 'heic'
57
- : declared === 'image/avif' ? 'avif' : null);
58
-
59
- if (kind === 'heic') return { mime: 'image/jpeg', data: await fromHeic(buf) };
60
- if (kind === 'avif') return { mime: 'image/jpeg', data: await fromAvif(buf) };
61
- return null;
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
  }
@@ -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
- const working = [...messages];
31
- const appended = [];
32
- const emit = runtime.onEvent || (() => {});
33
-
34
- // One extra pass past the budget: tools are mechanically refused there, so the
35
- // model spends it writing an answer instead of leaving the turn unfinished.
36
- for (let round = 0; round <= maxRounds; round++) {
37
- const lastCall = round === maxRounds;
38
- if (lastCall) {
39
- emit({
40
- type: EVENT.NOTICE,
41
- text: `Tool budget spent (${maxRounds} rounds) — answering with what was gathered.`
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
- for (const hook of list || []) {
109
- if (ctx.signal?.aborted) throw new Error('Stopped.');
110
- const out = await hook(ctx);
111
- if (ctx.signal?.aborted) throw new Error('Stopped.');
112
- if (out && each(out)) return true;
113
- }
114
- return false;
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 = null) => ({ category, tokens, source, ...(capability ? { 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
- ? content.filter((p) => p.type === 'text').map((p) => p.text || '').join('') : '';
11
-
9
+ ? content.filter((p) => p.type === 'text').map((p) => p.text || '').join('') : '';
12
10
  export function attributeRequest(body, { systemParts = [], owner = () => 'built-ins' } = {}) {
13
- const buckets = [];
14
- const add = (category, value, capability) => {
15
- if (value) buckets.push(bucket(category, estimate(value), 'estimated', capability));
16
- };
17
- let systemAttributed = false;
18
- for (const message of body.messages || []) {
19
- if (message.role === 'system') {
20
- const actual = text(message.content);
21
- if (!systemAttributed && actual === systemParts.map((p) => p.text).filter(Boolean).join('\n\n')) {
22
- for (const part of systemParts) add(part.category, part.text, part.capability);
23
- systemAttributed = true;
24
- } else add('system_other', actual);
25
- continue;
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 category = message.role === 'user' ? 'user_messages'
28
- : message.role === 'tool' ? 'tool_results' : 'assistant_history';
29
- add(category, text(message.content));
30
- for (const part of Array.isArray(message.content) ? message.content : []) {
31
- if (part.type === 'image_url') buckets.push(bucket('image_allowance', 1500));
32
- else if (part.type !== 'text') add('attachments_other', part);
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
- if (message.tool_calls) add('tool_history', message.tool_calls);
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
- return { total, source: total === null ? 'unreported' : 'provider', buckets: total === null ? buckets
51
- : [...buckets, bucket('provider_delta', total - sum(buckets), 'estimated')] };
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
- const output = [];
56
- const reportedReasoning = count(usage?.completion_tokens_details?.reasoning_tokens ?? usage?.output_tokens_details?.reasoning_tokens ?? usage?.reasoning_tokens);
57
- if (reportedReasoning !== null) output.push(bucket('reasoning', reportedReasoning, 'provider'));
58
- else {
59
- const visibleReasoning = reasoning || details.map((d) => d.text || d.summary || '').join('');
60
- if (visibleReasoning) output.push(bucket('reasoning', estimate(visibleReasoning)));
61
- }
62
- if (toolCalls.length) output.push(bucket('tool_generation', estimate(toolCalls.map((c) => c.function))));
63
- if (content) output.push(bucket('intermediate_text', estimate(content)));
64
- return {
65
- version: 1, estimator: 'characters/4; images 1500 tokens each',
66
- input: reconcile(input.buckets, inputTokens(usage)),
67
- output: reconcile(output, outputTokens(usage)),
68
- cached: cacheReads(usage)
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
- return { ...attribution, output: { ...attribution.output, buckets: attribution.output.buckets.map((b) =>
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
  }