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
@@ -2,221 +2,225 @@ import { readFileSync, existsSync, writeFileSync, chmodSync } from 'node:fs';
2
2
  import { resolve, dirname } from 'node:path';
3
3
  import { randomBytes } from 'node:crypto';
4
4
  import { FILE_ONLY, mergeAccess, keyClass, validateConfig } from '../access/policy.js';
5
- import { modelProblems, connectorProblems, labelFor, REASONING_REPLAY } from './models.js';
5
+ import { modelProblems, connectorProblems, labelFor, flattenCatalog, nestCatalog, hasEndpoint, defaultModel, REASONING_REPLAY } from './models.js';
6
6
  import { logger } from '../log.js';
7
-
7
+ import { applySecrets, extractSecrets, mergeSecrets, refProblems, takeSecrets } from './secrets.js';
8
8
  const log = logger('config');
9
-
10
9
  const CONFIG_FILE = process.env.TINYWEBUI_CONFIG
11
- ? resolve(process.env.TINYWEBUI_CONFIG)
12
- : resolve(process.cwd(), 'tinywebui.config.json');
13
-
10
+ ? resolve(process.env.TINYWEBUI_CONFIG)
11
+ : resolve(process.cwd(), 'tinywebui.config.json');
14
12
  // MCP wiring lives in its own Claude-Desktop-shaped file, next to the config.
15
13
  const MCP_FILE = process.env.TINYWEBUI_MCP
