konsul-ai 0.2.4
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/LICENSE +21 -0
- package/README.md +99 -0
- package/dist/abort.d.ts +3 -0
- package/dist/abort.js +20 -0
- package/dist/config.d.ts +182 -0
- package/dist/config.js +218 -0
- package/dist/council.d.ts +31 -0
- package/dist/council.js +395 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +616 -0
- package/dist/router.d.ts +61 -0
- package/dist/router.js +268 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.js +233 -0
- package/dist/types.d.ts +52 -0
- package/dist/types.js +1 -0
- package/dist/web/app.js +760 -0
- package/dist/web/index.html +13 -0
- package/dist/web/style.css +554 -0
- package/package.json +48 -0
package/dist/router.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { combineSignals, isAbortError, normalizeAbortReason } from "./abort.js";
|
|
2
|
+
const OPENROUTER_BASE = process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1";
|
|
3
|
+
export class Router {
|
|
4
|
+
apiKey;
|
|
5
|
+
timeoutMs;
|
|
6
|
+
constructor(apiKey, timeoutMs = 60_000) {
|
|
7
|
+
this.apiKey = apiKey;
|
|
8
|
+
this.timeoutMs = timeoutMs;
|
|
9
|
+
}
|
|
10
|
+
buildHeaders() {
|
|
11
|
+
return {
|
|
12
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
13
|
+
"Content-Type": "application/json",
|
|
14
|
+
"HTTP-Referer": "https://github.com/model-council",
|
|
15
|
+
"X-Title": "model-council",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
handleFetchError(model, err, signal) {
|
|
19
|
+
if (err instanceof DOMException && err.name === "TimeoutError") {
|
|
20
|
+
throw new Error(`${model}: timed out after ${this.timeoutMs / 1000}s`);
|
|
21
|
+
}
|
|
22
|
+
if (isAbortError(err)) {
|
|
23
|
+
throw normalizeAbortReason(err);
|
|
24
|
+
}
|
|
25
|
+
if (signal?.aborted) {
|
|
26
|
+
throw normalizeAbortReason(signal.reason, `${model}: ${err instanceof Error ? err.message : String(err)}`);
|
|
27
|
+
}
|
|
28
|
+
throw new Error(`${model}: ${err instanceof Error ? err.message : String(err)}`);
|
|
29
|
+
}
|
|
30
|
+
buildSignal(timeoutMs, signal) {
|
|
31
|
+
return signal ? AbortSignal.any([AbortSignal.timeout(timeoutMs), signal]) : AbortSignal.timeout(timeoutMs);
|
|
32
|
+
}
|
|
33
|
+
async checkResponse(model, res) {
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
const body = await res.text();
|
|
36
|
+
const hint = res.status === 401 ? " (check your OPENROUTER_API_KEY)" :
|
|
37
|
+
res.status === 402 ? " (insufficient credits — top up at openrouter.ai/credits)" :
|
|
38
|
+
res.status === 429 ? " (rate limited — wait a moment and retry)" :
|
|
39
|
+
res.status === 503 ? " (model temporarily unavailable)" :
|
|
40
|
+
"";
|
|
41
|
+
throw new Error(`${model}: ${res.status}${hint} — ${body}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async complete(model, messages, opts = {}) {
|
|
45
|
+
let res;
|
|
46
|
+
try {
|
|
47
|
+
res = await fetch(`${OPENROUTER_BASE}/chat/completions`, {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: this.buildHeaders(),
|
|
50
|
+
body: JSON.stringify({
|
|
51
|
+
model,
|
|
52
|
+
messages,
|
|
53
|
+
temperature: opts.temperature ?? 0.7,
|
|
54
|
+
max_tokens: opts.maxTokens ?? 8192,
|
|
55
|
+
...(opts.plugins?.length ? { plugins: opts.plugins } : {}),
|
|
56
|
+
}),
|
|
57
|
+
signal: this.buildSignal(this.timeoutMs, opts.signal),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
this.handleFetchError(model, err, opts.signal);
|
|
62
|
+
}
|
|
63
|
+
await this.checkResponse(model, res);
|
|
64
|
+
const data = (await res.json());
|
|
65
|
+
const content = data.choices?.[0]?.message?.content ?? "";
|
|
66
|
+
const usage = data.usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
|
|
67
|
+
return {
|
|
68
|
+
content,
|
|
69
|
+
usage: { prompt: usage.prompt_tokens, completion: usage.completion_tokens },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** Streaming completion — calls onChunk for each token as it arrives. */
|
|
73
|
+
async completeStream(model, messages, opts, onChunk) {
|
|
74
|
+
let res;
|
|
75
|
+
try {
|
|
76
|
+
res = await fetch(`${OPENROUTER_BASE}/chat/completions`, {
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: this.buildHeaders(),
|
|
79
|
+
body: JSON.stringify({
|
|
80
|
+
model,
|
|
81
|
+
messages,
|
|
82
|
+
temperature: opts.temperature ?? 0.7,
|
|
83
|
+
max_tokens: opts.maxTokens ?? 8192,
|
|
84
|
+
stream: true,
|
|
85
|
+
...(opts.plugins?.length ? { plugins: opts.plugins } : {}),
|
|
86
|
+
}),
|
|
87
|
+
signal: this.buildSignal(this.timeoutMs * 2, opts.signal),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
this.handleFetchError(model, err, opts.signal);
|
|
92
|
+
}
|
|
93
|
+
await this.checkResponse(model, res);
|
|
94
|
+
if (!res.body) {
|
|
95
|
+
throw new Error(`${model}: response body is empty`);
|
|
96
|
+
}
|
|
97
|
+
const reader = res.body.getReader();
|
|
98
|
+
const decoder = new TextDecoder();
|
|
99
|
+
let full = "";
|
|
100
|
+
let buffer = "";
|
|
101
|
+
let usage = { prompt: 0, completion: 0 };
|
|
102
|
+
const handleSseLine = (line) => {
|
|
103
|
+
const normalized = line.trim();
|
|
104
|
+
if (!normalized.startsWith("data: "))
|
|
105
|
+
return;
|
|
106
|
+
const data = normalized.slice(6).trim();
|
|
107
|
+
if (data === "[DONE]")
|
|
108
|
+
return;
|
|
109
|
+
try {
|
|
110
|
+
const parsed = JSON.parse(data);
|
|
111
|
+
const delta = parsed.choices?.[0]?.delta?.content ?? "";
|
|
112
|
+
if (delta) {
|
|
113
|
+
full += delta;
|
|
114
|
+
onChunk(delta);
|
|
115
|
+
}
|
|
116
|
+
if (parsed.usage) {
|
|
117
|
+
usage = {
|
|
118
|
+
prompt: parsed.usage.prompt_tokens ?? 0,
|
|
119
|
+
completion: parsed.usage.completion_tokens ?? 0,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch { /* skip malformed SSE chunks */ }
|
|
124
|
+
};
|
|
125
|
+
try {
|
|
126
|
+
while (true) {
|
|
127
|
+
const { done, value } = await reader.read();
|
|
128
|
+
if (done)
|
|
129
|
+
break;
|
|
130
|
+
buffer += decoder.decode(value, { stream: true });
|
|
131
|
+
const lines = buffer.split("\n");
|
|
132
|
+
buffer = lines.pop();
|
|
133
|
+
for (const line of lines) {
|
|
134
|
+
handleSseLine(line);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (buffer.trim()) {
|
|
138
|
+
handleSseLine(buffer);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
reader.releaseLock();
|
|
143
|
+
}
|
|
144
|
+
return { content: full, usage };
|
|
145
|
+
}
|
|
146
|
+
/** Fetch available models from OpenRouter. */
|
|
147
|
+
async listModels() {
|
|
148
|
+
let res;
|
|
149
|
+
try {
|
|
150
|
+
res = await fetch(`${OPENROUTER_BASE}/models`, {
|
|
151
|
+
headers: this.buildHeaders(),
|
|
152
|
+
signal: AbortSignal.timeout(15_000),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
throw new Error(`Failed to fetch models: ${err instanceof Error ? err.message : String(err)}`);
|
|
157
|
+
}
|
|
158
|
+
if (!res.ok) {
|
|
159
|
+
throw new Error(`Failed to fetch models: HTTP ${res.status}`);
|
|
160
|
+
}
|
|
161
|
+
const body = (await res.json());
|
|
162
|
+
return body.data
|
|
163
|
+
.filter((m) => {
|
|
164
|
+
const pp = parseFloat(m.pricing?.prompt ?? "");
|
|
165
|
+
const cp = parseFloat(m.pricing?.completion ?? "");
|
|
166
|
+
return !isNaN(pp) && !isNaN(cp) && (m.context_length ?? 0) > 0;
|
|
167
|
+
})
|
|
168
|
+
.map((m) => ({
|
|
169
|
+
id: m.id,
|
|
170
|
+
name: m.name,
|
|
171
|
+
promptPrice: parseFloat(m.pricing.prompt) * 1_000_000,
|
|
172
|
+
completionPrice: parseFloat(m.pricing.completion) * 1_000_000,
|
|
173
|
+
contextLength: m.context_length,
|
|
174
|
+
modality: m.architecture?.modality ?? null,
|
|
175
|
+
}))
|
|
176
|
+
.sort((a, b) => a.promptPrice - b.promptPrice || a.name.localeCompare(b.name));
|
|
177
|
+
}
|
|
178
|
+
/** Fire completions in parallel, settle all (don't fail-fast). */
|
|
179
|
+
async parallel(tasks, staggerMs = 0) {
|
|
180
|
+
if (staggerMs <= 0) {
|
|
181
|
+
return Promise.allSettled(tasks.map((fn) => fn()));
|
|
182
|
+
}
|
|
183
|
+
const delay_ = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
184
|
+
return Promise.allSettled(tasks.map(async (fn, i) => {
|
|
185
|
+
if (i > 0)
|
|
186
|
+
await delay_(i * staggerMs);
|
|
187
|
+
return fn();
|
|
188
|
+
}));
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Like parallel(), but proceeds early once minResults have fulfilled,
|
|
192
|
+
* giving remaining tasks a short grace period before skipping them.
|
|
193
|
+
*/
|
|
194
|
+
async parallelRace(tasks, opts = {}) {
|
|
195
|
+
if (tasks.length === 0)
|
|
196
|
+
return [];
|
|
197
|
+
const { staggerMs = 0, minResults = tasks.length, graceMs = 5000, signal } = opts;
|
|
198
|
+
const target = Math.min(minResults, tasks.length);
|
|
199
|
+
const controllers = tasks.map(() => new AbortController());
|
|
200
|
+
const promises = tasks.map(async (fn, i) => {
|
|
201
|
+
const taskSignal = combineSignals(controllers[i].signal, signal);
|
|
202
|
+
try {
|
|
203
|
+
if (staggerMs > 0 && i > 0)
|
|
204
|
+
await delay(i * staggerMs, taskSignal);
|
|
205
|
+
return await fn(taskSignal);
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
if (taskSignal?.aborted)
|
|
209
|
+
throw normalizeAbortReason(taskSignal.reason);
|
|
210
|
+
throw err;
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
const results = new Array(tasks.length).fill(null);
|
|
214
|
+
let fulfilled = 0;
|
|
215
|
+
const tracked = promises.map((p, i) => p.then((value) => {
|
|
216
|
+
if (results[i] !== null)
|
|
217
|
+
return;
|
|
218
|
+
results[i] = { status: "fulfilled", value };
|
|
219
|
+
fulfilled++;
|
|
220
|
+
}, (reason) => {
|
|
221
|
+
if (results[i] !== null)
|
|
222
|
+
return;
|
|
223
|
+
results[i] = { status: "rejected", reason };
|
|
224
|
+
}));
|
|
225
|
+
const allDone = Promise.allSettled(tracked);
|
|
226
|
+
const ready = new Promise((resolve) => {
|
|
227
|
+
let graceStarted = false;
|
|
228
|
+
const check = () => {
|
|
229
|
+
if (!graceStarted && fulfilled >= target) {
|
|
230
|
+
graceStarted = true;
|
|
231
|
+
if (graceMs <= 0) {
|
|
232
|
+
resolve();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
setTimeout(resolve, graceMs);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
tracked.forEach((t) => t.then(check));
|
|
239
|
+
});
|
|
240
|
+
await Promise.race([allDone, ready]);
|
|
241
|
+
for (let i = 0; i < results.length; i++) {
|
|
242
|
+
if (results[i] !== null)
|
|
243
|
+
continue;
|
|
244
|
+
const reason = new Error("Skipped (cancelled after grace period)");
|
|
245
|
+
results[i] = { status: "rejected", reason };
|
|
246
|
+
controllers[i].abort(new DOMException(reason.message, "AbortError"));
|
|
247
|
+
}
|
|
248
|
+
return results;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function delay(ms, signal) {
|
|
252
|
+
return new Promise((resolve, reject) => {
|
|
253
|
+
if (signal?.aborted) {
|
|
254
|
+
reject(normalizeAbortReason(signal.reason));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const onAbort = () => {
|
|
258
|
+
clearTimeout(timer);
|
|
259
|
+
signal?.removeEventListener("abort", onAbort);
|
|
260
|
+
reject(normalizeAbortReason(signal?.reason));
|
|
261
|
+
};
|
|
262
|
+
const timer = setTimeout(() => {
|
|
263
|
+
signal?.removeEventListener("abort", onAbort);
|
|
264
|
+
resolve();
|
|
265
|
+
}, ms);
|
|
266
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
267
|
+
});
|
|
268
|
+
}
|
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { Council, aggregateScores } from "./council.js";
|
|
4
|
+
import { Router } from "./router.js";
|
|
5
|
+
import { PRESETS, DEFAULT_PRESET, MODELS } from "./config.js";
|
|
6
|
+
const MIME = {
|
|
7
|
+
".html": "text/html; charset=utf-8",
|
|
8
|
+
".css": "text/css; charset=utf-8",
|
|
9
|
+
".js": "text/javascript; charset=utf-8",
|
|
10
|
+
};
|
|
11
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
12
|
+
class PayloadTooLargeError extends Error {
|
|
13
|
+
constructor(message = "Request body too large") {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "PayloadTooLargeError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function resolveWebDir() {
|
|
19
|
+
return new URL("web/", import.meta.url);
|
|
20
|
+
}
|
|
21
|
+
function serveStatic(res, file) {
|
|
22
|
+
const ext = file.slice(file.lastIndexOf("."));
|
|
23
|
+
const mime = MIME[ext];
|
|
24
|
+
if (!mime) {
|
|
25
|
+
res.writeHead(404).end("Not found");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const content = readFileSync(new URL(file, resolveWebDir()), "utf-8");
|
|
30
|
+
res.writeHead(200, { "Content-Type": mime }).end(content);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
res.writeHead(404).end("Not found");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function json(res, status, data) {
|
|
37
|
+
res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify(data));
|
|
38
|
+
}
|
|
39
|
+
function discardBody(req) {
|
|
40
|
+
req.on("error", () => { });
|
|
41
|
+
req.resume();
|
|
42
|
+
}
|
|
43
|
+
function firstHeader(value) {
|
|
44
|
+
return Array.isArray(value) ? value[0] : value;
|
|
45
|
+
}
|
|
46
|
+
function hasJsonContentType(req) {
|
|
47
|
+
const contentType = firstHeader(req.headers["content-type"]);
|
|
48
|
+
if (!contentType)
|
|
49
|
+
return false;
|
|
50
|
+
return contentType.split(";", 1)[0].trim().toLowerCase() === "application/json";
|
|
51
|
+
}
|
|
52
|
+
function isAllowedBrowserOrigin(req) {
|
|
53
|
+
const origin = firstHeader(req.headers.origin);
|
|
54
|
+
if (!origin)
|
|
55
|
+
return true;
|
|
56
|
+
try {
|
|
57
|
+
const url = new URL(origin);
|
|
58
|
+
return (url.protocol === "http:" &&
|
|
59
|
+
(url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]"));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function getContentLength(req) {
|
|
66
|
+
const contentLength = firstHeader(req.headers["content-length"]);
|
|
67
|
+
if (!contentLength || !/^\d+$/.test(contentLength))
|
|
68
|
+
return undefined;
|
|
69
|
+
return Number.parseInt(contentLength, 10);
|
|
70
|
+
}
|
|
71
|
+
function sendSSE(res, event, data) {
|
|
72
|
+
if (res.destroyed || res.writableEnded)
|
|
73
|
+
return;
|
|
74
|
+
try {
|
|
75
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// Ignore writes after the client has already gone away.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function readBody(req) {
|
|
82
|
+
const chunks = [];
|
|
83
|
+
let totalBytes = 0;
|
|
84
|
+
for await (const chunk of req) {
|
|
85
|
+
const buffer = chunk;
|
|
86
|
+
totalBytes += buffer.length;
|
|
87
|
+
if (totalBytes > MAX_BODY_BYTES) {
|
|
88
|
+
throw new PayloadTooLargeError(`Request body exceeds ${MAX_BODY_BYTES} bytes`);
|
|
89
|
+
}
|
|
90
|
+
chunks.push(buffer);
|
|
91
|
+
}
|
|
92
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
93
|
+
}
|
|
94
|
+
function buildConfig(preset, web) {
|
|
95
|
+
const base = PRESETS[preset] ?? PRESETS[DEFAULT_PRESET];
|
|
96
|
+
return {
|
|
97
|
+
members: [...base.members],
|
|
98
|
+
chair: { ...base.chair },
|
|
99
|
+
rounds: base.rounds,
|
|
100
|
+
temperature: base.temperature,
|
|
101
|
+
web,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function presetsInfo() {
|
|
105
|
+
const info = {};
|
|
106
|
+
for (const [name, cfg] of Object.entries(PRESETS)) {
|
|
107
|
+
info[name] = {
|
|
108
|
+
members: cfg.members.map((m) => m.label),
|
|
109
|
+
chair: cfg.chair.label,
|
|
110
|
+
rounds: cfg.rounds,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return info;
|
|
114
|
+
}
|
|
115
|
+
function modelsInfo() {
|
|
116
|
+
return Object.values(MODELS).map((m) => ({ id: m.id, label: m.label, role: m.role }));
|
|
117
|
+
}
|
|
118
|
+
async function handleCouncil(req, res, apiKey) {
|
|
119
|
+
if (!hasJsonContentType(req)) {
|
|
120
|
+
discardBody(req);
|
|
121
|
+
json(res, 415, { error: "Content-Type must be application/json" });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (!isAllowedBrowserOrigin(req)) {
|
|
125
|
+
discardBody(req);
|
|
126
|
+
json(res, 403, { error: "Origin not allowed" });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const contentLength = getContentLength(req);
|
|
130
|
+
if (contentLength !== undefined && contentLength > MAX_BODY_BYTES) {
|
|
131
|
+
discardBody(req);
|
|
132
|
+
json(res, 413, { error: `Request body must be <= ${MAX_BODY_BYTES} bytes` });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
let body;
|
|
136
|
+
try {
|
|
137
|
+
body = JSON.parse(await readBody(req));
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
if (err instanceof PayloadTooLargeError) {
|
|
141
|
+
json(res, 413, { error: `Request body must be <= ${MAX_BODY_BYTES} bytes` });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
json(res, 400, { error: "Invalid JSON" });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const query = body.query?.trim();
|
|
148
|
+
if (!query) {
|
|
149
|
+
json(res, 400, { error: "Missing query" });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const presetName = body.preset && body.preset in PRESETS ? body.preset : DEFAULT_PRESET;
|
|
153
|
+
const config = buildConfig(presetName, body.web);
|
|
154
|
+
const router = new Router(apiKey);
|
|
155
|
+
const disconnect = new AbortController();
|
|
156
|
+
const abortForDisconnect = () => {
|
|
157
|
+
if (disconnect.signal.aborted || res.writableEnded)
|
|
158
|
+
return;
|
|
159
|
+
disconnect.abort(new DOMException("Client disconnected", "AbortError"));
|
|
160
|
+
};
|
|
161
|
+
const cleanupDisconnect = () => {
|
|
162
|
+
req.off("aborted", abortForDisconnect);
|
|
163
|
+
res.off("close", abortForDisconnect);
|
|
164
|
+
};
|
|
165
|
+
req.once("aborted", abortForDisconnect);
|
|
166
|
+
res.once("close", abortForDisconnect);
|
|
167
|
+
res.writeHead(200, {
|
|
168
|
+
"Content-Type": "text/event-stream",
|
|
169
|
+
"Cache-Control": "no-cache",
|
|
170
|
+
"Connection": "keep-alive",
|
|
171
|
+
});
|
|
172
|
+
const council = new Council(router, config, (msg) => sendSSE(res, "log", { message: msg }), (chunk) => sendSSE(res, "stream", { text: chunk }));
|
|
173
|
+
if (body.history?.length) {
|
|
174
|
+
council.setHistory(body.history);
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
const result = await council.run(query, disconnect.signal);
|
|
178
|
+
if (disconnect.signal.aborted)
|
|
179
|
+
return;
|
|
180
|
+
const scores = aggregateScores(result.opinions, result.reviews);
|
|
181
|
+
sendSSE(res, "result", { ...result, scores });
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
if (disconnect.signal.aborted)
|
|
185
|
+
return;
|
|
186
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
187
|
+
sendSSE(res, "error", { message });
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
cleanupDisconnect();
|
|
191
|
+
}
|
|
192
|
+
res.end();
|
|
193
|
+
}
|
|
194
|
+
export function startServer(opts) {
|
|
195
|
+
const { apiKey, port = 3000, host = "127.0.0.1" } = opts;
|
|
196
|
+
const server = createServer(async (req, res) => {
|
|
197
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
198
|
+
const method = req.method ?? "GET";
|
|
199
|
+
// Static files
|
|
200
|
+
if (method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) {
|
|
201
|
+
serveStatic(res, "index.html");
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (method === "GET" && url.pathname === "/style.css") {
|
|
205
|
+
serveStatic(res, "style.css");
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (method === "GET" && url.pathname === "/app.js") {
|
|
209
|
+
serveStatic(res, "app.js");
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
// API routes
|
|
213
|
+
if (method === "GET" && url.pathname === "/api/presets") {
|
|
214
|
+
json(res, 200, presetsInfo());
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (method === "GET" && url.pathname === "/api/models") {
|
|
218
|
+
json(res, 200, modelsInfo());
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (method === "POST" && url.pathname === "/api/council") {
|
|
222
|
+
await handleCouncil(req, res, apiKey);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
json(res, 404, { error: "Not found" });
|
|
226
|
+
});
|
|
227
|
+
server.listen(port, host, () => {
|
|
228
|
+
const address = server.address();
|
|
229
|
+
const resolvedPort = typeof address === "object" && address ? address.port : port;
|
|
230
|
+
console.log(`\n⚖ Konsul web UI running at http://localhost:${resolvedPort}\n`);
|
|
231
|
+
});
|
|
232
|
+
return server;
|
|
233
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface ModelConfig {
|
|
2
|
+
id: string;
|
|
3
|
+
label: string;
|
|
4
|
+
role: "member" | "chair";
|
|
5
|
+
}
|
|
6
|
+
export interface CouncilConfig {
|
|
7
|
+
members: ModelConfig[];
|
|
8
|
+
chair: ModelConfig;
|
|
9
|
+
rounds: number;
|
|
10
|
+
temperature: number;
|
|
11
|
+
web?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface Opinion {
|
|
14
|
+
model: ModelConfig;
|
|
15
|
+
content: string;
|
|
16
|
+
latencyMs: number;
|
|
17
|
+
}
|
|
18
|
+
export interface Review {
|
|
19
|
+
reviewer: ModelConfig;
|
|
20
|
+
rankings: {
|
|
21
|
+
modelId: string;
|
|
22
|
+
label: string;
|
|
23
|
+
rank: number;
|
|
24
|
+
reasoning: string;
|
|
25
|
+
}[];
|
|
26
|
+
latencyMs: number;
|
|
27
|
+
}
|
|
28
|
+
export interface CouncilResult {
|
|
29
|
+
query: string;
|
|
30
|
+
opinions: Opinion[];
|
|
31
|
+
reviews: Review[];
|
|
32
|
+
synthesis: string;
|
|
33
|
+
totalLatencyMs: number;
|
|
34
|
+
tokenEstimate: number;
|
|
35
|
+
}
|
|
36
|
+
export interface RouterMessage {
|
|
37
|
+
role: "system" | "user" | "assistant";
|
|
38
|
+
content: string;
|
|
39
|
+
}
|
|
40
|
+
export interface RouterResponse {
|
|
41
|
+
id: string;
|
|
42
|
+
choices: {
|
|
43
|
+
message: {
|
|
44
|
+
content: string;
|
|
45
|
+
};
|
|
46
|
+
}[];
|
|
47
|
+
usage?: {
|
|
48
|
+
prompt_tokens: number;
|
|
49
|
+
completion_tokens: number;
|
|
50
|
+
total_tokens: number;
|
|
51
|
+
};
|
|
52
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|