tinywebui 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -13
- package/bin/tinywebui.js +69 -63
- package/package.json +25 -8
- package/public/app.js +40 -14
- package/public/chat/composer.js +1 -1
- package/public/chat/sidebar.js +8 -5
- package/public/index.html +88 -49
- package/public/panels/admin.js +40 -1
- package/public/panels/model-editor.js +336 -0
- package/public/panels/panels.js +37 -12
- package/public/panels/settings-home.js +125 -0
- package/public/panels/settings.js +124 -36
- package/public/shared/attachment_note.js +7 -1
- package/public/shared/events.js +1 -0
- package/public/shared/features.js +1 -0
- package/public/shared/origins.js +1 -0
- package/public/shared/usage.js +16 -1
- package/public/styles.css +83 -20
- package/src/access/auth.js +155 -155
- package/src/access/auth_gate.js +39 -38
- package/src/access/email.js +17 -0
- package/src/access/policy.js +123 -138
- package/src/audit.js +1 -1
- package/src/automations/automation.js +135 -123
- package/src/automations/scheduler.js +138 -127
- package/src/chat/compact.js +149 -156
- package/src/chat/llm.js +380 -382
- package/src/chat/runs.js +162 -151
- package/src/chat/tool_executor.js +229 -218
- package/src/cli.js +535 -433
- package/src/config/approval.js +35 -29
- package/src/config/config.js +546 -451
- package/src/config/data_dir.js +24 -0
- package/src/config/models.js +191 -115
- package/src/config/refs.js +32 -0
- package/src/config/secrets.js +153 -0
- package/src/files/documents.js +57 -68
- package/src/files/images.js +30 -31
- package/src/harness/agent.js +60 -60
- package/src/harness/attribution.js +66 -58
- package/src/harness/checkpoint.js +67 -62
- package/src/harness/history.js +28 -23
- package/src/harness/prompt.js +40 -57
- package/src/harness/provider.js +284 -283
- package/src/harness/types.js +1 -0
- package/src/http.js +98 -66
- package/src/log.js +11 -12
- package/src/mcp.js +398 -374
- package/src/retrieval/embedding.js +228 -240
- package/src/retrieval/retrieval.js +182 -173
- package/src/routes/admin.js +178 -138
- package/src/routes/auth.js +184 -100
- package/src/routes/automations.js +99 -82
- package/src/routes/chats.js +240 -218
- package/src/routes/documents.js +130 -120
- package/src/routes/library.js +43 -46
- package/src/routes/mcp.js +49 -49
- package/src/routes/settings.js +127 -122
- package/src/routes/types.js +1 -0
- package/src/server.js +428 -392
- package/src/store/automations.js +106 -106
- package/src/store/chats.js +217 -242
- package/src/store/documents.js +181 -185
- package/src/store/embeddings.js +52 -54
- package/src/store/index.js +95 -49
- package/src/store/messages.js +55 -80
- package/src/store/schema.js +128 -107
- package/src/store/scope.js +18 -17
- package/src/store/search.js +25 -27
- package/src/store/turns.js +53 -53
- package/src/store/usage.js +343 -269
- package/src/store/users.js +162 -87
- package/src/store/wire.js +50 -41
- package/src/tools/ask_tool.js +68 -68
- package/src/tools/chat_search_tool.js +38 -44
- package/src/tools/context_tool.js +131 -138
- package/src/tools/document_tool.js +73 -78
- package/src/tools/mcp_tool.js +274 -232
- package/src/tools/task_tool.js +53 -50
- package/src/tools/types.js +1 -0
- package/public/panels/connectors.js +0 -184
- package/src/set-password.js +0 -43
package/src/harness/provider.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
}
|