16
- ? resolve(process.env.TINYWEBUI_MCP)
17
- : resolve(dirname(CONFIG_FILE), 'mcp.json');
18
-
14
+ ? resolve(process.env.TINYWEBUI_MCP)
15
+ : resolve(dirname(CONFIG_FILE), 'mcp.json');
16
+ // Keys and the session secret: kept out of the config file (see secrets.ts).
17
+ const SECRETS_FILE = process.env.TINYWEBUI_SECRETS
18
+ ? resolve(process.env.TINYWEBUI_SECRETS)
19
+ : resolve(dirname(CONFIG_FILE), 'tinywebui.secrets.json');
19
20
  export const DEFAULTS = {
20
- baseUrl: 'https://openrouter.ai/api/v1',
21
- apiKey: '',
22
- // A cheap, fast, tool-capable default that caches well on a stable prefix and
23
- // needs no cache_control fields. Any OpenAI-compatible model id works.
24
- model: 'deepseek/deepseek-v4.1-flash',
25
- // The model catalog (see src/config/models.js). Empty: any model id goes. Listed:
26
- // only these can be picked, `model` names one by id, and people see each
27
- // entry's label, with its own prompt and sampling settings if it has them.
28
- models: [],
29
- // Named endpoints a catalog entry can point at (`connector: <id>`): each has
30
- // its own baseUrl and apiKey. Entries without one use baseUrl/apiKey above.
31
- connectors: [],
32
- systemPrompt: [
33
- 'You are a direct, technically precise assistant.',
34
- '',
35
- 'Answer the question that was asked. Lead with the answer, then the reasoning that',
36
- 'changes what the reader does -- no preamble, no restating the question, no summary',
37
- 'of what you just said. Match length to the question: a one-line question gets a',
38
- 'one-line answer; only go long when the task itself has that much surface area.',
39
- '',
40
- 'Use the tools available to you rather than guessing or asking the user for something',
41
- 'a tool can tell you. Call a tool immediately when it can resolve the question --',
42
- 'don\'t ask permission to look something up or run a read-only check. If a tool',
43
- 'fails, say what failed and what you did instead, rather than silently retrying',
44
- 'or inventing a plausible-looking result. If you still need to call a tool, make',
45
- 'the call on its own and hold your answer until the result is back -- writing the',
46
- 'full answer in the same reply as a pending tool call forces a wasted extra round',
47
- 'just to say there is nothing left to add.',
48
- '',
49
- 'Say plainly when you are unsure or when something is outside what you can verify.',
50
- 'Never invent APIs, flags, file paths, figures or citations.',
51
- '',
52
- 'Use markdown only where it earns its keep: code in fenced blocks with a language',
53
- 'tag, tables for genuinely tabular data, lists for genuinely parallel items. Don\'t',
54
- 'reach for headers, bold, or bullets to dress up a short answer that reads fine as',
55
- 'plain sentences.'
56
- ].join('\n'),
57
- // null = let the provider decide. Setting max_tokens in particular silently
58
- // truncates on models that would happily write more.
59
- temperature: null,
60
- maxTokens: null,
61
- // How many rounds of tool calls one message may trigger before the loop is
62
- // cut off. Raise it for deep research, lower it to cap spend per message.
63
- maxToolRounds: 12,
64
- // How long an ask_user question waits for an answer before the run carries
65
- // on without one, on the model's own assumptions. 0 waits until the user
66
- // answers or stops the run.
67
- askUserTimeoutSeconds: 120,
68
- // Merged into every completion request. Gateway-specific knobs live here.
69
- // TinyWebUI adds OpenRouter session_id automatically; setting provider.order
70
- // yourself takes precedence over sticky routing, so only pin providers when
71
- // you deliberately want that behavior.
72
- extraBody: {},
73
- // Prompt caching is on by default, and for most backends -- including every
74
- // local runtime -- that means nothing more than a stable request prefix,
75
- // which costs nothing and needs no provider support.
76
- cache: true,
77
- // Which cache dialect to speak. 'auto' infers it from the endpoint and model
78
- // and is right for OpenRouter and for anything local; set it explicitly when
79
- // you are on a gateway TinyWebUI has not been taught about.
80
- // 'auto' infer (default)
81
- // 'implicit' stable prefix only, no cache fields on the wire
82
- // 'explicit' Anthropic-style cache_control breakpoints in message content
83
- // 'rolling' OpenRouter top-level automatic cache_control
84
- // 'off' no cache shaping at all
85
- cacheMode: 'auto',
86
- // Anthropic cache writes default to five minutes. One hour costs more to write
87
- // but is useful for research chats with pauses between turns.
88
- cacheTtl: '5m',
89
- // Which reasoning past assistant turns send back: 'auto' picks per endpoint
90
- // (OpenRouter and Anthropic-style: reasoning_details; api.deepseek.com:
91
- // reasoning_content on tool-call turns; anything else: none).
92
- reasoningReplay: 'auto',
93
- // Where conversations live. Tool output is kept whole here and only the
94
- // compacted form goes on the wire, so this file grows faster than the window.
95
- dbPath: '',
96
- // Context compaction. Once the conversation history (system prompt and tool
97
- // definitions excluded) crosses `compactThreshold` tokens, tool results older
98
- // than the last `keepTurns` user messages are demoted to stubs -- once, and
99
- // then frozen, so the cost is a single cache miss rather than a rewritten
100
- // prefix on every turn. Set the threshold to 0 to disable.
101
- compactThreshold: 60000,
102
- keepTurns: 2,
103
- // An epoch is a full cache miss, so it only happens when stubbing would
104
- // remove at least this many characters from the window.
105
- compactMinSaved: 20000,
106
- // A single result larger than this is stubbed for every LATER turn the
107
- // moment it arrives; the turn that fetched it still reads it whole. High
108
- // enough that an ordinary page or search result stays readable for a
109
- // follow-up question without an expand_context round.
110
- maxInlineChars: 30000,
111
- // Hard cap: a result larger than this is stubbed even for the turn that
112
- // fetched it, since it would otherwise be resent in full on every remaining
113
- // round and can blow the window on its own.
114
- maxTurnChars: 120000,
115
- // Hard window. If the history is still larger than this many tokens after
116
- // compaction -- a long plain-text chat, where there is nothing to stub --
117
- // the oldest turns stop being sent (they stay in the transcript), cutting
118
- // back to half this size so it moves rarely. 0 disables it.
119
- maxHistoryTokens: 100000,
120
- // When that window drops turns, summarise them with the chat's model first
121
- // and give the model the summary in their place. Off: they are just dropped.
122
- llmCompaction: true,
123
- // Output cap for that summary request. Reasoning models spend part of it
124
- // thinking before they write, and a reply cut off by the cap is rejected,
125
- // so this is headroom; the prompt itself asks for a short summary.
126
- compactionMaxTokens: 8192,
127
- // IANA zone the model is told the date in (and uses for automations). Empty
128
- // means the server's own zone.
129
- timezone: '',
130
- // Which tool calls wait for the user (see src/config/approval.js).
131
- // 'writes' ask before any call not declared read-only (default)
132
- // 'all' ask before every call
133
- // 'off' never ask
134
- // Scheduled runs have no one to ask, so a call that would ask is refused.
135
- toolApproval: 'writes',
136
- // Per-tool overrides, by flat name: always ask / never ask.
137
- confirmTools: [],
138
- autoApproveTools: [],
139
- // Cap on what one expand_context call may return.
140
- expandCharBudget: 8000,
141
- // Flat tool names switched off from the settings panel -- built-in or an
142
- // MCP server's, named exactly as buildBody sends them. Filtered out of what
143
- // is offered to the model; a server can still be reached by name, so this
144
- // is a per-tool block, not a substitute for disabling the whole server.
145
- disabledTools: [],
146
- // 'none' explicit local-only use without login
147
- // 'single' just you, behind a password (authPassword or
148
- // $TINYWEBUI_PASSWORD; set it with `tinywebui set-password`)
149
- // 'trusted-header' many users: an upstream gateway (Cloudflare Access,
150
- // oauth2-proxy, Authelia, ...) signs people in with
151
- // Google, Apple, SSO etc. and passes identity headers
152
- authMode: 'single',
153
- // Level 'single': an scrypt hash (never plaintext), from `tinywebui
154
- // set-password`; never writable through /api/config.
155
- authPassword: '',
156
- // Signs session cookies. Auto-generated on first run when auth is enabled
157
- // and this is empty; never sent to the frontend.
158
- sessionSecret: '',
159
- // Reserved for a native OAuth login ('multiuser'), not implemented yet --
160
- // use 'trusted-header' behind a gateway for Google/Apple/SSO sign-in.
161
- googleClientId: '',
162
- googleClientSecret: '',
163
- googleRedirectUri: '',
164
- // Emails auto-approved as admin the first time they sign in -- how the
165
- // first admin account is bootstrapped.
166
- adminEmails: [],
167
- sessionTtlDays: 30,
168
- // Level 'trusted-header': an upstream gateway (Cloudflare Access, oauth2-
169
- // proxy, ...) authenticates and injects identity headers on every request.
170
- // Headers are believed only from peers inside trustedProxyCidrs; startup
171
- // refuses an empty list. Users are provisioned on first sight.
172
- trustedProxyCidrs: [],
173
- trustedUserIdHeader: 'x-tinysuite-user-id',
174
- trustedEmailHeader: 'x-tinysuite-email',
175
- trustedNameHeader: 'x-tinysuite-name',
176
- trustedRoleHeader: 'x-tinysuite-role',
177
- // Where "log out" sends the browser (e.g. the Access logout URL).
178
- logoutUrl: '',
179
- // Extra origins allowed to send state-changing requests (e.g. an admin
180
- // tool on another host). The page's own origin is always allowed.
181
- allowedOrigins: [],
182
- // false: refuse to start on a database that needs migrating; run
183
- // `tinywebui migrate` as its own deployment step instead.
184
- autoMigrate: true,
185
- // true: nothing in the control plane can change from the UI or API --
186
- // settings, tools, MCP servers. Change the files and reload instead.
187
- frozen: false,
188
- // read_document and search_chats ranking. 'lexical' is SQLite FTS5 BM25 and
189
- // needs nothing extra. 'dense' and 'hybrid' use a local ONNX embedding
190
- // bundle (fetched by `npm start` / `tinywebui models ensure`, or baked into
191
- // the Docker image -- never by the server) and the onnxruntime-node +
192
- // @huggingface/tokenizers optional dependencies. 'auto' is hybrid when both
193
- // are there and lexical, with a warning, when they are not.
194
- retrieval: {
195
- mode: 'auto', // auto | lexical | dense | hybrid
196
- model: 'fast', // fast | balanced | quality (TinySearch's presets) | multilingual, or a name with modelDir
197
- modelDir: '', // bundle folder; default models/<preset> next to the config
198
- modelSha256: '', // pin model.onnx; startup refuses a different file
199
- denseWeight: 0.5, // hybrid: dense share of the fused ranking, BM25 gets the rest
200
- rrfK: 60,
201
- queryPrefix: '', // e.g. bge: 'Represent this sentence for searching relevant passages: '
202
- documentPrefix: '',
203
- // Small-to-big: PASSAGES are what read_document returns (every mode);
204
- // CHUNKS are what gets embedded -- as long as the model reads (256
205
- // tokens for fast, 512 for bge), so only their overlap is set here.
206
- passageSize: 1800, // characters per passage
207
- passageOverlap: 200, // characters shared by neighbouring passages
208
- chunkOverlap: 32, // tokens shared by neighbouring chunks
209
- },
21
+ // The endpoint of a model that names no connector. Unset: there is none,
22
+ // and a fresh instance is set up by adding a connector, then its models.
23
+ // Code and the environment ($TINYWEBUI_BASE_URL) may still set one.
24
+ baseUrl: '',
25
+ apiKey: '',
26
+ // The default model: a catalog id, or with an empty catalog any model id.
27
+ model: '',
28
+ // The model catalog (see src/config/models.js). Empty everywhere: any model
29
+ // id goes. Listed: only these can be picked, `model` names one by id, and
30
+ // people see each entry's label. An entry may set its own prompt, sampling
31
+ // and caching; the top-level values below are what it inherits.
32
+ models: [],
33
+ // Named endpoints, each with its own baseUrl, optional apiKey and `models` list.
34
+ connectors: [],
35
+ systemPrompt: [
36
+ 'You are a direct, technically precise assistant.',
37
+ '',
38
+ 'Answer the question that was asked. Lead with the answer, then the reasoning that',
39
+ 'changes what the reader does -- no preamble, no restating the question, no summary',
40
+ 'of what you just said. Match length to the question: a one-line question gets a',
41
+ 'one-line answer; only go long when the task itself has that much surface area.',
42
+ '',
43
+ 'Use the tools available to you rather than guessing or asking the user for something',
44
+ 'a tool can tell you. Call a tool immediately when it can resolve the question --',
45
+ 'don\'t ask permission to look something up or run a read-only check. If a tool',
46
+ 'fails, say what failed and what you did instead, rather than silently retrying',
47
+ 'or inventing a plausible-looking result. If you still need to call a tool, make',
48
+ 'the call on its own and hold your answer until the result is back -- writing the',
49
+ 'full answer in the same reply as a pending tool call forces a wasted extra round',
50
+ 'just to say there is nothing left to add.',
51
+ '',
52
+ 'Say plainly when you are unsure or when something is outside what you can verify.',
53
+ 'Never invent APIs, flags, file paths, figures or citations.',
54
+ '',
55
+ 'Use markdown only where it earns its keep: code in fenced blocks with a language',
56
+ 'tag, tables for genuinely tabular data, lists for genuinely parallel items. Don\'t',
57
+ 'reach for headers, bold, or bullets to dress up a short answer that reads fine as',
58
+ 'plain sentences.'
59
+ ].join('\n'),
60
+ // null = let the provider decide. Setting max_tokens in particular silently
61
+ // truncates on models that would happily write more.
62
+ temperature: null,
63
+ maxTokens: null,
64
+ // How many rounds of tool calls one message may trigger before the loop is
65
+ // cut off. Raise it for deep research, lower it to cap spend per message.
66
+ maxToolRounds: 12,
67
+ // How long an ask_user question waits for an answer before the run carries
68
+ // on without one, on the model's own assumptions. 0 waits until the user
69
+ // answers or stops the run.
70
+ askUserTimeoutSeconds: 120,
71
+ // Merged into every completion request. Gateway-specific knobs live here.
72
+ // TinyWebUI adds OpenRouter session_id automatically; setting provider.order
73
+ // yourself takes precedence over sticky routing, so only pin providers when
74
+ // you deliberately want that behavior.
75
+ extraBody: {},
76
+ // Prompt caching is on by default, and for most backends -- including every
77
+ // local runtime -- that means nothing more than a stable request prefix,
78
+ // which costs nothing and needs no provider support.
79
+ cache: true,
80
+ // Which cache dialect to speak. 'auto' infers it from the endpoint and model
81
+ // and is right for OpenRouter and for anything local; set it explicitly when
82
+ // you are on a gateway TinyWebUI has not been taught about.
83
+ // 'auto' infer (default)
84
+ // 'implicit' stable prefix only, no cache fields on the wire
85
+ // 'explicit' Anthropic-style cache_control breakpoints in message content
86
+ // 'rolling' OpenRouter top-level automatic cache_control
87
+ // 'off' no cache shaping at all
88
+ cacheMode: 'auto',
89
+ // Anthropic cache writes default to five minutes. One hour costs more to write
90
+ // but is useful for research chats with pauses between turns.
91
+ cacheTtl: '5m',
92
+ // Which reasoning past assistant turns send back: 'auto' picks per endpoint
93
+ // (OpenRouter and Anthropic-style: reasoning_details; api.deepseek.com:
94
+ // reasoning_content on tool-call turns; anything else: none).
95
+ reasoningReplay: 'auto',
96
+ // Where conversations live. Tool output is kept whole here and only the
97
+ // compacted form goes on the wire, so this file grows faster than the window.
98
+ dbPath: '',
99
+ // Context compaction. Once the conversation history (system prompt and tool
100
+ // definitions excluded) crosses `compactThreshold` tokens, tool results older
101
+ // than the last `keepTurns` user messages are demoted to stubs -- once, and
102
+ // then frozen, so the cost is a single cache miss rather than a rewritten
103
+ // prefix on every turn. Set the threshold to 0 to disable.
104
+ compactThreshold: 60000,
105
+ keepTurns: 2,
106
+ // An epoch is a full cache miss, so it only happens when stubbing would
107
+ // remove at least this many characters from the window.
108
+ compactMinSaved: 20000,
109
+ // A single result larger than this is stubbed for every LATER turn the
110
+ // moment it arrives; the turn that fetched it still reads it whole. High
111
+ // enough that an ordinary page or search result stays readable for a
112
+ // follow-up question without an expand_context round.
113
+ maxInlineChars: 30000,
114
+ // Hard cap: a result larger than this is stubbed even for the turn that
115
+ // fetched it, since it would otherwise be resent in full on every remaining
116
+ // round and can blow the window on its own.
117
+ maxTurnChars: 120000,
118
+ // Hard window. If the history is still larger than this many tokens after
119
+ // compaction -- a long plain-text chat, where there is nothing to stub --
120
+ // the oldest turns stop being sent (they stay in the transcript), cutting
121
+ // back to half this size so it moves rarely. 0 disables it.
122
+ maxHistoryTokens: 100000,
123
+ // When that window drops turns, summarise them with the chat's model first
124
+ // and give the model the summary in their place. Off: they are just dropped.
125
+ llmCompaction: true,
126
+ // Output cap for that summary request. Reasoning models spend part of it
127
+ // thinking before they write, and a reply cut off by the cap is rejected,
128
+ // so this is headroom; the prompt itself asks for a short summary.
129
+ compactionMaxTokens: 8192,
130
+ // IANA zone the model is told the date in (and uses for automations). Empty
131
+ // means the server's own zone.
132
+ timezone: '',
133
+ // Which tool calls wait for the user (see src/config/approval.js).
134
+ // 'writes' ask before any call not declared read-only (default)
135
+ // 'all' ask before every call
136
+ // 'off' never ask
137
+ // Scheduled runs have no one to ask, so a call that would ask is refused.
138
+ toolApproval: 'writes',
139
+ // Per-tool overrides, by flat name: always ask / never ask.
140
+ confirmTools: [],
141
+ autoApproveTools: [],
142
+ // Cap on what one expand_context call may return.
143
+ expandCharBudget: 8000,
144
+ // Flat tool names switched off from the settings panel -- built-in or an
145
+ // MCP server's, named exactly as buildBody sends them. Filtered out of what
146
+ // is offered to the model; a server can still be reached by name, so this
147
+ // is a per-tool block, not a substitute for disabling the whole server.
148
+ disabledTools: [],
149
+ // 'local' explicit local-only use without login
150
+ // 'multiuser' local accounts: first signup is admin; later signups
151
+ // require an invitation from an admin
152
+ // 'trusted-header' many users: an upstream gateway (Cloudflare Access,
153
+ // oauth2-proxy, Authelia, ...) signs people in with
154
+ // Google, Apple, SSO etc. and passes identity headers
155
+ authMode: 'multiuser',
156
+ // Kept only so old password configurations can be rejected rather than
157
+ // silently opened without login after upgrading.
158
+ authPassword: '',
159
+ // Signs session cookies. Auto-generated on first run when auth is enabled
160
+ // and this is empty; never sent to the frontend.
161
+ sessionSecret: '',
162
+ // Reserved for a future native OAuth login; local multi-user accounts use
163
+ // email and password today.
164
+ googleClientId: '',
165
+ googleClientSecret: '',
166
+ googleRedirectUri: '',
167
+ // Emails auto-approved as admin the first time they sign in -- how the
168
+ // first admin account is bootstrapped.
169
+ adminEmails: [],
170
+ sessionTtlDays: 30,
171
+ // Level 'trusted-header': an upstream gateway (Cloudflare Access, oauth2-
172
+ // proxy, ...) authenticates and injects identity headers on every request.
173
+ // Headers are believed only from peers inside trustedProxyCidrs; startup
174
+ // refuses an empty list. Users are provisioned on first sight.
175
+ trustedProxyCidrs: [],
176
+ trustedUserIdHeader: 'x-tinysuite-user-id',
177
+ trustedEmailHeader: 'x-tinysuite-email',
178
+ trustedNameHeader: 'x-tinysuite-name',
179
+ trustedRoleHeader: 'x-tinysuite-role',
180
+ // Where "log out" sends the browser (e.g. the Access logout URL).
181
+ logoutUrl: '',
182
+ // Extra origins allowed to send state-changing requests (e.g. an admin
183
+ // tool on another host). The page's own origin is always allowed.
184
+ allowedOrigins: [],
185
+ // Extra Host names answered when authMode is 'local' (e.g. a LAN name for a
186
+ // local-only install). Loopback names are always allowed.
187
+ allowedHosts: [],
188
+ // false: refuse to start on a database that needs migrating; run
189
+ // `tinywebui migrate` as its own deployment step instead.
190
+ autoMigrate: true,
191
+ // true: nothing in the control plane can change from the UI or API --
192
+ // settings, tools, MCP servers. Change the files and reload instead.
193
+ frozen: false,
194
+ // read_document and search_chats ranking. 'lexical' is SQLite FTS5 BM25 and
195
+ // needs nothing extra. 'dense' and 'hybrid' use a local ONNX embedding
196
+ // bundle (fetched by `npm start` / `tinywebui models ensure`, or baked into
197
+ // the Docker image -- never by the server) and the onnxruntime-node +
198
+ // @huggingface/tokenizers optional dependencies. 'auto' is hybrid when both
199
+ // are there and lexical, with a warning, when they are not.
200
+ retrieval: {
201
+ mode: 'auto', // auto | lexical | dense | hybrid
202
+ model: 'fast', // fast | balanced | quality (TinySearch's presets) | multilingual, or a name with modelDir
203
+ modelDir: '', // bundle folder; default models/<preset> next to the config
204
+ modelSha256: '', // pin model.onnx; startup refuses a different file
205
+ denseWeight: 0.5, // hybrid: dense share of the fused ranking, BM25 gets the rest
206
+ rrfK: 60,
207
+ queryPrefix: '', // e.g. bge: 'Represent this sentence for searching relevant passages: '
208
+ documentPrefix: '',
209
+ // Small-to-big: PASSAGES are what read_document returns (every mode);
210
+ // CHUNKS are what gets embedded -- as long as the model reads (256
211
+ // tokens for fast, 512 for bge), so only their overlap is set here.
212
+ passageSize: 1800, // characters per passage
213
+ passageOverlap: 200, // characters shared by neighbouring passages
214
+ chunkOverlap: 32, // tokens shared by neighbouring chunks
215
+ },
210
216
  };
