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
@@ -1,6 +1,5 @@
1
1
  /* The provider side of a turn: request shape, prompt caching, retries and the SSE stream. */
2
2
  import { EVENT } from '../../public/shared/events.js';
3
-
4
3
  /**
5
4
  * Caching has two layers, and only the first one is universal.
6
5
  *
@@ -29,22 +28,24 @@ import { EVENT } from '../../public/shared/events.js';
29
28
  * 'off' no cache shaping at all
30
29
  */
31
30
  export function isOpenRouter(cfg) {
32
- return /^https?:\/\/(?:[^/]+\.)?openrouter\.ai(?::\d+)?(?:\/|$)/i.test(cfg.baseUrl || '');
31
+ return /^https?:\/\/(?:[^/]+\.)?openrouter\.ai(?::\d+)?(?:\/|$)/i.test(cfg.baseUrl || '');
33
32
  }
34
-
35
33
  /** Loopback and LAN endpoints: a local runtime, whatever model it is serving. */
36
34
  function isLocal(cfg) {
37
- let host = '';
38
- try { host = new URL(cfg.baseUrl || '').hostname.toLowerCase(); } catch { return false; }
39
- return host === 'localhost' || host === '::1' || host === 'host.docker.internal'
40
- || host.endsWith('.local') || /^127\./.test(host)
41
- || /^(?:10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.)/.test(host);
35
+ let host = '';
36
+ try {
37
+ host = new URL(cfg.baseUrl || '').hostname.toLowerCase();
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ return host === 'localhost' || host === '::1' || host === 'host.docker.internal'
43
+ || host.endsWith('.local') || /^127\./.test(host)
44
+ || /^(?:10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.)/.test(host);
42
45
  }
43
-
44
46
  function isClaude(model) {
45
- return /claude|anthropic/i.test(model || '');
47
+ return /claude|anthropic/i.test(model || '');
46
48
  }
47
-
48
49
  /**
49
50
  * Model families that bill cache writes and therefore want an explicit marker.
50
51
  * Matched at the family level on purpose: pinning exact model ids means every
@@ -61,40 +62,39 @@ function isClaude(model) {
61
62
  * already gets the saving and the marker is pure downside.
62
63
  */
63
64
  const NATIVE_ANTHROPIC = /claude|anthropic|amazon\.nova|nova-(?:lite|micro|pro|premier)/i;
64
-
65
65
  /** Resolves config + endpoint + model to exactly one of the modes above. */
66
66
  export function cacheMode(cfg) {
67
- const asked = cfg.cacheMode || 'auto';
68
- if (cfg.cache === false) return 'off';
69
- if (asked !== 'auto') return asked;
70
-
71
- // A pinned provider route means the caller has taken routing into their own
72
- // hands; automatic mode would quietly narrow it, so fall back to markers.
73
- // Only routing counts: policy fields like zdr or data_collection narrow the
74
- // pool without choosing an upstream, so they leave automatic mode alone.
75
- const route = cfg.extraBody?.provider || {};
76
- const pinned = Boolean(route.order?.length || route.only?.length);
77
-
78
- if (isOpenRouter(cfg)) {
79
- if (isClaude(cfg.model) && !pinned) return 'rolling';
67
+ const asked = cfg.cacheMode || 'auto';
68
+ if (cfg.cache === false)
69
+ return 'off';
70
+ if (asked !== 'auto')
71
+ return asked;
72
+ // A pinned provider route means the caller has taken routing into their own
73
+ // hands; automatic mode would quietly narrow it, so fall back to markers.
74
+ // Only routing counts: policy fields like zdr or data_collection narrow the
75
+ // pool without choosing an upstream, so they leave automatic mode alone.
76
+ const route = cfg.extraBody?.provider || {};
77
+ const pinned = Boolean(route.order?.length || route.only?.length);
78
+ if (isOpenRouter(cfg)) {
79
+ if (isClaude(cfg.model) && !pinned)
80
+ return 'rolling';
81
+ return NATIVE_ANTHROPIC.test(cfg.model || '') ? 'explicit' : 'implicit';
82
+ }
83
+ // Local runtimes cache the prefix themselves and are the strictest about
84
+ // request shape. Never shape their wire.
85
+ if (isLocal(cfg))
86
+ return 'implicit';
87
+ // A direct Anthropic-compatible endpoint is the one remaining case worth
88
+ // marking; anything else we have not identified gets the safe path.
80
89
  return NATIVE_ANTHROPIC.test(cfg.model || '') ? 'explicit' : 'implicit';
81
- }
82
- // Local runtimes cache the prefix themselves and are the strictest about
83
- // request shape. Never shape their wire.
84
- if (isLocal(cfg)) return 'implicit';
85
- // A direct Anthropic-compatible endpoint is the one remaining case worth
86
- // marking; anything else we have not identified gets the safe path.
87
- return NATIVE_ANTHROPIC.test(cfg.model || '') ? 'explicit' : 'implicit';
88
90
  }
89
-
90
91
  function cacheDirective(cfg) {
91
- const directive = { type: 'ephemeral' };
92
- if (isClaude(cfg.model) && cfg.cacheTtl === '1h') directive.ttl = '1h';
93
- return directive;
92
+ const directive = { type: 'ephemeral' };
93
+ if (isClaude(cfg.model) && cfg.cacheTtl === '1h')
94
+ directive.ttl = '1h';
95
+ return directive;
94
96
  }
95
-
96
97
  const REPLAY = { OMIT: 'omit', CONTENT: 'reasoning_content', DETAILS: 'reasoning_details' };
97
-
98
98
  /**
99
99
  * Which reasoning a past assistant turn sends back. Structured reasoning goes
100
100
  * back wherever the gateway normalises it, not only where cache markers are on:
@@ -107,13 +107,17 @@ const REPLAY = { OMIT: 'omit', CONTENT: 'reasoning_content', DETAILS: 'reasoning
107
107
  * table does not know.
108
108
  */
109
109
  export function reasoningReplay(cfg) {
110
- if (cfg.reasoningReplay && cfg.reasoningReplay !== 'auto') return cfg.reasoningReplay;
111
- if (isOpenRouter(cfg) || cacheMode(cfg) === 'explicit') return REPLAY.DETAILS;
112
- let host = '';
113
- try { host = new URL(cfg.baseUrl || '').hostname.toLowerCase(); } catch { /* no endpoint set */ }
114
- return host === 'api.deepseek.com' ? REPLAY.CONTENT : REPLAY.OMIT;
110
+ if (cfg.reasoningReplay && cfg.reasoningReplay !== 'auto')
111
+ return cfg.reasoningReplay;
112
+ if (isOpenRouter(cfg) || cacheMode(cfg) === 'explicit')
113
+ return REPLAY.DETAILS;
114
+ let host = '';
115
+ try {
116
+ host = new URL(cfg.baseUrl || '').hostname.toLowerCase();
117
+ }
118
+ catch { /* no endpoint set */ }
119
+ return host === 'api.deepseek.com' ? REPLAY.CONTENT : REPLAY.OMIT;
115
120
  }
116
-
117
121
  /**
118
122
  * Prompt-cache shaping: one fixed key order for every message on the wire.
119
123
  *
@@ -130,23 +134,25 @@ export function reasoningReplay(cfg) {
130
134
  * is the provider's contract, see reasoningReplay.
131
135
  */
132
136
  function canonical(m, replay) {
133
- const out = { role: m.role };
134
- // A tool-only assistant turn stores content as null, but some gateways (seen
135
- // on DeepInfra via OpenRouter) reject a null text part outright and want an
136
- // empty string instead. Only assistant+tool_calls hits this; every other
137
- // role still sends null through unchanged.
138
- out.content = (m.content == null && m.role === 'assistant' && m.tool_calls) ? '' : (m.content ?? null);
139
- if (m.tool_calls) out.tool_calls = m.tool_calls;
140
- if (m.tool_call_id) out.tool_call_id = m.tool_call_id;
141
- if (replay === REPLAY.DETAILS && m.reasoning_details) out.reasoning_details = m.reasoning_details;
142
- // Only a tool-call turn: DeepSeek needs its reasoning to continue the turn
143
- // it belongs to and has no use for it on a finished answer.
144
- if (replay === REPLAY.CONTENT && m.role === 'assistant' && m.tool_calls && m.reasoning) {
145
- out.reasoning_content = m.reasoning;
146
- }
147
- return out;
137
+ const out = { role: m.role };
138
+ // A tool-only assistant turn stores content as null, but some gateways (seen
139
+ // on DeepInfra via OpenRouter) reject a null text part outright and want an
140
+ // empty string instead. Only assistant+tool_calls hits this; every other
141
+ // role still sends null through unchanged.
142
+ out.content = (m.content == null && m.role === 'assistant' && m.tool_calls) ? '' : (m.content ?? null);
143
+ if (m.tool_calls)
144
+ out.tool_calls = m.tool_calls;
145
+ if (m.tool_call_id)
146
+ out.tool_call_id = m.tool_call_id;
147
+ if (replay === REPLAY.DETAILS && m.reasoning_details)
148
+ out.reasoning_details = m.reasoning_details;
149
+ // Only a tool-call turn: DeepSeek needs its reasoning to continue the turn
150
+ // it belongs to and has no use for it on a finished answer.
151
+ if (replay === REPLAY.CONTENT && m.role === 'assistant' && m.tool_calls && m.reasoning) {
152
+ out.reasoning_content = m.reasoning;
153
+ }
154
+ return out;
148
155
  }
149
-
150
156
  /**
151
157
  * Marks a cache breakpoint on the newest text-bearing turn at or before `from`.
152
158
  * Only user/assistant text can carry it: an assistant message that is purely
@@ -154,112 +160,108 @@ function canonical(m, replay) {
154
160
  * every OpenAI-compatible gateway accepts array content there.
155
161
  */
156
162
  function markBreakpoint(messages, from, directive) {
157
- for (let i = Math.min(from, messages.length - 1); i > 0; i--) {
158
- const m = messages[i];
159
- if (typeof m.content !== 'string' || !m.content) continue;
160
- if (m.role !== 'user' && m.role !== 'assistant') continue;
161
- messages[i] = {
162
- ...m,
163
- content: [{ type: 'text', text: m.content, cache_control: { ...directive } }]
164
- };
165
- return i;
166
- }
167
- return -1;
163
+ for (let i = Math.min(from, messages.length - 1); i > 0; i--) {
164
+ const m = messages[i];
165
+ if (typeof m.content !== 'string' || !m.content)
166
+ continue;
167
+ if (m.role !== 'user' && m.role !== 'assistant')
168
+ continue;
169
+ messages[i] = {
170
+ ...m,
171
+ content: [{ type: 'text', text: m.content, cache_control: { ...directive } }]
172
+ };
173
+ return i;
174
+ }
175
+ return -1;
168
176
  }
169
-
170
177
  export function buildMessages(cfg, history, epochIndex = -1) {
171
- // Only 'explicit' shapes message content. 'rolling' is the strategy by
172
- // itself and must not be stacked with markers; 'implicit' and 'off' send
173
- // plain strings, which is what every strict OpenAI-compatible server wants.
174
- const bp = cacheMode(cfg) === 'explicit';
175
- const directive = cacheDirective(cfg);
176
- const system = {
177
- role: 'system',
178
- content: bp
179
- ? [{ type: 'text', text: cfg.systemPrompt, cache_control: { ...directive } }]
180
- : cfg.systemPrompt
181
- };
182
-
183
- // Kept for the whole history, not just the current turn: stripping it once a
184
- // turn is over would rewrite those bytes and miss the cache on the next turn.
185
- const replay = reasoningReplay(cfg);
186
- const messages = [system, ...history.map((m) => canonical(m, replay))];
187
- if (bp) {
188
- // Rolling breakpoint at the END of what we are sending, not behind it. Each
189
- // tool round appends an assistant turn and its results, and marking the
190
- // newest text message writes all of that to the cache so the next round
191
- // reads it back instead of re-paying for it. Anthropic uses cache_control
192
- // only to decide where to WRITE -- prefix matching ignores it -- so moving
193
- // the marker forward each round does not invalidate what is already cached.
194
- const rolling = markBreakpoint(messages, messages.length - 1, directive);
195
- // Second, pinned breakpoint on the frozen compacted prefix. The rolling one
196
- // moves every round and its entry expires; this one sits on a span that can
197
- // no longer change, so the expensive part of the conversation stays cached
198
- // for as long as the epoch lasts. Skipped when it would land on the same
199
- // message the rolling marker already claimed.
200
- if (epochIndex >= 0) {
201
- const at = epochIndex + 1; // +1 for the system message at index 0
202
- if (at < rolling || rolling === -1) markBreakpoint(messages, at, directive);
178
+ // Only 'explicit' shapes message content. 'rolling' is the strategy by
179
+ // itself and must not be stacked with markers; 'implicit' and 'off' send
180
+ // plain strings, which is what every strict OpenAI-compatible server wants.
181
+ const bp = cacheMode(cfg) === 'explicit';
182
+ const directive = cacheDirective(cfg);
183
+ const system = {
184
+ role: 'system',
185
+ content: bp
186
+ ? [{ type: 'text', text: cfg.systemPrompt, cache_control: { ...directive } }]
187
+ : cfg.systemPrompt
188
+ };
189
+ // Kept for the whole history, not just the current turn: stripping it once a
190
+ // turn is over would rewrite those bytes and miss the cache on the next turn.
191
+ const replay = reasoningReplay(cfg);
192
+ const messages = [system, ...history.map((m) => canonical(m, replay))];
193
+ if (bp) {
194
+ // Rolling breakpoint at the END of what we are sending, not behind it. Each
195
+ // tool round appends an assistant turn and its results, and marking the
196
+ // newest text message writes all of that to the cache so the next round
197
+ // reads it back instead of re-paying for it. Anthropic uses cache_control
198
+ // only to decide where to WRITE -- prefix matching ignores it -- so moving
199
+ // the marker forward each round does not invalidate what is already cached.
200
+ const rolling = markBreakpoint(messages, messages.length - 1, directive);
201
+ // Second, pinned breakpoint on the frozen compacted prefix. The rolling one
202
+ // moves every round and its entry expires; this one sits on a span that can
203
+ // no longer change, so the expensive part of the conversation stays cached
204
+ // for as long as the epoch lasts. Skipped when it would land on the same
205
+ // message the rolling marker already claimed.
206
+ if (epochIndex >= 0) {
207
+ const at = epochIndex + 1; // +1 for the system message at index 0
208
+ if (at < rolling || rolling === -1)
209
+ markBreakpoint(messages, at, directive);
210
+ }
203
211
  }
204
- }
205
- return messages;
212
+ return messages;
206
213
  }
207
-
208
214
  // Optional knobs, in the order we give them up. Gateways vary in what they
209
215
  // accept, and none of these is worth failing a whole turn over: caching is an
210
216
  // optimisation, tool_choice has a prompt-level fallback, and usage is cosmetic.
211
217
  const OPTIONAL = [
212
- { probe: /cache_control|ephemeral/i, drop: 'cache' },
213
- { probe: /tool_choice/i, drop: 'tool_choice' },
214
- { probe: /stream_options|include_usage/i, drop: 'stream_options' }
218
+ { probe: /cache_control|ephemeral/i, drop: 'cache' },
219
+ { probe: /tool_choice/i, drop: 'tool_choice' },
220
+ { probe: /stream_options|include_usage/i, drop: 'stream_options' }
215
221
  ];
216
-
217
222
  /** Builds one request body, leaving out whatever this endpoint has refused. */
218
223
  export function buildBody(cfg, { turn, tools, lastCall, epochIndex, chatId, relax }, disabled) {
219
- const body = {
220
- // Gateway-specific routing/knobs first, so nothing below can be clobbered.
221
- ...(cfg.extraBody || {}),
222
- model: cfg.model,
223
- messages: buildMessages(
224
- disabled.has('cache') ? { ...cfg, cache: false } : cfg, turn, epochIndex
225
- ),
226
- stream: true
227
- };
228
-
229
- // Last resort after the pinned provider has refused us repeatedly. Pinning
230
- // exists to keep the prompt cache on one upstream, but a warm cache is worth
231
- // less than a turn that finishes, so the pin yields before the turn does.
232
- if (relax && body.provider && body.provider.allow_fallbacks === false) {
233
- body.provider = { ...body.provider, allow_fallbacks: true };
234
- }
235
-
236
- // session_id is an OpenRouter field. Sending it anywhere else risks a strict
237
- // server rejecting an unknown key over something that would do nothing.
238
- if (isOpenRouter(cfg) && chatId && body.session_id == null) {
239
- body.session_id = String(chatId).slice(0, 256);
240
- }
241
-
242
- // Rolling mode: the gateway advances the breakpoint to the last cacheable
243
- // block itself, which is the only shape that keeps caching across client-side
244
- // tool results in an agentic loop.
245
- if (!disabled.has('cache') && cacheMode(cfg) === 'rolling') {
246
- body.cache_control = cacheDirective(cfg);
247
- }
248
-
249
- if (!disabled.has('stream_options')) body.stream_options = { include_usage: true };
250
- // Only send the sampling knobs that were actually set, so an unset one gets
251
- // the provider's default instead of ours.
252
- if (cfg.temperature != null) body.temperature = cfg.temperature;
253
- if (cfg.maxTokens != null) body.max_tokens = cfg.maxTokens;
254
- if (tools.length) {
255
- // The tool block stays in the request even on the final pass: dropping it
256
- // would change the cached prefix. `tool_choice: none` is what blocks the call.
257
- body.tools = tools;
258
- if (lastCall && !disabled.has('tool_choice')) body.tool_choice = 'none';
259
- }
260
- return body;
224
+ const body = {
225
+ // Gateway-specific routing/knobs first, so nothing below can be clobbered.
226
+ ...(cfg.extraBody || {}),
227
+ model: cfg.model,
228
+ messages: buildMessages(disabled.has('cache') ? { ...cfg, cache: false } : cfg, turn, epochIndex),
229
+ stream: true
230
+ };
231
+ // Last resort after the pinned provider has refused us repeatedly. Pinning
232
+ // exists to keep the prompt cache on one upstream, but a warm cache is worth
233
+ // less than a turn that finishes, so the pin yields before the turn does.
234
+ if (relax && body.provider && body.provider.allow_fallbacks === false) {
235
+ body.provider = { ...body.provider, allow_fallbacks: true };
236
+ }
237
+ // session_id is an OpenRouter field. Sending it anywhere else risks a strict
238
+ // server rejecting an unknown key over something that would do nothing.
239
+ if (isOpenRouter(cfg) && chatId && body.session_id == null) {
240
+ body.session_id = String(chatId).slice(0, 256);
241
+ }
242
+ // Rolling mode: the gateway advances the breakpoint to the last cacheable
243
+ // block itself, which is the only shape that keeps caching across client-side
244
+ // tool results in an agentic loop.
245
+ if (!disabled.has('cache') && cacheMode(cfg) === 'rolling') {
246
+ body.cache_control = cacheDirective(cfg);
247
+ }
248
+ if (!disabled.has('stream_options'))
249
+ body.stream_options = { include_usage: true };
250
+ // Only send the sampling knobs that were actually set, so an unset one gets
251
+ // the provider's default instead of ours.
252
+ if (cfg.temperature != null)
253
+ body.temperature = cfg.temperature;
254
+ if (cfg.maxTokens != null)
255
+ body.max_tokens = cfg.maxTokens;
256
+ if (tools.length) {
257
+ // The tool block stays in the request even on the final pass: dropping it
258
+ // would change the cached prefix. `tool_choice: none` is what blocks the call.
259
+ body.tools = tools;
260
+ if (lastCall && !disabled.has('tool_choice'))
261
+ body.tool_choice = 'none';
262
+ }
263
+ return body;
261
264
  }
262
-
263
265
  /**
264
266
  * Failures worth trying again: a busy or briefly broken upstream, not a bad
265
267
  * request. A rate limit is the common one, and it is the reason this exists --
@@ -270,42 +272,42 @@ const TRANSIENT = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
270
272
  const MAX_RETRIES = 4;
271
273
  // Rounds re-run after the stream itself fails, on top of post()'s retries.
272
274
  export const STREAM_RETRIES = 2;
273
-
274
275
  /** A failure reported inside an otherwise successful stream. */
275
- export class StreamError extends Error {}
276
+ export class StreamError extends Error {
277
+ }
276
278
  // How many refusals to take from a pinned provider before trying another one.
277
279
  // Waiting out the full backoff first is the wrong trade: a provider that is
278
280
  // rate-limited on its shared pool is usually limited for longer than any
279
281
  // backoff we are willing to sit through, and a cold cache costs far less than
280
282
  // half a minute of nothing.
281
283
  const RELEASE_PIN_AFTER = 2;
282
-
283
284
  /** How long to wait: what the provider asked for, else backing off with jitter. */
284
285
  export function retryDelay(res, attempt) {
285
- const header = res?.headers?.get?.('retry-after');
286
- if (header) {
287
- const secs = Number(header);
288
- if (Number.isFinite(secs) && secs >= 0) return Math.min(secs * 1000, 30000);
289
- const when = Date.parse(header);
290
- if (!Number.isNaN(when)) return Math.min(Math.max(when - Date.now(), 0), 30000);
291
- }
292
- // Jittered, so two tabs retrying the same upstream do not march in step.
293
- return Math.min(1000 * 2 ** attempt, 8000) * (0.5 + Math.random());
286
+ const header = res?.headers?.get?.('retry-after');
287
+ if (header) {
288
+ const secs = Number(header);
289
+ if (Number.isFinite(secs) && secs >= 0)
290
+ return Math.min(secs * 1000, 30000);
291
+ const when = Date.parse(header);
292
+ if (!Number.isNaN(when))
293
+ return Math.min(Math.max(when - Date.now(), 0), 30000);
294
+ }
295
+ // Jittered, so two tabs retrying the same upstream do not march in step.
296
+ return Math.min(1000 * 2 ** attempt, 8000) * (0.5 + Math.random());
294
297
  }
295
-
296
298
  /** A sleep that a stop actually interrupts, rather than one you wait out. */
297
299
  export function sleep(ms, signal) {
298
- return new Promise((resolve, reject) => {
299
- if (signal?.aborted) return reject(new Error('Stopped.'));
300
- const timer = setTimeout(resolve, ms);
301
- signal?.addEventListener('abort', () => {
302
- clearTimeout(timer);
303
- reject(new Error('Stopped.'));
304
- }, { once: true });
305
- return undefined;
306
- });
300
+ return new Promise((resolve, reject) => {
301
+ if (signal?.aborted)
302
+ return reject(new Error('Stopped.'));
303
+ const timer = setTimeout(resolve, ms);
304
+ signal?.addEventListener('abort', () => {
305
+ clearTimeout(timer);
306
+ reject(new Error('Stopped.'));
307
+ }, { once: true });
308
+ return undefined;
309
+ });
307
310
  }
308
-
309
311
  /**
310
312
  * POSTs the completion, and if the gateway rejects it over one of the optional
311
313
  * knobs, drops that knob and retries. `disabled` is shared across the whole turn,
@@ -317,114 +319,113 @@ export function sleep(ms, signal) {
317
319
  * still beats losing the turn and everything already spent on it.
318
320
  */
319
321
  export async function post(cfg, plan, signal, disabled, emit) {
320
- let retries = 0;
321
- let relaxed = false;
322
-
323
- const pinned = cfg.extraBody?.provider?.allow_fallbacks === false;
324
-
325
- const backOff = async (res, why) => {
326
- // A pin exists to keep the prompt cache on one upstream. Once that upstream
327
- // has refused us twice, the cache is not the binding constraint any more --
328
- // finishing the turn is. Retry elsewhere immediately rather than waiting.
329
- if (pinned && !relaxed && retries >= RELEASE_PIN_AFTER) {
330
- relaxed = true;
331
- emit({ type: EVENT.NOTICE, text: `${why} — releasing the provider pin (cache will be cold).` });
332
- return true;
333
- }
334
- if (retries < MAX_RETRIES) {
335
- retries++;
336
- const wait = retryDelay(res, retries);
337
- emit({
338
- type: EVENT.NOTICE,
339
- text: `${why} — retrying in ${(wait / 1000).toFixed(1)}s (${retries}/${MAX_RETRIES}).`
340
- });
341
- await sleep(wait, signal);
342
- return true;
343
- }
344
- return false;
345
- };
346
-
347
- for (;;) {
348
- let res;
349
- const body = buildBody(cfg, { ...plan, relax: relaxed }, disabled);
350
- plan.onRequest?.(body);
351
- try {
352
- res = await fetch(`${cfg.baseUrl}/chat/completions`, {
353
- method: 'POST',
354
- signal,
355
- // Local runtimes are usually keyless, and the ranking headers are
356
- // OpenRouter's own -- neither belongs on a request to someone else.
357
- headers: {
358
- 'content-type': 'application/json',
359
- ...(cfg.apiKey ? { authorization: `Bearer ${cfg.apiKey}` } : {}),
360
- ...(isOpenRouter(cfg)
361
- ? {
362
- 'http-referer': 'https://github.com/TinySuiteHQ/tinywebui',
363
- 'x-title': 'TinyWebUI'
364
- }
365
- : {})
366
- },
367
- body: JSON.stringify(body)
368
- });
369
- } catch (err) {
370
- // A dropped connection looks like nothing at all, so it is judged here
371
- // rather than by a status code. A stop is not a failure to retry.
372
- if (signal?.aborted) throw err;
373
- if (await backOff(null, `Network error (${err.message})`)) continue;
374
- throw err;
375
- }
376
-
377
- if (res.ok && res.body) return res;
378
-
379
- const text = await res.text();
380
-
381
- // A knob this endpoint refuses is not transient; dropping it is the fix.
382
- const culprit = res.status >= 400 && res.status < 500
383
- ? OPTIONAL.find((o) => !disabled.has(o.drop) && o.probe.test(text))
384
- : null;
385
- if (culprit) {
386
- disabled.add(culprit.drop);
387
- emit({ type: EVENT.NOTICE, text: `Provider rejected ${culprit.drop}; retrying without it.` });
388
- continue;
389
- }
390
-
391
- if (TRANSIENT.has(res.status)) {
392
- if (await backOff(res, `Provider returned ${res.status}`)) continue;
393
- }
394
-
395
- // A model with no vision support rejects image content with a 4xx rather
396
- // than any dedicated status code, and the raw provider text is often just
397
- // an opaque "invalid content" -- naming the actual cause here is the
398
- // difference between that and a clear, actionable error in the transcript.
399
- const hasImage = plan.turn.some((m) => Array.isArray(m.content)
400
- && m.content.some((p) => p?.type === 'image_url'));
401
- if (hasImage && res.status >= 400 && res.status < 500) {
402
- throw new Error(
403
- `This model doesn't appear to support image input. `
404
- + `Provider said: ${res.status} ${res.statusText}: ${text.slice(0, 300)}`
405
- );
322
+ let retries = 0;
323
+ let relaxed = false;
324
+ const pinned = cfg.extraBody?.provider?.allow_fallbacks === false;
325
+ const backOff = async (res, why) => {
326
+ // A pin exists to keep the prompt cache on one upstream. Once that upstream
327
+ // has refused us twice, the cache is not the binding constraint any more --
328
+ // finishing the turn is. Retry elsewhere immediately rather than waiting.
329
+ if (pinned && !relaxed && retries >= RELEASE_PIN_AFTER) {
330
+ relaxed = true;
331
+ emit({ type: EVENT.NOTICE, text: `${why} — releasing the provider pin (cache will be cold).` });
332
+ return true;
333
+ }
334
+ if (retries < MAX_RETRIES) {
335
+ retries++;
336
+ const wait = retryDelay(res, retries);
337
+ emit({
338
+ type: EVENT.NOTICE,
339
+ text: `${why} — retrying in ${(wait / 1000).toFixed(1)}s (${retries}/${MAX_RETRIES}).`
340
+ });
341
+ await sleep(wait, signal);
342
+ return true;
343
+ }
344
+ return false;
345
+ };
346
+ for (;;) {
347
+ let res;
348
+ const body = buildBody(cfg, { ...plan, relax: relaxed }, disabled);
349
+ plan.onRequest?.(body);
350
+ try {
351
+ res = await fetch(`${cfg.baseUrl}/chat/completions`, {
352
+ method: 'POST',
353
+ signal,
354
+ // Local runtimes are usually keyless, and the ranking headers are
355
+ // OpenRouter's own -- neither belongs on a request to someone else.
356
+ headers: {
357
+ 'content-type': 'application/json',
358
+ ...(cfg.apiKey ? { authorization: `Bearer ${cfg.apiKey}` } : {}),
359
+ ...(isOpenRouter(cfg)
360
+ ? {
361
+ 'http-referer': 'https://github.com/TinySuiteHQ/tinywebui',
362
+ 'x-title': 'TinyWebUI'
363
+ }
364
+ : {})
365
+ },
366
+ body: JSON.stringify(body)
367
+ });
368
+ }
369
+ catch (err) {
370
+ // A dropped connection looks like nothing at all, so it is judged here
371
+ // rather than by a status code. A stop is not a failure to retry.
372
+ if (signal?.aborted)
373
+ throw err;
374
+ if (await backOff(null, `Network error (${err.message})`))
375
+ continue;
376
+ throw err;
377
+ }
378
+ if (res.ok && res.body)
379
+ return res;
380
+ const text = await res.text();
381
+ // A knob this endpoint refuses is not transient; dropping it is the fix.
382
+ const culprit = res.status >= 400 && res.status < 500
383
+ ? OPTIONAL.find((o) => !disabled.has(o.drop) && o.probe.test(text))
384
+ : null;
385
+ if (culprit) {
386
+ disabled.add(culprit.drop);
387
+ emit({ type: EVENT.NOTICE, text: `Provider rejected ${culprit.drop}; retrying without it.` });
388
+ continue;
389
+ }
390
+ if (TRANSIENT.has(res.status)) {
391
+ if (await backOff(res, `Provider returned ${res.status}`))
392
+ continue;
393
+ }
394
+ // A model with no vision support rejects image content with a 4xx rather
395
+ // than any dedicated status code, and the raw provider text is often just
396
+ // an opaque "invalid content" -- naming the actual cause here is the
397
+ // difference between that and a clear, actionable error in the transcript.
398
+ const hasImage = plan.turn.some((m) => Array.isArray(m.content)
399
+ && m.content.some((p) => p?.type === 'image_url'));
400
+ if (hasImage && res.status >= 400 && res.status < 500) {
401
+ throw new Error(`This model doesn't appear to support image input. `
402
+ + `Provider said: ${res.status} ${res.statusText}: ${text.slice(0, 300)}`);
403
+ }
404
+ throw new Error(`${res.status} ${res.statusText}: ${text.slice(0, 500)}`);
406
405
  }
407
-
408
- throw new Error(`${res.status} ${res.statusText}: ${text.slice(0, 500)}`);
409
- }
410
406
  }
411
-
412
407
  export async function* streamChunks(res) {
413
- const reader = res.body.getReader();
414
- const decoder = new TextDecoder();
415
- let buf = '';
416
- while (true) {
417
- const { done, value } = await reader.read();
418
- if (done) break;
419
- buf += decoder.decode(value, { stream: true });
420
- let idx;
421
- while ((idx = buf.indexOf('\n')) !== -1) {
422
- const line = buf.slice(0, idx).trim();
423
- buf = buf.slice(idx + 1);
424
- if (!line.startsWith('data:')) continue;
425
- const data = line.slice(5).trim();
426
- if (data === '[DONE]') return;
427
- try { yield JSON.parse(data); } catch { /* keepalive / partial */ }
408
+ const reader = res.body.getReader();
409
+ const decoder = new TextDecoder();
410
+ let buf = '';
411
+ while (true) {
412
+ const { done, value } = await reader.read();
413
+ if (done)
414
+ break;
415
+ buf += decoder.decode(value, { stream: true });
416
+ let idx;
417
+ while ((idx = buf.indexOf('\n')) !== -1) {
418
+ const line = buf.slice(0, idx).trim();
419
+ buf = buf.slice(idx + 1);
420
+ if (!line.startsWith('data:'))
421
+ continue;
422
+ const data = line.slice(5).trim();
423
+ if (data === '[DONE]')
424
+ return;
425
+ try {
426
+ yield JSON.parse(data);
427
+ }
428
+ catch { /* keepalive / partial */ }
429
+ }
428
430
  }
429
- }
430
431
  }