Repository navigation
Expand file tree
/
Copy pathchat.js
More file actions
249 lines (227 loc) · 10.1 KB
/
Copy pathchat.js
File metadata and controls
249 lines (227 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
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
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
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
// Relaying a chat to the AI provider and streaming the reply back.
//
// The browser sends the whole conversation with every message (it lives in the browser's
// localStorage, not here). This file trims it to a sane size, adds the system prompt, calls
// the provider's Chat Completions API with the key, and passes the reply through to the
// browser as server-sent events while it is being generated. The key never leaves the server.
// Limits on what one request may send upstream. Every token of history is paid for again on
// every message, so an unbounded history is an unbounded bill.
const MAX_BODY_BYTES = 128 * 1024;
const MAX_MESSAGES = 40;
const MAX_MESSAGE_CHARS = 8_000;
const MAX_HISTORY_CHARS = 32_000;
const FIRST_TOKEN_TIMEOUT_MS = 60_000; // some models think for a while before the first word
const IDLE_TIMEOUT_MS = 60_000; // silence between chunks after that
const TOTAL_TIMEOUT_MS = 5 * 60_000;
// velixir's edge closes a response that sends nothing for 30 seconds, so a comment goes out
// every 15 while the model is thinking. Browsers ignore SSE comments.
const HEARTBEAT_MS = 15_000;
class RequestError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
// Keeps the newest messages that fit the limits. Only user and assistant turns are accepted:
// the system prompt is the server's to set, so a client cannot smuggle in its own.
function trimHistory(body) {
if (!body || !Array.isArray(body.messages)) {
throw new RequestError(400, 'Expected {"messages": [{"role": "user", "content": "..."}]}.');
}
const turns = body.messages
.filter((m) => m && (m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string')
.map((m) => ({ role: m.role, content: m.content }));
const last = turns.at(-1);
if (!last || last.role !== 'user' || !last.content.trim()) {
throw new RequestError(400, 'The last message must be yours, and not empty.');
}
if (last.content.length > MAX_MESSAGE_CHARS) {
throw new RequestError(413, `That message is too long. The limit is ${MAX_MESSAGE_CHARS.toLocaleString('en-GB')} characters.`);
}
// Walk back from the newest message, keeping whole messages while they fit. An older
// message that is too long on its own is cut down rather than breaking the conversation.
const kept = [];
let total = 0;
for (let i = turns.length - 1; i >= 0 && kept.length < MAX_MESSAGES; i--) {
const content = turns[i].content.slice(0, MAX_MESSAGE_CHARS);
if (total + content.length > MAX_HISTORY_CHARS) break;
total += content.length;
kept.unshift({ role: turns[i].role, content });
}
// Start on a user turn: some providers reject a conversation that opens with the assistant.
while (kept.length > 1 && kept[0].role !== 'user') kept.shift();
return kept;
}
// Anything that looks like a credential is removed before a provider's error message is shown
// in the browser. Some providers quote part of the key back in a 401.
function redact(text, apiKey) {
let out = String(text ?? '');
if (apiKey) out = out.split(apiKey).join('[redacted]');
return out
.replace(/\b(sk|gsk|pk|rk|key)[-_][A-Za-z0-9*._-]{6,}/gi, '[redacted]')
.replace(/Bearer\s+\S+/gi, 'Bearer [redacted]')
.slice(0, 300);
}
// Pulls the human-readable part out of whatever JSON (or text) a provider returns on error.
function providerMessage(text) {
try {
const json = JSON.parse(text);
const e = json.error ?? json;
const message = typeof e === 'string' ? e : (e.message ?? e.detail ?? json.message ?? json.detail);
if (message) return typeof message === 'string' ? message : JSON.stringify(message);
} catch {
// Not JSON: fall through to the raw text.
}
return String(text || '').trim();
}
function describeUpstreamError(status, text, config) {
const host = new URL(config.baseUrl).host;
const detail = redact(providerMessage(text), config.apiKey);
const says = detail ? ` The provider said: ${detail}` : '';
// No provider text for auth failures: it adds nothing, and it is where key fragments show up.
if (status === 401 || status === 403) {
return `The AI provider rejected the API key (${status}). Check that AI_API_KEY is a valid key for ${host}.`;
}
if (status === 404) {
return `The AI provider could not find the model or endpoint (404). Check AI_MODEL ("${config.model}") and AI_BASE_URL.${says}`;
}
if (status === 429) {
return `The AI provider is rate limiting this key, or its credit has run out (429). Wait a moment and try again, or check your plan with the provider.${says}`;
}
if (status >= 500) {
return `The AI provider had a problem on its side (${status}). Try again in a moment.${says}`;
}
return `The AI provider refused the request (${status}).${says}`;
}
// The text of one streamed chunk. Usually a string; some providers send a list of parts
// (text and "thinking"), of which only the text is shown.
function deltaText(delta) {
const content = delta?.content;
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content.map((part) => (typeof part === 'string' ? part : part?.type === 'text' ? part.text ?? '' : '')).join('');
}
return '';
}
async function streamChat({ config, messages, res }) {
// Headers go out before the provider is even called. The edge waits at most 30 seconds for
// them, and a slow provider must not turn into a gateway error. From here on, problems are
// reported inside the stream as an "error" event.
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-store, no-transform',
'X-Accel-Buffering': 'no', // tells nginx-style proxies not to hold the stream back
});
res.flushHeaders();
const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
const heartbeat = setInterval(() => res.write(': keep-alive\n\n'), HEARTBEAT_MS);
const controller = new AbortController();
let stopReason = null;
const stop = (reason) => {
if (stopReason) return;
stopReason = reason;
controller.abort();
};
// The browser went away (tab closed, Stop pressed): stop the provider too, so nobody pays
// for tokens that no one will read.
res.on('close', () => stop('client'));
const total = setTimeout(() => stop('timeout'), TOTAL_TIMEOUT_MS);
let idle = setTimeout(() => stop('timeout'), FIRST_TOKEN_TIMEOUT_MS);
const started = Date.now();
let chars = 0;
let finishReason = null;
let failed = false;
try {
const upstream = await fetch(`${config.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${config.apiKey}`,
'Content-Type': 'application/json',
Accept: 'text/event-stream',
},
body: JSON.stringify({
model: config.model,
messages: config.systemPrompt ? [{ role: 'system', content: config.systemPrompt }, ...messages] : messages,
stream: true,
}),
signal: controller.signal,
});
if (!upstream.ok) {
const text = (await upstream.text()).slice(0, 4000);
failed = true;
console.error(`upstream ${upstream.status}: ${redact(providerMessage(text), config.apiKey)}`);
send('error', { message: describeUpstreamError(upstream.status, text, config), status: upstream.status });
return;
}
// Server-sent events: blocks separated by a blank line, each with "data:" lines. Lines
// starting with ":" are comments (OpenRouter sends them as keep-alives) and are skipped.
const decoder = new TextDecoder();
let buffer = '';
let done = false;
const handleEvent = (block) => {
const data = block
.split(/\r?\n/)
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).replace(/^ /, ''))
.join('\n');
if (!data) return;
if (data === '[DONE]') {
done = true;
return;
}
let json;
try {
json = JSON.parse(data);
} catch {
return; // a malformed chunk is skipped rather than ending the reply
}
// Some providers report a failure mid-stream, after the 200 has gone out.
if (json.error) {
failed = true;
done = true;
const message = redact(providerMessage(JSON.stringify(json)), config.apiKey) || 'unknown error';
send('error', { message: `The AI provider stopped with an error: ${message}` });
return;
}
const choice = json.choices?.[0];
const text = deltaText(choice?.delta);
if (text) {
chars += text.length;
send('delta', { text });
}
if (choice?.finish_reason) finishReason = choice.finish_reason;
};
for await (const chunk of upstream.body) {
clearTimeout(idle);
idle = setTimeout(() => stop('timeout'), IDLE_TIMEOUT_MS);
buffer += decoder.decode(chunk, { stream: true });
const blocks = buffer.split(/\r?\n\r?\n/);
buffer = blocks.pop();
for (const block of blocks) {
handleEvent(block);
if (done) break;
}
if (done) break;
}
if (!done && buffer.trim()) handleEvent(buffer);
if (!failed) send('done', { finishReason });
} catch (err) {
if (stopReason === 'client') return; // nobody left to tell
failed = true;
const host = new URL(config.baseUrl).host;
const message = stopReason === 'timeout'
? `The AI provider at ${host} stopped responding, so the request was cancelled. Try again.`
: `Could not reach the AI provider at ${host} (${err.cause?.code || err.message}). Check AI_BASE_URL.`;
console.error(`upstream request failed: ${stopReason === 'timeout' ? 'timed out' : err.cause?.code || err.message}`);
send('error', { message });
} finally {
clearInterval(heartbeat);
clearTimeout(total);
clearTimeout(idle);
res.end();
// Sizes and timings only. The conversation itself is never logged.
console.log(`chat: ${messages.length} messages in, ${chars} characters out, ${Date.now() - started} ms` +
`${finishReason ? `, finish ${finishReason}` : ''}${failed ? ', failed' : ''}${stopReason === 'client' ? ', cancelled by the browser' : ''}`);
}
}
module.exports = { trimHistory, streamChat, RequestError, MAX_BODY_BYTES, MAX_MESSAGE_CHARS };