infinicode 2.3.4 → 2.3.6
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/.opencode/plugins/home-logo-animation.tsx +5 -1
- package/.opencode/plugins/mesh-commands.tsx +4 -3
- package/.opencode/plugins/routing-mode-display.tsx +6 -2
- package/.opencode/tui.json +2 -1
- package/dist/cli.js +25 -1
- package/dist/commands/mesh-install.d.ts +10 -0
- package/dist/commands/mesh-install.js +160 -0
- package/dist/commands/run.js +196 -41
- package/dist/kernel/agents/backends/cli-backend.d.ts +37 -0
- package/dist/kernel/agents/backends/cli-backend.js +132 -0
- package/dist/kernel/agents/backends/detect.d.ts +5 -0
- package/dist/kernel/agents/backends/detect.js +40 -0
- package/dist/kernel/agents/backends/index.d.ts +11 -0
- package/dist/kernel/agents/backends/index.js +3 -0
- package/dist/kernel/agents/backends/parse-utils.d.ts +20 -0
- package/dist/kernel/agents/backends/parse-utils.js +72 -0
- package/dist/kernel/agents/backends/registry.d.ts +30 -0
- package/dist/kernel/agents/backends/registry.js +140 -0
- package/dist/kernel/agents/backends/types.d.ts +65 -0
- package/dist/kernel/agents/backends/types.js +1 -0
- package/dist/kernel/agents/index.d.ts +8 -0
- package/dist/kernel/agents/index.js +8 -0
- package/dist/kernel/federation/federation.d.ts +54 -1
- package/dist/kernel/federation/federation.js +180 -8
- package/dist/kernel/federation/types.d.ts +26 -1
- package/dist/kernel/free-providers.js +83 -0
- package/dist/kernel/gateway/index.d.ts +7 -0
- package/dist/kernel/gateway/index.js +7 -0
- package/dist/kernel/gateway/openai-gateway.d.ts +137 -0
- package/dist/kernel/gateway/openai-gateway.js +723 -0
- package/dist/kernel/index.d.ts +3 -1
- package/dist/kernel/index.js +5 -1
- package/dist/kernel/logger.d.ts +16 -3
- package/dist/kernel/logger.js +38 -0
- package/dist/kernel/mcp/mcp-server.js +47 -8
- package/dist/kernel/orchestrator.d.ts +4 -0
- package/dist/kernel/orchestrator.js +34 -0
- package/dist/kernel/provider-manager.d.ts +13 -0
- package/dist/kernel/provider-manager.js +58 -5
- package/dist/kernel/providers/openai-compatible-provider.d.ts +6 -0
- package/dist/kernel/providers/openai-compatible-provider.js +22 -3
- package/dist/kernel/recovery-manager.js +55 -15
- package/dist/kernel/router.js +15 -2
- package/dist/kernel/types.d.ts +12 -2
- package/package.json +1 -1
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenKernel — OpenAI-compatible Gateway
|
|
3
|
+
*
|
|
4
|
+
* A local HTTP endpoint (`/v1/chat/completions`, `/v1/models`) that sits between
|
|
5
|
+
* the opencode TUI (the Harness) and the real LLM providers. Every chat turn the
|
|
6
|
+
* TUI makes flows THROUGH the kernel, so the interactive path gains full 1:1
|
|
7
|
+
* parity with the headless mission path:
|
|
8
|
+
*
|
|
9
|
+
* • routing — CapabilityRouter.rank() picks the best model per request
|
|
10
|
+
* (model "auto"), honoring the active policy + benchmarks.
|
|
11
|
+
* • escalation — on a provider error (e.g. Groq 400 "tool_use_failed"),
|
|
12
|
+
* RecoveryManager.classify() + router.escalate() swap to a
|
|
13
|
+
* stronger model (or rotate provider) and retry, transparently.
|
|
14
|
+
* • health/quota — recordSuccess/recordFailure/record429 feed the pool.
|
|
15
|
+
* • telemetry — MODEL_CHANGED / PROVIDER_CHANGED / RECOVERY events fire on
|
|
16
|
+
* the kernel bus, so /activity + the mesh see TUI traffic.
|
|
17
|
+
*
|
|
18
|
+
* Because the TUI *and* every upstream speak the OpenAI wire format, the gateway
|
|
19
|
+
* is a TRANSPARENT RELAY: it forwards messages/tools/tool_choice verbatim and
|
|
20
|
+
* streams the response back byte-for-byte, only choosing WHICH provider+model to
|
|
21
|
+
* hit. The kernel is purely the routing/recovery brain — no lossy re-encoding.
|
|
22
|
+
*
|
|
23
|
+
* The provider CALL is isolated behind the ProviderExecutor interface. The default
|
|
24
|
+
* FetchExecutor calls the upstream directly; a BifrostExecutor (or any other
|
|
25
|
+
* execution plane) can be dropped in later without touching the routing logic.
|
|
26
|
+
*/
|
|
27
|
+
import { createServer } from 'node:http';
|
|
28
|
+
/** Default executor: call the upstream OpenAI-compatible endpoint directly. */
|
|
29
|
+
export class FetchExecutor {
|
|
30
|
+
async execute(target, body, signal) {
|
|
31
|
+
const headers = { 'content-type': 'application/json' };
|
|
32
|
+
if (target.apiKey)
|
|
33
|
+
headers['authorization'] = `Bearer ${target.apiKey}`;
|
|
34
|
+
Object.assign(headers, target.headers ?? {}); // custom headers win (e.g. non-Authorization key header)
|
|
35
|
+
const url = `${target.baseURL.replace(/\/+$/, '')}/chat/completions`;
|
|
36
|
+
return fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Keep swapping models until one succeeds (or the pool is exhausted). A weak
|
|
40
|
+
// model hallucinating a tool call must never stop the flow — it just triggers
|
|
41
|
+
// the next escalation. Bounded only to cap worst-case latency on huge pools;
|
|
42
|
+
// the loop also exits early the moment nextTarget() runs out of untried models.
|
|
43
|
+
const MAX_ATTEMPTS = 8;
|
|
44
|
+
const MODELS_TTL_MS = 30_000;
|
|
45
|
+
// Stall guards — a provider that accepts the request then goes silent must not
|
|
46
|
+
// hang the TUI forever. Time out and swap instead.
|
|
47
|
+
const HEADERS_TIMEOUT_MS = 60_000; // to receive response headers
|
|
48
|
+
const FIRST_CHUNK_TIMEOUT_MS = 120_000; // to first streamed byte (reasoning models are slow to start)
|
|
49
|
+
const IDLE_TIMEOUT_MS = 60_000; // max silence BETWEEN chunks before we call it stalled
|
|
50
|
+
// Never buffer more than this many SSE frames while looking for a commit point —
|
|
51
|
+
// past it, start streaming regardless so the user always sees output flowing
|
|
52
|
+
// (prevents a whole long generation from being withheld until the very end).
|
|
53
|
+
const FLUSH_AFTER_FRAMES = 6;
|
|
54
|
+
/** Extract the concatenated `data:` payload from one SSE frame (or null). */
|
|
55
|
+
function sseData(frame) {
|
|
56
|
+
const lines = frame.split('\n').filter(l => l.startsWith('data:'));
|
|
57
|
+
if (lines.length === 0)
|
|
58
|
+
return null;
|
|
59
|
+
return lines.map(l => l.slice(5).trimStart()).join('');
|
|
60
|
+
}
|
|
61
|
+
export class KernelGateway {
|
|
62
|
+
opts;
|
|
63
|
+
server;
|
|
64
|
+
table = new Map();
|
|
65
|
+
executor;
|
|
66
|
+
boundPort = 0;
|
|
67
|
+
modelsCache;
|
|
68
|
+
constructor(opts) {
|
|
69
|
+
this.opts = opts;
|
|
70
|
+
for (const p of opts.providers)
|
|
71
|
+
this.table.set(p.id, p);
|
|
72
|
+
this.executor = opts.executor ?? new FetchExecutor();
|
|
73
|
+
}
|
|
74
|
+
get port() {
|
|
75
|
+
return this.boundPort;
|
|
76
|
+
}
|
|
77
|
+
get url() {
|
|
78
|
+
return `http://${this.opts.host ?? '127.0.0.1'}:${this.boundPort}/v1`;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Start listening. Scans ports [port .. port+20] then falls back to an OS-chosen
|
|
82
|
+
* ephemeral port. Retries past BOTH EADDRINUSE (a lingering session holds it) and
|
|
83
|
+
* EACCES (a Windows excluded/reserved range) — never dies on a single bad port,
|
|
84
|
+
* since the whole point is that the gateway must come up so the TUI gets routing.
|
|
85
|
+
*/
|
|
86
|
+
async start() {
|
|
87
|
+
const host = this.opts.host ?? '127.0.0.1';
|
|
88
|
+
const first = this.opts.port ?? 47915;
|
|
89
|
+
let lastErr;
|
|
90
|
+
for (let candidate = first; candidate <= first + 20; candidate++) {
|
|
91
|
+
try {
|
|
92
|
+
await this.listen(host, candidate);
|
|
93
|
+
this.opts.logger.info(`[gateway] OpenAI-compatible endpoint on ${host}:${this.boundPort} (${this.table.size} providers)`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
lastErr = err;
|
|
98
|
+
const code = err.code;
|
|
99
|
+
if (code !== 'EADDRINUSE' && code !== 'EACCES')
|
|
100
|
+
break; // unexpected — stop scanning, try ephemeral
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Last resort: let the OS pick any free port (bulletproof against reserved ranges).
|
|
104
|
+
try {
|
|
105
|
+
await this.listen(host, 0);
|
|
106
|
+
this.opts.logger.info(`[gateway] OpenAI-compatible endpoint on ${host}:${this.boundPort} (ephemeral, ${this.table.size} providers)`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
throw lastErr ?? err;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
listen(host, port) {
|
|
114
|
+
return new Promise((resolve, reject) => {
|
|
115
|
+
const server = createServer((req, res) => void this.handle(req, res));
|
|
116
|
+
server.once('error', reject);
|
|
117
|
+
server.listen(port, host, () => {
|
|
118
|
+
server.off('error', reject);
|
|
119
|
+
this.server = server;
|
|
120
|
+
const addr = server.address();
|
|
121
|
+
this.boundPort = typeof addr === 'object' && addr ? addr.port : port;
|
|
122
|
+
resolve();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
async stop() {
|
|
127
|
+
if (this.server) {
|
|
128
|
+
await new Promise(resolve => this.server.close(() => resolve()));
|
|
129
|
+
this.server = undefined;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// ── HTTP ────────────────────────────────────────────────────────────────────
|
|
133
|
+
async handle(req, res) {
|
|
134
|
+
const url = req.url ?? '/';
|
|
135
|
+
try {
|
|
136
|
+
if (req.method === 'GET' && (url.startsWith('/v1/models') || url === '/v1' || url === '/')) {
|
|
137
|
+
this.sendJson(res, 200, this.listModels());
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (req.method === 'POST' && url.startsWith('/v1/chat/completions')) {
|
|
141
|
+
const body = JSON.parse(await this.readBody(req));
|
|
142
|
+
await this.handleChat(body, res);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
this.sendJson(res, 404, { error: { message: 'not found', type: 'invalid_request_error' } });
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
149
|
+
this.opts.logger.warn(`[gateway] ${message}`);
|
|
150
|
+
if (!res.headersSent)
|
|
151
|
+
this.sendJson(res, 500, { error: { message, type: 'gateway_error' } });
|
|
152
|
+
else
|
|
153
|
+
try {
|
|
154
|
+
res.end();
|
|
155
|
+
}
|
|
156
|
+
catch { /* ignore */ }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
listModels() {
|
|
160
|
+
const data = [
|
|
161
|
+
{ id: 'auto', object: 'model', created: 0, owned_by: 'infinicode' },
|
|
162
|
+
];
|
|
163
|
+
for (const p of this.table.values()) {
|
|
164
|
+
for (const m of p.models)
|
|
165
|
+
data.push({ id: `${p.id}/${m.id}`, object: 'model', created: 0, owned_by: p.id });
|
|
166
|
+
}
|
|
167
|
+
return { object: 'list', data };
|
|
168
|
+
}
|
|
169
|
+
// ── Chat: route → execute → validate → swap-on-failure → relay ──────────────
|
|
170
|
+
async handleChat(body, res) {
|
|
171
|
+
const stream = body['stream'] === true;
|
|
172
|
+
const messages = Array.isArray(body['messages']) ? body['messages'] : [];
|
|
173
|
+
const estTokens = Math.ceil(JSON.stringify(messages).length / 4);
|
|
174
|
+
const requested = typeof body['model'] === 'string' ? body['model'] : '';
|
|
175
|
+
const toolNames = this.allowedTools(body);
|
|
176
|
+
const tried = new Set();
|
|
177
|
+
let target = await this.resolveTarget(requested, estTokens, tried);
|
|
178
|
+
if (!target) {
|
|
179
|
+
this.sendJson(res, 503, { error: { message: 'no providers available', type: 'gateway_error' } });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const controller = new AbortController();
|
|
183
|
+
res.on('close', () => controller.abort());
|
|
184
|
+
let lastStatus = 502;
|
|
185
|
+
let lastErr = 'no attempt made';
|
|
186
|
+
// `currentBody` may become a continuation request (original messages + the
|
|
187
|
+
// partial answer as an assistant prefix). `carried` is the whole answer
|
|
188
|
+
// streamed so far, accumulated across however many providers died mid-task.
|
|
189
|
+
let currentBody = body;
|
|
190
|
+
let carried = '';
|
|
191
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS && target; attempt++) {
|
|
192
|
+
tried.add(`${target.providerId}/${target.model}`);
|
|
193
|
+
const upstream = { ...currentBody, model: target.model, stream };
|
|
194
|
+
const outcome = await this.attempt(target, upstream, res, stream, toolNames, controller);
|
|
195
|
+
// Clean completion (streamed finish, or any non-stream response).
|
|
196
|
+
if (outcome.forwarded && outcome.complete !== false) {
|
|
197
|
+
this.opts.kernel.providerManager.recordSuccess(target.providerId);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (outcome.aborted)
|
|
201
|
+
return; // client hung up — nothing to do
|
|
202
|
+
// Mid-stream truncation: the provider streamed part of the answer then died
|
|
203
|
+
// (finish_reason:length, dropped connection, or went idle). We can't swap
|
|
204
|
+
// cleanly — the client already has the partial — so CONTINUE from it with a
|
|
205
|
+
// fresh model. This is the "long task never just stops" guarantee.
|
|
206
|
+
if (outcome.truncated && outcome.committed) {
|
|
207
|
+
carried += outcome.assembled ?? '';
|
|
208
|
+
this.opts.logger.warn(`[gateway] truncated on ${target.providerId}/${target.model} (${outcome.finishReason ?? '?'}) — continuing from ${carried.length} chars`);
|
|
209
|
+
this.opts.kernel.providerManager.penalize?.(target.providerId, 'provider-down');
|
|
210
|
+
const next = await this.nextTarget(target, `truncated ${outcome.finishReason ?? ''}`, estTokens, tried);
|
|
211
|
+
if (!next) {
|
|
212
|
+
this.endStream(res);
|
|
213
|
+
return;
|
|
214
|
+
} // pool spent — close cleanly, don't hang
|
|
215
|
+
currentBody = this.buildContinuation(body, carried);
|
|
216
|
+
this.emitSwitch(target, next, 'continue after truncation');
|
|
217
|
+
target = next;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
// Committed but failed in a way we can't continue from — end the open stream
|
|
221
|
+
// gracefully instead of trying to send an error over it.
|
|
222
|
+
if (outcome.committed) {
|
|
223
|
+
this.opts.logger.warn(`[gateway] committed stream failed on ${target.providerId}/${target.model}: ${outcome.error}`);
|
|
224
|
+
this.endStream(res);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
// Pre-commit failure — nothing sent yet, swap cleanly to the next model.
|
|
228
|
+
lastStatus = outcome.status;
|
|
229
|
+
lastErr = outcome.error;
|
|
230
|
+
this.recordError(target.providerId, outcome.status, outcome.error);
|
|
231
|
+
const next = await this.nextTarget(target, `${lastStatus} ${lastErr}`, estTokens, tried);
|
|
232
|
+
if (!next)
|
|
233
|
+
break;
|
|
234
|
+
this.emitSwitch(target, next, `${lastStatus} ${lastErr}`);
|
|
235
|
+
target = next;
|
|
236
|
+
}
|
|
237
|
+
if (!res.headersSent) {
|
|
238
|
+
this.sendJson(res, lastStatus >= 400 ? lastStatus : 502, {
|
|
239
|
+
error: { message: `all routes failed: ${lastErr}`, type: 'gateway_error' },
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
this.endStream(res); // exhausted attempts mid-stream — close so the TUI doesn't hang
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Build a continuation request: the original messages plus the partial answer as
|
|
248
|
+
* an assistant prefix so a fresh model resumes it instead of restarting. `prefix:
|
|
249
|
+
* true` is the continue-this-message convention (DeepSeek/Mistral/Zhipu/Qwen);
|
|
250
|
+
* providers that ignore it at worst restart — still better than a dead task.
|
|
251
|
+
*/
|
|
252
|
+
buildContinuation(body, assembled) {
|
|
253
|
+
const base = Array.isArray(body['messages']) ? body['messages'] : [];
|
|
254
|
+
const messages = assembled.length > 0
|
|
255
|
+
? [...base, { role: 'assistant', content: assembled, prefix: true }]
|
|
256
|
+
: [...base];
|
|
257
|
+
return { ...body, messages };
|
|
258
|
+
}
|
|
259
|
+
/** Close an already-open SSE stream with a synthetic clean finish so the client
|
|
260
|
+
* gets a proper end-of-turn instead of hanging on a half-open connection. */
|
|
261
|
+
endStream(res) {
|
|
262
|
+
try {
|
|
263
|
+
if (!res.headersSent) {
|
|
264
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
265
|
+
}
|
|
266
|
+
if (!res.writableEnded) {
|
|
267
|
+
res.write('data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n');
|
|
268
|
+
res.write('data: [DONE]\n\n');
|
|
269
|
+
res.end();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
catch { /* ignore */ }
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Run one model. Returns forwarded:true only after the response is validated
|
|
276
|
+
* and sent to the client; otherwise nothing is written to `res` and the caller
|
|
277
|
+
* swaps to the next model. Catches BOTH transport failures (non-2xx) AND bad
|
|
278
|
+
* tool calls the model streams back on a 200 (a weak model naming a tool that
|
|
279
|
+
* isn't in request.tools — e.g. `glob={...}` — which downstream would reject).
|
|
280
|
+
*/
|
|
281
|
+
async attempt(target, upstream, res, stream, toolNames, controller) {
|
|
282
|
+
// Per-attempt controller: aborts on client close OR our stall timeouts, so a
|
|
283
|
+
// silent provider can't hang the request. Linked to the parent controller.
|
|
284
|
+
const ctrl = new AbortController();
|
|
285
|
+
const onParentAbort = () => ctrl.abort();
|
|
286
|
+
if (controller.signal.aborted)
|
|
287
|
+
ctrl.abort();
|
|
288
|
+
else
|
|
289
|
+
controller.signal.addEventListener('abort', onParentAbort, { once: true });
|
|
290
|
+
const cleanup = () => controller.signal.removeEventListener('abort', onParentAbort);
|
|
291
|
+
let resp;
|
|
292
|
+
const headersTimer = setTimeout(() => ctrl.abort(), this.opts.headersTimeoutMs ?? HEADERS_TIMEOUT_MS);
|
|
293
|
+
try {
|
|
294
|
+
resp = await this.executor.execute(target, upstream, ctrl.signal);
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
clearTimeout(headersTimer);
|
|
298
|
+
cleanup();
|
|
299
|
+
if (controller.signal.aborted)
|
|
300
|
+
return { forwarded: false, status: 0, error: 'aborted', aborted: true };
|
|
301
|
+
return { forwarded: false, status: 504, error: `connect/headers timeout: ${err instanceof Error ? err.message : String(err)}` };
|
|
302
|
+
}
|
|
303
|
+
clearTimeout(headersTimer);
|
|
304
|
+
if (!resp.ok) {
|
|
305
|
+
cleanup();
|
|
306
|
+
const text = await resp.text().catch(() => `${resp.status}`);
|
|
307
|
+
return { forwarded: false, status: resp.status, error: text };
|
|
308
|
+
}
|
|
309
|
+
const ctype = resp.headers.get('content-type') ?? '';
|
|
310
|
+
if (stream && ctype.includes('text/event-stream') && resp.body) {
|
|
311
|
+
const out = await this.streamValidated(resp, res, toolNames, controller, ctrl);
|
|
312
|
+
cleanup();
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
// Non-streaming (or a provider that returned plain JSON despite stream=true).
|
|
316
|
+
try {
|
|
317
|
+
const text = await resp.text();
|
|
318
|
+
let parsed = null;
|
|
319
|
+
try {
|
|
320
|
+
parsed = JSON.parse(text);
|
|
321
|
+
}
|
|
322
|
+
catch { /* leave null */ }
|
|
323
|
+
const bad = this.validateResponse(parsed, toolNames);
|
|
324
|
+
if (bad)
|
|
325
|
+
return { forwarded: false, status: 200, error: bad };
|
|
326
|
+
res.writeHead(resp.status, { 'content-type': ctype || 'application/json' });
|
|
327
|
+
res.end(text);
|
|
328
|
+
return { forwarded: true, status: resp.status, error: '' };
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
cleanup();
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Relay an SSE stream, but with a small look-ahead: buffer frames until the
|
|
336
|
+
* first tool-call name or content delta. If that first tool call names a tool
|
|
337
|
+
* not in request.tools (or an error frame arrives), fail WITHOUT writing to the
|
|
338
|
+
* client so the caller can swap models. Once a valid start is seen, commit and
|
|
339
|
+
* stream the rest live (so large generations still stream token-by-token).
|
|
340
|
+
*/
|
|
341
|
+
async streamValidated(resp, res, toolNames, parentCtrl, ctrl) {
|
|
342
|
+
const reader = resp.body.getReader();
|
|
343
|
+
const decoder = new TextDecoder();
|
|
344
|
+
let raw = '';
|
|
345
|
+
let committed = res.headersSent; // a prior (continuation) attempt already opened the stream
|
|
346
|
+
let started = false;
|
|
347
|
+
// Terminal frames (a finish_reason marker or `[DONE]`) are HELD, not written
|
|
348
|
+
// live: if the stream turns out truncated we must NOT send a finish/[DONE] to
|
|
349
|
+
// the client (that would end the turn) — we swap and continue instead. On a
|
|
350
|
+
// clean finish we flush them so the client sees a proper end.
|
|
351
|
+
const held = [];
|
|
352
|
+
let assembled = ''; // visible answer text so far (for continuation prefix)
|
|
353
|
+
let finishReason = null;
|
|
354
|
+
let sawDone = false;
|
|
355
|
+
let sawToolCall = false;
|
|
356
|
+
const pending = [];
|
|
357
|
+
const commit = () => {
|
|
358
|
+
if (!res.headersSent) {
|
|
359
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
|
|
360
|
+
}
|
|
361
|
+
for (const p of pending)
|
|
362
|
+
res.write(p);
|
|
363
|
+
pending.length = 0;
|
|
364
|
+
committed = true;
|
|
365
|
+
};
|
|
366
|
+
// What did this stream actually do? Distinguishes "done for real" from
|
|
367
|
+
// "cut off mid-answer", which is the whole point of continuation.
|
|
368
|
+
const classify = (endReason) => {
|
|
369
|
+
const clean = sawToolCall
|
|
370
|
+
|| finishReason === 'stop'
|
|
371
|
+
|| finishReason === 'tool_calls'
|
|
372
|
+
|| (sawDone && finishReason !== 'length');
|
|
373
|
+
if (clean) {
|
|
374
|
+
// Real completion — flush the held terminal frames + trailing partial.
|
|
375
|
+
if (!committed)
|
|
376
|
+
commit();
|
|
377
|
+
for (const h of held)
|
|
378
|
+
res.write(h);
|
|
379
|
+
if (raw)
|
|
380
|
+
res.write(raw);
|
|
381
|
+
if (!res.writableEnded)
|
|
382
|
+
res.end();
|
|
383
|
+
return { forwarded: true, status: 200, error: '', committed: true, complete: true, assembled };
|
|
384
|
+
}
|
|
385
|
+
// Never committed anything and the stream is already over → clean swap
|
|
386
|
+
// (nothing was sent to the client, so the caller can retry from scratch).
|
|
387
|
+
if (!committed && assembled.length === 0) {
|
|
388
|
+
return { forwarded: false, status: endReason === 'done' ? 502 : 504, error: `truncated before any output (${endReason}${finishReason ? ' ' + finishReason : ''})` };
|
|
389
|
+
}
|
|
390
|
+
// Committed + truncated (finish_reason:length, dropped connection, or idle
|
|
391
|
+
// stall). We've already streamed part of the answer, so we CANNOT swap
|
|
392
|
+
// cleanly — signal the caller to CONTINUE from `assembled` with a fresh model.
|
|
393
|
+
if (!committed)
|
|
394
|
+
commit(); // flush what we buffered so the client sees it
|
|
395
|
+
return {
|
|
396
|
+
forwarded: false, status: 0, error: `stream truncated (${endReason}${finishReason ? ' ' + finishReason : ''})`,
|
|
397
|
+
committed: true, complete: false, assembled, truncated: true, finishReason: finishReason ?? endReason,
|
|
398
|
+
};
|
|
399
|
+
};
|
|
400
|
+
for (;;) {
|
|
401
|
+
// Race each read against a stall timeout (longer for the first token). On a
|
|
402
|
+
// stall we abort the upstream and — if nothing has been sent yet — swap.
|
|
403
|
+
const limit = started
|
|
404
|
+
? (this.opts.idleTimeoutMs ?? IDLE_TIMEOUT_MS)
|
|
405
|
+
: (this.opts.firstChunkTimeoutMs ?? FIRST_CHUNK_TIMEOUT_MS);
|
|
406
|
+
let timer;
|
|
407
|
+
const readP = reader.read().then(r => ({ r })).catch(e => ({ err: e }));
|
|
408
|
+
const outcome = await Promise.race([
|
|
409
|
+
readP,
|
|
410
|
+
new Promise(resolve => { timer = setTimeout(() => resolve({ idle: true }), limit); }),
|
|
411
|
+
]);
|
|
412
|
+
clearTimeout(timer);
|
|
413
|
+
if ('idle' in outcome) {
|
|
414
|
+
ctrl.abort();
|
|
415
|
+
try {
|
|
416
|
+
await reader.cancel();
|
|
417
|
+
}
|
|
418
|
+
catch { /* ignore */ }
|
|
419
|
+
if (!committed) {
|
|
420
|
+
return { forwarded: false, status: 504, error: `upstream stalled (${started ? 'idle' : 'no first token'} >${limit / 1000}s)` };
|
|
421
|
+
}
|
|
422
|
+
return classify('idle'); // streaming already started — continue from partial
|
|
423
|
+
}
|
|
424
|
+
if ('err' in outcome) {
|
|
425
|
+
if (parentCtrl.signal.aborted)
|
|
426
|
+
return { forwarded: false, status: 0, error: 'aborted', aborted: true };
|
|
427
|
+
if (!committed)
|
|
428
|
+
return { forwarded: false, status: 502, error: outcome.err instanceof Error ? outcome.err.message : String(outcome.err) };
|
|
429
|
+
return classify('err');
|
|
430
|
+
}
|
|
431
|
+
const { done, value } = outcome.r;
|
|
432
|
+
if (done)
|
|
433
|
+
return classify('done');
|
|
434
|
+
started = true;
|
|
435
|
+
raw += decoder.decode(value, { stream: true });
|
|
436
|
+
const parts = raw.split('\n\n');
|
|
437
|
+
raw = parts.pop() ?? '';
|
|
438
|
+
for (const frame of parts) {
|
|
439
|
+
const payload = frame + '\n\n';
|
|
440
|
+
const verdict = this.inspectFrame(frame, toolNames);
|
|
441
|
+
// Track completion signals + accumulate the visible answer for continuation.
|
|
442
|
+
if (verdict.done)
|
|
443
|
+
sawDone = true;
|
|
444
|
+
if (verdict.toolCall)
|
|
445
|
+
sawToolCall = true;
|
|
446
|
+
if (verdict.finishReason)
|
|
447
|
+
finishReason = verdict.finishReason;
|
|
448
|
+
if (verdict.content)
|
|
449
|
+
assembled += verdict.content;
|
|
450
|
+
if (!committed) {
|
|
451
|
+
if (verdict.error) {
|
|
452
|
+
try {
|
|
453
|
+
await reader.cancel();
|
|
454
|
+
}
|
|
455
|
+
catch { /* ignore */ }
|
|
456
|
+
return { forwarded: false, status: 200, error: verdict.error };
|
|
457
|
+
}
|
|
458
|
+
if (verdict.commit || pending.length >= FLUSH_AFTER_FRAMES)
|
|
459
|
+
commit();
|
|
460
|
+
}
|
|
461
|
+
// Hold terminal frames (finish/`[DONE]`) so a truncation can suppress them;
|
|
462
|
+
// stream everything else live.
|
|
463
|
+
if (verdict.terminal) {
|
|
464
|
+
held.push(payload);
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
if (committed)
|
|
468
|
+
res.write(payload);
|
|
469
|
+
else
|
|
470
|
+
pending.push(payload);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Classify one SSE frame: does it error, is it a safe commit point, is it a
|
|
476
|
+
* TERMINAL frame (finish_reason / `[DONE]` — held back so a truncation can be
|
|
477
|
+
* continued), and what visible content does it carry (accumulated so a fresh
|
|
478
|
+
* model can continue the answer from where a dead provider left off)?
|
|
479
|
+
*/
|
|
480
|
+
inspectFrame(frame, toolNames) {
|
|
481
|
+
const data = sseData(frame);
|
|
482
|
+
if (!data)
|
|
483
|
+
return { commit: false };
|
|
484
|
+
if (data === '[DONE]')
|
|
485
|
+
return { commit: true, terminal: true, done: true }; // end of stream
|
|
486
|
+
let obj = null;
|
|
487
|
+
try {
|
|
488
|
+
obj = JSON.parse(data);
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
return { commit: false };
|
|
492
|
+
}
|
|
493
|
+
if (obj && obj['error']) {
|
|
494
|
+
const e = obj['error'];
|
|
495
|
+
return { commit: false, error: typeof e === 'string' ? e : JSON.stringify(e) };
|
|
496
|
+
}
|
|
497
|
+
const choice = obj?.choices?.[0];
|
|
498
|
+
const delta = choice?.delta;
|
|
499
|
+
const finish = choice?.finish_reason;
|
|
500
|
+
const finishReason = typeof finish === 'string' && finish.length > 0 ? finish : undefined;
|
|
501
|
+
const tcName = delta?.tool_calls?.[0]?.function?.name;
|
|
502
|
+
if (typeof tcName === 'string' && tcName.length > 0) {
|
|
503
|
+
if (toolNames.size > 0 && !toolNames.has(tcName)) {
|
|
504
|
+
return { commit: false, error: `tool call validation failed: '${tcName}' was not in request.tools` };
|
|
505
|
+
}
|
|
506
|
+
return { commit: true, toolCall: true, finishReason };
|
|
507
|
+
}
|
|
508
|
+
// Only real answer text (delta.content) feeds the continuation prefix; reasoning
|
|
509
|
+
// tokens are internal and must not be replayed as the assistant's answer.
|
|
510
|
+
const visible = typeof delta?.content === 'string' ? delta.content : '';
|
|
511
|
+
const anyText = visible || (typeof delta?.reasoning_content === 'string' ? delta.reasoning_content : '')
|
|
512
|
+
|| (typeof delta?.reasoning === 'string' ? delta.reasoning : '');
|
|
513
|
+
if (finishReason)
|
|
514
|
+
return { commit: true, terminal: true, finishReason, content: visible || undefined };
|
|
515
|
+
if (anyText.length > 0)
|
|
516
|
+
return { commit: true, content: visible || undefined };
|
|
517
|
+
return { commit: false };
|
|
518
|
+
}
|
|
519
|
+
/** Validate a non-streamed response body for unknown tool calls / errors. */
|
|
520
|
+
validateResponse(parsed, toolNames) {
|
|
521
|
+
if (!parsed || typeof parsed !== 'object')
|
|
522
|
+
return null;
|
|
523
|
+
const obj = parsed;
|
|
524
|
+
if (obj['error']) {
|
|
525
|
+
const e = obj['error'];
|
|
526
|
+
return typeof e === 'string' ? e : JSON.stringify(e);
|
|
527
|
+
}
|
|
528
|
+
if (toolNames.size === 0)
|
|
529
|
+
return null;
|
|
530
|
+
const calls = obj
|
|
531
|
+
?.choices?.[0]?.message?.tool_calls;
|
|
532
|
+
if (Array.isArray(calls)) {
|
|
533
|
+
for (const c of calls) {
|
|
534
|
+
const n = c?.function?.name;
|
|
535
|
+
if (typeof n === 'string' && !toolNames.has(n)) {
|
|
536
|
+
return `tool call validation failed: '${n}' was not in request.tools`;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
/** The set of tool names the request declared (OpenAI tools[].function.name). */
|
|
543
|
+
allowedTools(body) {
|
|
544
|
+
const set = new Set();
|
|
545
|
+
const tools = body['tools'];
|
|
546
|
+
if (Array.isArray(tools)) {
|
|
547
|
+
for (const t of tools) {
|
|
548
|
+
const n = t?.function?.name ?? t?.name;
|
|
549
|
+
if (typeof n === 'string')
|
|
550
|
+
set.add(n);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return set;
|
|
554
|
+
}
|
|
555
|
+
// ── Routing helpers ──────────────────────────────────────────────────────────
|
|
556
|
+
/** Resolve the initial target: honor an explicit provider/model, else route. */
|
|
557
|
+
async resolveTarget(requested, estTokens, tried) {
|
|
558
|
+
const raw = requested.trim();
|
|
559
|
+
const isAuto = raw === '' || raw === 'auto' || raw === 'infinicode/auto' || raw.endsWith('/auto');
|
|
560
|
+
if (!isAuto) {
|
|
561
|
+
const slash = raw.indexOf('/');
|
|
562
|
+
if (slash > 0) {
|
|
563
|
+
const providerId = raw.slice(0, slash);
|
|
564
|
+
const modelId = raw.slice(slash + 1);
|
|
565
|
+
const gp = this.table.get(providerId);
|
|
566
|
+
if (gp)
|
|
567
|
+
return this.toTarget(gp, modelId);
|
|
568
|
+
}
|
|
569
|
+
// bare model id — find its owner
|
|
570
|
+
for (const gp of this.table.values()) {
|
|
571
|
+
if (gp.models.some(m => m.id === raw))
|
|
572
|
+
return this.toTarget(gp, raw);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return this.routerPick(estTokens, tried);
|
|
576
|
+
}
|
|
577
|
+
/** Ask the kernel router for the best untried, executable model. */
|
|
578
|
+
async routerPick(estTokens, tried) {
|
|
579
|
+
const ranked = await this.rank(estTokens);
|
|
580
|
+
for (const d of ranked) {
|
|
581
|
+
const gp = this.table.get(d.providerId);
|
|
582
|
+
if (gp && !tried.has(`${d.providerId}/${d.modelId}`))
|
|
583
|
+
return this.toTarget(gp, d.modelId);
|
|
584
|
+
}
|
|
585
|
+
// last resort: first configured model not yet tried
|
|
586
|
+
for (const gp of this.table.values()) {
|
|
587
|
+
for (const m of gp.models) {
|
|
588
|
+
if (!tried.has(`${gp.id}/${m.id}`))
|
|
589
|
+
return this.toTarget(gp, m.id);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Choose the next target after a failure. 429 / provider-down → rotate to a
|
|
596
|
+
* different provider; everything else (incl. Groq's tool_use_failed) →
|
|
597
|
+
* escalate to a stronger model via the kernel ladder, then fall back to rank.
|
|
598
|
+
*/
|
|
599
|
+
async nextTarget(current, errText, estTokens, tried) {
|
|
600
|
+
const kind = this.opts.kernel.recovery.classify(errText);
|
|
601
|
+
const ranked = await this.rank(estTokens);
|
|
602
|
+
const usable = ranked.filter(d => this.table.has(d.providerId) && !tried.has(`${d.providerId}/${d.modelId}`));
|
|
603
|
+
// Provider-level failures (rate limit, credits, bad key, outage, filter) →
|
|
604
|
+
// move to a DIFFERENT provider; escalating to a bigger model on the same dead
|
|
605
|
+
// provider would just fail again.
|
|
606
|
+
const rotateKinds = new Set(['429', 'provider-down', 'quota-exhausted', 'auth-failure', 'content-filter']);
|
|
607
|
+
if (rotateKinds.has(kind)) {
|
|
608
|
+
const diffProvider = usable.find(d => d.providerId !== current.providerId);
|
|
609
|
+
if (diffProvider)
|
|
610
|
+
return this.toTarget(this.table.get(diffProvider.providerId), diffProvider.modelId);
|
|
611
|
+
if (usable[0])
|
|
612
|
+
return this.toTarget(this.table.get(usable[0].providerId), usable[0].modelId);
|
|
613
|
+
// Ranking gave nothing usable — still rotate to ANY untried provider/model.
|
|
614
|
+
return this.firstUntried(tried, current.providerId) ?? this.firstUntried(tried);
|
|
615
|
+
}
|
|
616
|
+
// escalate: strictly stronger model than the current one
|
|
617
|
+
const esc = this.opts.kernel.router.escalate(await this.routingRequest(estTokens), current.providerId, current.model);
|
|
618
|
+
if (esc && this.table.has(esc.providerId) && !tried.has(`${esc.providerId}/${esc.modelId}`)) {
|
|
619
|
+
return this.toTarget(this.table.get(esc.providerId), esc.modelId);
|
|
620
|
+
}
|
|
621
|
+
if (usable[0])
|
|
622
|
+
return this.toTarget(this.table.get(usable[0].providerId), usable[0].modelId);
|
|
623
|
+
// Last resort: ANY configured model not yet tried, so a swap/continuation never
|
|
624
|
+
// dead-ends just because the router couldn't rank (e.g. providers not yet
|
|
625
|
+
// health-verified at session start). Prefer a different provider.
|
|
626
|
+
return this.firstUntried(tried, current.providerId) ?? this.firstUntried(tried);
|
|
627
|
+
}
|
|
628
|
+
/** First configured provider/model not in `tried` (optionally skipping a provider). */
|
|
629
|
+
firstUntried(tried, exceptProvider) {
|
|
630
|
+
for (const gp of this.table.values()) {
|
|
631
|
+
if (exceptProvider && gp.id === exceptProvider)
|
|
632
|
+
continue;
|
|
633
|
+
for (const m of gp.models)
|
|
634
|
+
if (!tried.has(`${gp.id}/${m.id}`))
|
|
635
|
+
return this.toTarget(gp, m.id);
|
|
636
|
+
}
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
toTarget(gp, model) {
|
|
640
|
+
return { providerId: gp.id, baseURL: gp.baseURL, apiKey: gp.apiKey, headers: gp.headers, model };
|
|
641
|
+
}
|
|
642
|
+
async rank(estTokens) {
|
|
643
|
+
try {
|
|
644
|
+
return this.opts.kernel.router.rank(await this.routingRequest(estTokens));
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
return [];
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
async routingRequest(estTokens) {
|
|
651
|
+
const kernel = this.opts.kernel;
|
|
652
|
+
const now = Date.now();
|
|
653
|
+
if (!this.modelsCache || now - this.modelsCache.at > MODELS_TTL_MS) {
|
|
654
|
+
this.modelsCache = { at: now, models: await kernel.providerManager.getAvailableModels() };
|
|
655
|
+
}
|
|
656
|
+
const policy = kernel.policies.get(this.opts.policyName);
|
|
657
|
+
return {
|
|
658
|
+
capabilities: this.opts.capabilities ?? ['coding', 'reasoning'],
|
|
659
|
+
preferences: { reasoning: 88, speed: 70, cost: 'medium', contextLength: Math.max(16_000, estTokens * 2) },
|
|
660
|
+
policy,
|
|
661
|
+
availableProviders: kernel.providerManager.listInfos(),
|
|
662
|
+
availableModels: this.modelsCache.models,
|
|
663
|
+
estimatedRequestTokens: estTokens,
|
|
664
|
+
minBenchmark: this.opts.minBenchmark,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
// ── Telemetry / health ───────────────────────────────────────────────────────
|
|
668
|
+
recordError(providerId, status, error) {
|
|
669
|
+
const kind = this.opts.kernel.recovery.classify(`${status} ${error}`);
|
|
670
|
+
if (status === 429 || kind === '429')
|
|
671
|
+
this.opts.kernel.providerManager.record429(providerId);
|
|
672
|
+
else
|
|
673
|
+
this.opts.kernel.providerManager.recordFailure(providerId);
|
|
674
|
+
// Cooldown credits/auth/outage providers so the NEXT turns skip them too
|
|
675
|
+
// (not just this request's retry loop). tool/context failures cool down 0ms.
|
|
676
|
+
this.opts.kernel.providerManager.penalize(providerId, kind, `${kind}: ${error.slice(0, 120)}`);
|
|
677
|
+
this.modelsCache = undefined; // reflect the cooldown in the next routing pass
|
|
678
|
+
this.opts.kernel.eventBus.emit({
|
|
679
|
+
type: 'RECOVERY',
|
|
680
|
+
timestamp: Date.now(),
|
|
681
|
+
data: { source: 'gateway', providerId, status, kind, error: error.slice(0, 300) },
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
emitSwitch(from, to, reason) {
|
|
685
|
+
const bus = this.opts.kernel.eventBus;
|
|
686
|
+
if (from.providerId !== to.providerId) {
|
|
687
|
+
bus.emit({
|
|
688
|
+
type: 'PROVIDER_CHANGED',
|
|
689
|
+
timestamp: Date.now(),
|
|
690
|
+
data: { source: 'gateway', previousProviderId: from.providerId, providerId: to.providerId, reason: reason.slice(0, 200) },
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
bus.emit({
|
|
694
|
+
type: 'MODEL_CHANGED',
|
|
695
|
+
timestamp: Date.now(),
|
|
696
|
+
data: {
|
|
697
|
+
source: 'gateway',
|
|
698
|
+
previousModelId: `${from.providerId}/${from.model}`,
|
|
699
|
+
modelId: `${to.providerId}/${to.model}`,
|
|
700
|
+
providerId: to.providerId,
|
|
701
|
+
reason: reason.slice(0, 200),
|
|
702
|
+
},
|
|
703
|
+
});
|
|
704
|
+
this.opts.logger.info(`[gateway] ${from.providerId}/${from.model} → ${to.providerId}/${to.model}`);
|
|
705
|
+
}
|
|
706
|
+
// ── IO ────────────────────────────────────────────────────────────────────────
|
|
707
|
+
sendJson(res, status, body) {
|
|
708
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
709
|
+
res.end(JSON.stringify(body));
|
|
710
|
+
}
|
|
711
|
+
readBody(req) {
|
|
712
|
+
return new Promise((resolve, reject) => {
|
|
713
|
+
let data = '';
|
|
714
|
+
req.on('data', chunk => {
|
|
715
|
+
data += chunk;
|
|
716
|
+
if (data.length > 32_000_000)
|
|
717
|
+
reject(new Error('body too large'));
|
|
718
|
+
});
|
|
719
|
+
req.on('end', () => resolve(data));
|
|
720
|
+
req.on('error', reject);
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
}
|