211
-
212
217
  // What an install with no mcp.json starts with: the TinySuite servers, which
213
218
  // uv fetches and launches on first use. Any mcp.json, even an empty one,
214
219
  // replaces them, so removing them is one save in the MCP panel.
215
220
  export const DEFAULT_MCP_SERVERS = Object.freeze({
216
- tinysearch: { command: 'uvx', args: ['--python', '3.12', '--from', 'tinysuite-search[server]', 'tinysearch'] },
217
- tinycontext: { command: 'uvx', args: ['--python', '3.12', '--from', 'tinysuite-context[server]', 'tinycontext'] },
221
+ tinysearch: { command: 'uvx', args: ['--python', '3.12', '--from', 'tinysuite-search[server]', 'tinysearch'] },
222
+ tinycontext: { command: 'uvx', args: ['--python', '3.12', '--from', 'tinysuite-context[server]', 'tinycontext'] },
218
223
  });
219
-
220
224
  /**
221
225
  * One instance's view of its configuration.
222
226
  *
@@ -227,163 +231,250 @@ export const DEFAULT_MCP_SERVERS = Object.freeze({
227
231
  * `mcpServers` in code likewise replaces mcp.json and locks the MCP editor.
228
232
  */
229
233
  export function createConfigSource(opts = {}) {
230
- const code = opts.config || {};
231
- const file = opts.configFile === false ? null
232
- : opts.configFile ? resolve(opts.configFile) : CONFIG_FILE;
233
- const mcpFile = opts.mcpFile ? resolve(opts.mcpFile)
234
- : file && file !== CONFIG_FILE ? resolve(dirname(file), 'mcp.json') : MCP_FILE;
235
- const codeServers = opts.mcpServers || null;
236
- const locked = new Set(Object.keys(code));
237
- let memory = {}; // stands in for the file when there is none
238
-
239
- const readFile = () => {
240
- if (!file) return { ...memory };
241
- if (!existsSync(file)) return {};
242
- try {
243
- return JSON.parse(readFileSync(file, 'utf8'));
244
- } catch (err) {
245
- throw new Error(`Bad config at ${file}: ${err.message}`);
246
- }
247
- };
248
- const writeFile = (obj) => {
249
- if (!file) { memory = obj; return; }
250
- if (existsSync(file) && process.platform !== 'win32') chmodSync(file, 0o600);
251
- writeFileSync(file, JSON.stringify(obj, null, 2) + '\n', { mode: 0o600 });
252
- };
253
-
254
- function load({ persistSecret = true } = {}) {
255
- const env = {};
256
- if (process.env.TINYWEBUI_BASE_URL) env.baseUrl = process.env.TINYWEBUI_BASE_URL;
257
- if (process.env.TINYWEBUI_API_KEY) env.apiKey = process.env.TINYWEBUI_API_KEY;
258
- if (process.env.OPENROUTER_API_KEY) env.apiKey = process.env.OPENROUTER_API_KEY;
259
- if (process.env.TINYWEBUI_MODEL) env.model = process.env.TINYWEBUI_MODEL;
260
- // Secrets can come from the environment, so committed config never holds them.
261
- if (process.env.TINYWEBUI_SESSION_SECRET) env.sessionSecret = process.env.TINYWEBUI_SESSION_SECRET;
262
-
263
- const fromFile = readFile();
264
- const cfg = { ...DEFAULTS, ...fromFile, ...env, ...code };
265
- if (fromFile.access !== undefined || code.access !== undefined) cfg.access = mergeAccess(fromFile.access, code.access);
266
- // Partial retrieval blocks fill in from the defaults rather than replace them.
267
- // $TINYWEBUI_RETRIEVAL_MODE=lexical is the no-config way to opt out of hybrid.
268
- const envRetrieval = process.env.TINYWEBUI_RETRIEVAL_MODE ? { mode: process.env.TINYWEBUI_RETRIEVAL_MODE } : {};
269
- cfg.retrieval = { ...DEFAULTS.retrieval, ...(fromFile.retrieval || {}), ...envRetrieval, ...(code.retrieval || {}) };
270
- // No silent fallbacks: a bad value is reported by configProblems() and
271
- // refuses to start, rather than quietly becoming a default (a mistyped
272
- // authMode used to become 'none').
273
- if (typeof cfg.baseUrl === 'string') cfg.baseUrl = cfg.baseUrl.replace(/\/+$/, '');
274
- // A session secret is required the moment auth is on; generate and persist
275
- // one rather than signing cookies with an empty key.
276
- if (cfg.authMode !== 'none' && !cfg.sessionSecret && persistSecret) {
277
- cfg.sessionSecret = randomBytes(32).toString('hex');
278
- try {
279
- writeFile({ ...readFile(), sessionSecret: cfg.sessionSecret });
280
- } catch (err) {
281
- log.error(`could not persist generated sessionSecret: ${err.message}`);
282
- }
234
+ const code = opts.config || {};
235
+ const file = opts.configFile === false ? null
236
+ : opts.configFile ? resolve(opts.configFile) : CONFIG_FILE;
237
+ const mcpFile = opts.mcpFile ? resolve(opts.mcpFile)
238
+ : file && file !== CONFIG_FILE ? resolve(dirname(file), 'mcp.json') : MCP_FILE;
239
+ const secretsFile = opts.secretsFile ? resolve(opts.secretsFile)
240
+ : !file ? null : file !== CONFIG_FILE ? resolve(dirname(file), 'tinywebui.secrets.json') : SECRETS_FILE;
241
+ const codeServers = opts.mcpServers || null;
242
+ const locked = new Set(Object.keys(code));
243
+ let memory = {}; // stands in for the file when there is none
244
+ let memorySecrets = {};
245
+ const readFile = () => {
246
+ if (!file)
247
+ return { ...memory };
248
+ if (!existsSync(file))
249
+ return {};
250
+ try {
251
+ return JSON.parse(readFileSync(file, 'utf8'));
252
+ }
253
+ catch (err) {
254
+ throw new Error(`Bad config at ${file}: ${err.message}`);
255
+ }
256
+ };
257
+ const writeFile = (obj) => {
258
+ if (!file) {
259
+ memory = obj;
260
+ return;
261
+ }
262
+ if (existsSync(file) && process.platform !== 'win32')
263
+ chmodSync(file, 0o600);
264
+ writeFileSync(file, JSON.stringify(obj, null, 2) + '\n', { mode: 0o600 });
265
+ };
266
+ const readSecrets = () => {
267
+ if (!secretsFile)
268
+ return structuredClone(memorySecrets);
269
+ if (!existsSync(secretsFile))
270
+ return {};
271
+ try {
272
+ return JSON.parse(readFileSync(secretsFile, 'utf8'));
273
+ }
274
+ catch (err) {
275
+ throw new Error(`Bad secrets file at ${secretsFile}: ${err.message}`);
276
+ }
277
+ };
278
+ const writeSecrets = (secrets) => {
279
+ if (!secretsFile) {
280
+ memorySecrets = secrets;
281
+ return;
282
+ }
283
+ if (existsSync(secretsFile) && process.platform !== 'win32')
284
+ chmodSync(secretsFile, 0o600);
285
+ writeFileSync(secretsFile, JSON.stringify(secrets, null, 2) + '\n', { mode: 0o600 });
286
+ };
287
+ // Not validated here: startup, reload and save check the result with configProblems().
288
+ function load({ persistSecret = true } = {}) {
289
+ const env = {};
290
+ if (process.env.TINYWEBUI_BASE_URL)
291
+ env.baseUrl = process.env.TINYWEBUI_BASE_URL;
292
+ if (process.env.TINYWEBUI_API_KEY)
293
+ env.apiKey = process.env.TINYWEBUI_API_KEY;
294
+ if (process.env.OPENROUTER_API_KEY)
295
+ env.apiKey = process.env.OPENROUTER_API_KEY;
296
+ if (process.env.TINYWEBUI_MODEL)
297
+ env.model = process.env.TINYWEBUI_MODEL;
298
+ // Secrets can come from the environment, so committed config never holds them.
299
+ if (process.env.TINYWEBUI_SESSION_SECRET)
300
+ env.sessionSecret = process.env.TINYWEBUI_SESSION_SECRET;
301
+ let fromFile = readFile();
302
+ // Keys written into the config file by hand, or by a version before the
303
+ // secrets file, move there on start. Read-only callers (the CLI) and a
304
+ // frozen deployment leave the files alone; the keys still apply.
305
+ const { shape, found, names } = extractSecrets(fromFile);
306
+ if (names.length && persistSecret) {
307
+ if (isFrozen())
308
+ log.warn(`${file ?? 'the config'} holds ${names.join(', ')}; move them to ${secretsFile} or the environment`);
309
+ else {
310
+ try {
311
+ writeSecrets(mergeSecrets(readSecrets(), found));
312
+ writeFile(shape);
313
+ fromFile = shape;
314
+ log.info(`moved ${names.join(', ')} from ${file} to ${secretsFile}`);
315
+ }
316
+ catch (err) {
317
+ log.error(`could not move secrets out of ${file}: ${err.message}`);
318
+ }
319
+ }
320
+ }
321
+ const cfg = { ...DEFAULTS, ...applySecrets(fromFile, readSecrets()), ...env, ...code };
322
+ if (fromFile.access !== undefined || code.access !== undefined)
323
+ cfg.access = mergeAccess(fromFile.access, code.access);
324
+ // Partial retrieval blocks fill in from the defaults rather than replace them.
325
+ // $TINYWEBUI_RETRIEVAL_MODE=lexical is the no-config way to opt out of hybrid.
326
+ const envRetrieval = process.env.TINYWEBUI_RETRIEVAL_MODE ? { mode: process.env.TINYWEBUI_RETRIEVAL_MODE } : {};
327
+ cfg.retrieval = { ...DEFAULTS.retrieval, ...(fromFile.retrieval || {}), ...envRetrieval, ...(code.retrieval || {}) };
328
+ // A mistyped authMode must fail validation rather than disable login.
329
+ if (typeof cfg.baseUrl === 'string')
330
+ cfg.baseUrl = cfg.baseUrl.replace(/\/+$/, '');
331
+ Object.assign(cfg, flattenCatalog(cfg));
332
+ cfg.model = defaultModel(cfg);
333
+ // A session secret is required the moment auth is on; generate and persist
334
+ // one rather than signing cookies with an empty key.
335
+ if (['multiuser', 'trusted-header'].includes(cfg.authMode) && !cfg.sessionSecret && persistSecret) {
336
+ cfg.sessionSecret = randomBytes(32).toString('hex');
337
+ try {
338
+ writeSecrets({ ...readSecrets(), sessionSecret: cfg.sessionSecret });
339
+ }
340
+ catch (err) {
341
+ log.error(`could not persist generated sessionSecret: ${err.message}`);
342
+ }
343
+ }
344
+ return cfg;
283
345
  }
284
- return cfg;
285
- }
286
-
287
- /** Applies the writable, unlocked part of `patch`; locked keys are an error. */
288
- function save(patch) {
289
- if (isFrozen()) throw new LockedError('this deployment is frozen: change its files instead');
290
- const fileOnly = Object.keys(patch).filter((k) => FILE_ONLY.has(k));
291
- if (fileOnly.length) throw new LockedError(`set in files only, not editable here: ${fileOnly.join(', ')}`);
292
- const blocked = Object.keys(patch).filter((k) => WRITABLE.has(k) && locked.has(k));
293
- if (blocked.length) throw new LockedError(`set in code, not editable here: ${blocked.join(', ')}`);
294
- const current = readFile();
295
- for (const [k, v] of Object.entries(mergeSecrets(patch, current))) {
296
- if (WRITABLE.has(k)) current[k] = v;
346
+ /** Applies the writable, unlocked part of `patch`; locked keys are an error. */
347
+ function save(patch) {
348
+ if (isFrozen())
349
+ throw new LockedError('this deployment is frozen: change its files instead');
350
+ const fileOnly = Object.keys(patch).filter((k) => FILE_ONLY.has(k));
351
+ if (fileOnly.length)
352
+ throw new LockedError(`set in files only, not editable here: ${fileOnly.join(', ')}`);
353
+ const blocked = Object.keys(patch).filter((k) => WRITABLE.has(k) && locked.has(k));
354
+ if (blocked.length)
355
+ throw new LockedError(`set in code, not editable here: ${blocked.join(', ')}`);
356
+ // Edits apply to the flat catalog the page sees; the file gets it nested.
357
+ const current = readFile();
358
+ Object.assign(current, flattenCatalog(current));
359
+ const split = takeSecrets(patch, current, readSecrets());
360
+ if (split.refused.length)
361
+ throw new LockedError(`set from the environment, not editable here: ${split.refused.join(', ')}`);
362
+ for (const [k, v] of Object.entries(split.patch)) {
363
+ if (!WRITABLE.has(k))
364
+ continue;
365
+ if (v === undefined)
366
+ delete current[k];
367
+ else
368
+ current[k] = v;
369
+ }
370
+ // Validate what the files would become before writing them.
371
+ const problems = configProblems({ ...DEFAULTS, ...applySecrets(current, split.secrets), ...code });
372
+ if (problems.length)
373
+ throw new ConfigError(problems.join('; '));
374
+ // Secrets first: a key is never lost between the two writes.
375
+ writeSecrets(split.secrets);
376
+ writeFile({ ...current, ...nestCatalog(current) });
377
+ return load();
297
378
  }
298
- // Validate what the file would become before writing it.
299
- const problems = configProblems({ ...DEFAULTS, ...current, ...code });
300
- if (problems.length) throw new Error(problems.join('; '));
301
- writeFile(current);
302
- return load();
303
- }
304
-
305
- // `frozen` may be set in code or in the file; code wins, like any key.
306
- function isFrozen() {
307
- if (code.frozen !== undefined) return Boolean(code.frozen);
308
- try { return Boolean(readFile().frozen); } catch { return false; }
309
- }
310
- const lockedNow = () => (isFrozen() ? new Set([...locked, ...WRITABLE]) : locked);
311
-
312
- const readMcp = () => {
313
- if (codeServers) return JSON.stringify({ mcpServers: codeServers }, null, 2) + '\n';
314
- if (!existsSync(mcpFile)) return JSON.stringify({ mcpServers: DEFAULT_MCP_SERVERS }, null, 2) + '\n';
315
- return readFileSync(mcpFile, 'utf8');
316
- };
317
-
318
- return {
319
- load,
320
- save,
321
- public: (cfg) => ({
322
- ...publicConfig(cfg), lockedKeys: [...lockedNow()], mcpLocked: Boolean(codeServers) || isFrozen(),
323
- keyClasses: Object.fromEntries([...WRITABLE, ...FILE_ONLY].map((k) => [k, keyClass(k, lockedNow())]))
324
- }),
325
- lockedKeys: () => new Set(lockedNow()),
326
- /** The raw mcp.json (or code) server map, throwing on bad JSON -- for tooling that must not guess. */
327
- strictMcpServers() {
328
- const parsed = JSON.parse(readMcp());
329
- return parsed.mcpServers || parsed;
330
- },
331
- /** Users pinned in code's access.users: admins cannot overrule these. */
332
- codeAccessUsers: () => ({ ...(code.access?.users || {}) }),
333
- /** Low-level access for the policy write-back: read and replace the JSON file. */
334
- readFile,
335
- writeFile,
336
- path: () => file,
337
- mcpPath: () => (codeServers ? null : mcpFile),
338
- get mcpLocked() { return Boolean(codeServers) || isFrozen(); },
339
- isFrozen,
340
- dbPath: (cfg) => {
341
- const set = opts.dbPath || process.env.TINYWEBUI_DB || cfg?.dbPath;
342
- if (set === ':memory:') return set;
343
- return set ? resolve(set) : resolve(dirname(file || CONFIG_FILE), 'tinywebui.db');
344
- },
345
- /** Raw text of mcp.json, so the editor round-trips comments-free but verbatim. */
346
- readMcpFile: readMcp,
347
- loadMcpServers() {
348
- try {
349
- const parsed = JSON.parse(readMcp());
350
- // Accept both {"mcpServers": {...}} and a bare {...} map.
351
- return parsed.mcpServers || parsed || {};
352
- } catch (err) {
353
- log.error(`${mcpFile} is not valid JSON: ${err.message}`);
354
- return {};
355
- }
356
- },
357
- /** Validates before writing, so a typo cannot leave an unparseable file behind. */
358
- saveMcpFile(text) {
359
- if (codeServers) throw new LockedError('MCP servers are set in code, not editable here');
360
- if (isFrozen()) throw new LockedError('this deployment is frozen: change mcp.json instead');
361
- const parsed = JSON.parse(text);
362
- const servers = parsed.mcpServers || parsed;
363
- for (const [name, spec] of Object.entries(servers)) {
364
- if (!spec || (!spec.command && !spec.url)) {
365
- throw new Error(`"${name}" needs either "command" (stdio) or "url" (http)`);
379
+ // `frozen` may be set in code or in the file; code wins, like any key.
380
+ function isFrozen() {
381
+ if (code.frozen !== undefined)
382
+ return Boolean(code.frozen);
383
+ try {
384
+ return Boolean(readFile().frozen);
366
385
  }
367
- }
368
- writeFileSync(mcpFile, text.endsWith('\n') ? text : text + '\n');
369
- return servers;
370
- },
371
- };
386
+ catch {
387
+ return false;
388
+ }
389
+ }
390
+ const lockedNow = () => (isFrozen() ? new Set([...locked, ...WRITABLE]) : locked);
391
+ const readMcp = () => {
392
+ if (codeServers)
393
+ return JSON.stringify({ mcpServers: codeServers }, null, 2) + '\n';
394
+ if (!existsSync(mcpFile))
395
+ return JSON.stringify({ mcpServers: DEFAULT_MCP_SERVERS }, null, 2) + '\n';
396
+ return readFileSync(mcpFile, 'utf8');
397
+ };
398
+ return {
399
+ load,
400
+ save,
401
+ public: (cfg) => ({
402
+ ...publicConfig(cfg), lockedKeys: [...lockedNow()], mcpLocked: Boolean(codeServers) || isFrozen(),
403
+ keyClasses: Object.fromEntries([...WRITABLE, ...FILE_ONLY].map((k) => [k, keyClass(k, lockedNow())]))
404
+ }),
405
+ lockedKeys: () => new Set(lockedNow()),
406
+ /** The raw mcp.json (or code) server map, throwing on bad JSON -- for tooling that must not guess. */
407
+ strictMcpServers() {
408
+ const parsed = JSON.parse(readMcp());
409
+ return parsed.mcpServers || parsed;
410
+ },
411
+ /** Users pinned in code's access.users: admins cannot overrule these. */
412
+ codeAccessUsers: () => ({ ...(code.access?.users || {}) }),
413
+ /** Low-level access for the policy write-back: read and replace the JSON file. */
414
+ readFile,
415
+ writeFile,
416
+ path: () => file,
417
+ secretsPath: () => secretsFile,
418
+ /** Secret values the config file holds as written, by name (they move out on start). */
419
+ secretsInFile: () => extractSecrets(readFile()).names,
420
+ mcpPath: () => (codeServers ? null : mcpFile),
421
+ get mcpLocked() { return Boolean(codeServers) || isFrozen(); },
422
+ isFrozen,
423
+ dbPath: (cfg) => {
424
+ const set = opts.dbPath || process.env.TINYWEBUI_DB || cfg?.dbPath;
425
+ if (set === ':memory:')
426
+ return set;
427
+ return set ? resolve(set) : resolve(dirname(file || CONFIG_FILE), 'tinywebui.db');
428
+ },
429
+ /** Raw text of mcp.json, so the editor round-trips comments-free but verbatim. */
430
+ readMcpFile: readMcp,
431
+ loadMcpServers() {
432
+ try {
433
+ const parsed = JSON.parse(readMcp());
434
+ // Accept both {"mcpServers": {...}} and a bare {...} map.
435
+ return parsed.mcpServers || parsed || {};
436
+ }
437
+ catch (err) {
438
+ log.error(`${mcpFile} is not valid JSON: ${err.message}`);
439
+ return {};
440
+ }
441
+ },
442
+ /** Validates before writing, so a typo cannot leave an unparseable file behind. */
443
+ saveMcpFile(text) {
444
+ if (codeServers)
445
+ throw new LockedError('MCP servers are set in code, not editable here');
446
+ if (isFrozen())
447
+ throw new LockedError('this deployment is frozen: change mcp.json instead');
448
+ const parsed = JSON.parse(text);
449
+ const servers = parsed.mcpServers || parsed;
450
+ for (const [name, spec] of Object.entries(servers)) {
451
+ if (!spec || (!spec.command && !spec.url)) {
452
+ throw new Error(`"${name}" needs either "command" (stdio) or "url" (http)`);
453
+ }
454
+ }
455
+ // Headers and env here can hold tokens: owner-only, like the config.
456
+ if (existsSync(mcpFile) && process.platform !== 'win32')
457
+ chmodSync(mcpFile, 0o600);
458
+ writeFileSync(mcpFile, text.endsWith('\n') ? text : text + '\n', { mode: 0o600 });
459
+ return servers;
460
+ },
461
+ };
462
+ }
463
+ export class LockedError extends Error {
464
+ }
465
+ /** A save that would leave the config invalid: the caller's mistake, not the server's. */
466
+ export class ConfigError extends Error {
372
467
  }
373
-
374
- export class LockedError extends Error {}
375
-
376
468
  // Keys that are not in DEFAULTS but are still settings.
377
469
  const EXTRA_KEYS = new Set(['access']);
378
470
  const ENUMS = {
379
- authMode: ['none', 'single', 'multiuser', 'trusted-header'],
380
- cacheTtl: ['5m', '1h'],
381
- cacheMode: ['auto', 'implicit', 'explicit', 'rolling', 'off'],
382
- reasoningReplay: REASONING_REPLAY,
383
- toolApproval: ['writes', 'all', 'off'],
471
+ authMode: ['local', 'multiuser', 'trusted-header'],
472
+ cacheTtl: ['5m', '1h'],
473
+ cacheMode: ['auto', 'implicit', 'explicit', 'rolling', 'off'],
474
+ reasoningReplay: REASONING_REPLAY,
475
+ toolApproval: ['writes', 'all', 'off'],
384
476
  };
385
477
  const kind = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v);
386
-
387
478
  /**
388
479
  * Every problem with a loaded config: unknown keys (usually typos), values of
389
480
  * the wrong type or outside their allowed set, plus the policy checks in
@@ -391,125 +482,129 @@ const kind = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v
391
482
  * a config with any.
392
483
  */
393
484
  function retrievalProblems(r) {
394
- if (!r || typeof r !== 'object' || Array.isArray(r)) return ['retrieval must be an object'];
395
- const out = [];
396
- for (const k of Object.keys(r)) if (!(k in DEFAULTS.retrieval)) out.push(`unknown setting "retrieval.${k}"`);
397
- if (r.mode !== undefined && !['auto', 'lexical', 'dense', 'hybrid'].includes(r.mode)) out.push('retrieval.mode must be one of auto, lexical, dense, hybrid');
398
- for (const k of ['model', 'modelDir', 'modelSha256', 'queryPrefix', 'documentPrefix']) {
399
- if (r[k] !== undefined && typeof r[k] !== 'string') out.push(`retrieval.${k} must be a string`);
400
- }
401
- if (r.modelSha256 && !/^[0-9a-fA-F]{64}$/.test(r.modelSha256)) out.push('retrieval.modelSha256 must be a 64-character hex sha256');
402
- if (r.denseWeight !== undefined && !(typeof r.denseWeight === 'number' && r.denseWeight > 0 && r.denseWeight < 1)) {
403
- out.push('retrieval.denseWeight must be a number between 0 and 1 (exclusive); use mode "dense" or "lexical" for the extremes');
404
- }
405
- if (r.rrfK !== undefined && !(Number.isInteger(r.rrfK) && r.rrfK >= 0)) out.push('retrieval.rrfK must be a whole number >= 0');
406
- const size = r.passageSize ?? DEFAULTS.retrieval.passageSize;
407
- if (!(Number.isInteger(size) && size >= 200 && size <= 20000)) out.push('retrieval.passageSize must be a whole number of characters from 200 to 20000');
408
- if (r.passageOverlap !== undefined && !(Number.isInteger(r.passageOverlap) && r.passageOverlap >= 0 && r.passageOverlap < size / 2)) {
409
- out.push('retrieval.passageOverlap must be a whole number >= 0 and under half of passageSize');
410
- }
411
- if (r.chunkOverlap !== undefined && !(Number.isInteger(r.chunkOverlap) && r.chunkOverlap >= 0 && r.chunkOverlap <= 128)) {
412
- out.push('retrieval.chunkOverlap must be a whole number of tokens from 0 to 128');
413
- }
414
- return out;
415
- }
416
-
417
- export function configProblems(cfg) {
418
- const problems = [];
419
- for (const [key, value] of Object.entries(cfg)) {
420
- if (!(key in DEFAULTS) && !EXTRA_KEYS.has(key)) { problems.push(`unknown setting "${key}"`); continue; }
421
- if (ENUMS[key]) {
422
- if (!ENUMS[key].includes(value)) problems.push(`${key} must be one of ${ENUMS[key].join(', ')} (got ${JSON.stringify(value)})`);
423
- continue;
485
+ if (!r || typeof r !== 'object' || Array.isArray(r))
486
+ return ['retrieval must be an object'];
487
+ const out = [];
488
+ for (const k of Object.keys(r))
489
+ if (!(k in DEFAULTS.retrieval))
490
+ out.push(`unknown setting "retrieval.${k}"`);
491
+ if (r.mode !== undefined && !['auto', 'lexical', 'dense', 'hybrid'].includes(r.mode))
492
+ out.push('retrieval.mode must be one of auto, lexical, dense, hybrid');
493
+ for (const k of ['model', 'modelDir', 'modelSha256', 'queryPrefix', 'documentPrefix']) {
494
+ if (r[k] !== undefined && typeof r[k] !== 'string')
495
+ out.push(`retrieval.${k} must be a string`);
424
496
  }
425
- if (key === 'models' || key === 'connectors') continue; // modelProblems/connectorProblems, below, know the shape
426
- if (key === 'retrieval') {
427
- problems.push(...retrievalProblems(value));
428
- continue;
497
+ if (r.modelSha256 && !/^[0-9a-fA-F]{64}$/.test(r.modelSha256))
498
+ out.push('retrieval.modelSha256 must be a 64-character hex sha256');
499
+ if (r.denseWeight !== undefined && !(typeof r.denseWeight === 'number' && r.denseWeight > 0 && r.denseWeight < 1)) {
500
+ out.push('retrieval.denseWeight must be a number between 0 and 1 (exclusive); use mode "dense" or "lexical" for the extremes');
429
501
  }
430
- if (key === 'frozen' || key === 'autoMigrate') {
431
- if (typeof value !== 'boolean') problems.push(`${key} must be true or false`);
432
- continue;
502
+ if (r.rrfK !== undefined && !(Number.isInteger(r.rrfK) && r.rrfK >= 0))
503
+ out.push('retrieval.rrfK must be a whole number >= 0');
504
+ const size = r.passageSize ?? DEFAULTS.retrieval.passageSize;
505
+ if (!(Number.isInteger(size) && size >= 200 && size <= 20000))
506
+ out.push('retrieval.passageSize must be a whole number of characters from 200 to 20000');
507
+ if (r.passageOverlap !== undefined && !(Number.isInteger(r.passageOverlap) && r.passageOverlap >= 0 && r.passageOverlap < size / 2)) {
508
+ out.push('retrieval.passageOverlap must be a whole number >= 0 and under half of passageSize');
433
509
  }
434
- if (!(key in DEFAULTS)) continue;
435
- if (key === 'compactionMaxTokens') {
436
- if (!(Number.isInteger(value) && value >= 256)) problems.push('compactionMaxTokens must be a whole number >= 256');
437
- continue;
510
+ if (r.chunkOverlap !== undefined && !(Number.isInteger(r.chunkOverlap) && r.chunkOverlap >= 0 && r.chunkOverlap <= 128)) {
511
+ out.push('retrieval.chunkOverlap must be a whole number of tokens from 0 to 128');
438
512
  }
439
- if (key === 'askUserTimeoutSeconds') {
440
- if (!(Number.isInteger(value) && value >= 0)) problems.push('askUserTimeoutSeconds must be a whole number of seconds >= 0 (0: no timeout)');
441
- continue;
513
+ return out;
514
+ }
515
+ export function configProblems(raw) {
516
+ const cfg = { ...raw, ...flattenCatalog(raw) };
517
+ const problems = [];
518
+ for (const [key, value] of Object.entries(cfg)) {
519
+ if (!(key in DEFAULTS) && !EXTRA_KEYS.has(key)) {
520
+ problems.push(`unknown setting "${key}"`);
521
+ continue;
522
+ }
523
+ if (ENUMS[key]) {
524
+ if (!ENUMS[key].includes(value))
525
+ problems.push(`${key} must be one of ${ENUMS[key].join(', ')} (got ${JSON.stringify(value)})`);
526
+ continue;
527
+ }
528
+ if (key === 'models' || key === 'connectors')
529
+ continue; // modelProblems/connectorProblems, below, know the shape
530
+ if (key === 'retrieval') {
531
+ problems.push(...retrievalProblems(value));
532
+ continue;
533
+ }
534
+ if (key === 'frozen' || key === 'autoMigrate') {
535
+ if (typeof value !== 'boolean')
536
+ problems.push(`${key} must be true or false`);
537
+ continue;
538
+ }
539
+ if (!(key in DEFAULTS))
540
+ continue;
541
+ if (key === 'compactionMaxTokens') {
542
+ if (!(Number.isInteger(value) && value >= 256))
543
+ problems.push('compactionMaxTokens must be a whole number >= 256');
544
+ continue;
545
+ }
546
+ if (key === 'askUserTimeoutSeconds') {
547
+ if (!(Number.isInteger(value) && value >= 0))
548
+ problems.push('askUserTimeoutSeconds must be a whole number of seconds >= 0 (0: no timeout)');
549
+ continue;
550
+ }
551
+ const want = kind(DEFAULTS[key]);
552
+ const got = kind(value);
553
+ // A null default is an optional number (temperature, maxTokens).
554
+ const ok = want === 'null' ? got === 'null' || (got === 'number' && Number.isFinite(value))
555
+ : want === 'number' ? got === 'number' && Number.isFinite(value)
556
+ : got === want;
557
+ if (!ok)
558
+ problems.push(`${key} must be ${want === 'null' ? 'a number or null' : `a${want === 'array' || want === 'object' ? 'n' : ''} ${want}`} (got ${got})`);
442
559
  }
443
- const want = kind(DEFAULTS[key]);
444
- const got = kind(value);
445
- // A null default is an optional number (temperature, maxTokens).
446
- const ok = want === 'null' ? got === 'null' || (got === 'number' && Number.isFinite(value))
447
- : want === 'number' ? got === 'number' && Number.isFinite(value)
448
- : got === want;
449
- if (!ok) problems.push(`${key} must be ${want === 'null' ? 'a number or null' : `a${want === 'array' || want === 'object' ? 'n' : ''} ${want}`} (got ${got})`);
450
- }
451
- return [...problems, ...connectorProblems(cfg), ...modelProblems(cfg), ...validateConfig(cfg)];
560
+ return [...problems, ...connectorProblems(cfg), ...modelProblems(cfg), ...validateConfig(cfg), ...refProblems(cfg)];
452
561
  }
453
-
454
562
  // Only the knobs the UI is allowed to change. Secrets stay server-side.
455
563
  export const WRITABLE = new Set([
456
- 'model', 'systemPrompt', 'temperature', 'maxTokens', 'maxToolRounds', 'askUserTimeoutSeconds',
457
- 'cacheTtl', 'cacheMode', 'compactThreshold', 'keepTurns', 'maxInlineChars',
458
- 'compactMinSaved', 'maxTurnChars', 'maxHistoryTokens', 'timezone',
459
- 'toolApproval', 'confirmTools', 'autoApproveTools', 'disabledTools',
460
- // The deployment's endpoints and catalog: only the sole user or an admin may
461
- // send these (see SECRET_PATCH_KEYS in policy.js); keys are write-only.
462
- 'baseUrl', 'apiKey', 'models', 'connectors',
463
- 'llmCompaction', 'compactionMaxTokens', 'expandCharBudget', 'cache', 'reasoningReplay', 'extraBody'
564
+ 'model', 'systemPrompt', 'temperature', 'maxTokens', 'maxToolRounds', 'askUserTimeoutSeconds',
565
+ 'cacheTtl', 'cacheMode', 'compactThreshold', 'keepTurns', 'maxInlineChars',
566
+ 'compactMinSaved', 'maxTurnChars', 'maxHistoryTokens', 'timezone',
567
+ 'toolApproval', 'confirmTools', 'autoApproveTools', 'disabledTools',
568
+ // The deployment's endpoints and catalog: only the sole user or an admin may
569
+ // send these (see SECRET_PATCH_KEYS in policy.js); keys are write-only.
570
+ 'baseUrl', 'apiKey', 'models', 'connectors',
571
+ 'llmCompaction', 'compactionMaxTokens', 'expandCharBudget', 'cache', 'reasoningReplay', 'extraBody'
464
572
  ]);
465
-
466
573
  // A short tail is enough to tell two keys apart in a list; short keys show none.
467
574
  const keyHint = (key) => (key && key.length >= 12 ? key.slice(-4) : null);
468
-
469
575
  /**
470
576
  * API keys are write-only: the page learns that one is set (and its last four
471
- * characters), never the value. On save, a connector sent without `apiKey`
472
- * keeps its stored key; `apiKey: null` clears it.
577
+ * characters), never the value. Where a save puts them is takeSecrets() in
578
+ * secrets.ts.
473
579
  */
474
- export function mergeSecrets(patch, current) {
475
- const out = { ...patch };
476
- if ('apiKey' in out && (out.apiKey === undefined || out.apiKey === '')) delete out.apiKey;
477
- else if (out.apiKey === null) out.apiKey = '';
478
- if (Array.isArray(out.connectors)) {
479
- const old = new Map((Array.isArray(current.connectors) ? current.connectors : []).map((c) => [c?.id, c]));
480
- out.connectors = out.connectors.map((c) => {
481
- if (!c || typeof c !== 'object') return c;
482
- const { apiKey, ...rest } = c;
483
- if (apiKey === null) return { ...rest, apiKey: '' };
484
- if (apiKey === undefined || apiKey === '') return old.get(c.id)?.apiKey ? { ...rest, apiKey: old.get(c.id).apiKey } : rest;
485
- return c;
486
- });
487
- }
488
- return out;
489
- }
490
-
491
580
  /** The patch as it may be logged: secret values replaced by whether they were set. */
492
581
  export function redactSecrets(patch) {
493
- const out = { ...patch };
494
- if ('apiKey' in out) out.apiKey = Boolean(out.apiKey);
495
- if (Array.isArray(out.connectors)) {
496
- out.connectors = out.connectors.map((c) => (c && typeof c === 'object' && 'apiKey' in c ? { ...c, apiKey: Boolean(c.apiKey) } : c));
497
- }
498
- return out;
582
+ const out = { ...patch };
583
+ if ('apiKey' in out)
584
+ out.apiKey = Boolean(out.apiKey);
585
+ if (Array.isArray(out.connectors)) {
586
+ out.connectors = out.connectors.map((c) => (c && typeof c === 'object' && 'apiKey' in c ? { ...c, apiKey: Boolean(c.apiKey) } : c));
587
+ }
588
+ return out;
499
589
  }
500
-
501
590
  export function publicConfig(cfg) {
502
- const { apiKey, authPassword, sessionSecret, googleClientSecret, connectors, ...rest } = cfg;
503
- return {
504
- ...rest,
505
- connectors: (Array.isArray(connectors) ? connectors : []).map(({ apiKey: key, ...c }) => ({ ...c, hasApiKey: Boolean(key), keyHint: keyHint(key) })),
506
- keyHint: keyHint(apiKey),
507
- modelLabel: labelFor(cfg, cfg.model),
508
- hasApiKey: Boolean(apiKey),
509
- hasGoogleAuth: Boolean(cfg.googleClientId),
510
- };
591
+ const { apiKey, authPassword, sessionSecret, googleClientSecret, connectors, ...rest } = cfg;
592
+ return {
593
+ ...rest,
594
+ connectors: (Array.isArray(connectors) ? connectors : []).map(({ apiKey: key, ...c }) => ({ ...c, hasApiKey: Boolean(key), keyHint: keyHint(key) })),
595
+ keyHint: keyHint(apiKey),
596
+ modelLabel: labelFor(cfg, cfg.model),
597
+ hasApiKey: Boolean(apiKey),
598
+ // Whether the default model's turns have an endpoint to go to.
599
+ ready: hasEndpoint(cfg),
600
+ // Whether the top-level endpoint serves anything, so the page shows it:
601
+ // a URL, a key, or a model naming no connector. Otherwise the page offers
602
+ // connectors only.
603
+ usesDefaultEndpoint: Boolean(cfg.baseUrl) || Boolean(apiKey)
604
+ || (Array.isArray(cfg.models) && cfg.models.some((m) => !m.connector)),
605
+ hasGoogleAuth: Boolean(cfg.googleClientId),
606
+ };
511
607
  }
512
-
513
608
  // The file-driven instance the CLI has always used, kept as plain functions.
514
609
  const fileSource = createConfigSource();
515
610
  export const configPath = () => fileSource.path();