infinicode 2.3.3 → 2.3.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/.opencode/plugins/mesh-commands.tsx +139 -0
- package/dist/cli.js +1 -1
- package/dist/commands/run.js +212 -20
- package/dist/kernel/federation/federation.js +1 -0
- package/dist/kernel/federation/transport-http.d.ts +3 -0
- package/dist/kernel/federation/transport-http.js +25 -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 +85 -0
- package/dist/kernel/gateway/openai-gateway.js +344 -0
- package/dist/kernel/index.d.ts +1 -0
- package/dist/kernel/index.js +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
/**
|
|
3
|
+
* infinicode — mesh command palette
|
|
4
|
+
*
|
|
5
|
+
* Adds the OpenKernel mesh/kernel slash-commands natively to the TUI's `/`
|
|
6
|
+
* command palette. Each command is dispatched to a LOCAL mesh node over HTTP
|
|
7
|
+
* (POST /fed/command → kernel.command()), so the same command surface exposed by
|
|
8
|
+
* `infinicode serve`'s console and the CLI is available right inside the TUI.
|
|
9
|
+
*
|
|
10
|
+
* The local node is started (or reused) by `infinicode run`, which injects:
|
|
11
|
+
* INFINICODE_MESH_URL e.g. http://127.0.0.1:47913
|
|
12
|
+
* INFINICODE_MESH_TOKEN optional bearer token
|
|
13
|
+
*
|
|
14
|
+
* Argless commands (/nodes, /running, /activity, /whoami, /mesh-help) run
|
|
15
|
+
* immediately and show output in an alert. Arg-taking commands (/node, /build,
|
|
16
|
+
* /follow, /dispatch, /goal, /mission, /role) open a prompt first.
|
|
17
|
+
*/
|
|
18
|
+
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
|
19
|
+
import { createBindingLookup } from "@opentui/keymap/extras"
|
|
20
|
+
|
|
21
|
+
type CommandResult = { ok?: boolean; text?: string; data?: unknown }
|
|
22
|
+
|
|
23
|
+
const meshUrl = () => (process.env.INFINICODE_MESH_URL ?? "http://127.0.0.1:47913").replace(/\/+$/, "")
|
|
24
|
+
const meshToken = () => process.env.INFINICODE_MESH_TOKEN
|
|
25
|
+
|
|
26
|
+
/** POST a slash-command to the local mesh node and return its CommandResult. */
|
|
27
|
+
async function send(input: string): Promise<CommandResult> {
|
|
28
|
+
const token = meshToken()
|
|
29
|
+
try {
|
|
30
|
+
const res = await fetch(`${meshUrl()}/fed/command`, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: {
|
|
33
|
+
"content-type": "application/json",
|
|
34
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
35
|
+
},
|
|
36
|
+
body: JSON.stringify({ input }),
|
|
37
|
+
signal: AbortSignal.timeout(30_000),
|
|
38
|
+
})
|
|
39
|
+
if (!res.ok) return { ok: false, text: `mesh error ${res.status} — is a node running? (infinicode serve)` }
|
|
40
|
+
return (await res.json()) as CommandResult
|
|
41
|
+
} catch (e) {
|
|
42
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
43
|
+
return { ok: false, text: `no local mesh node reachable at ${meshUrl()} (${msg})` }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Render a CommandResult in a scrollable alert dialog. */
|
|
48
|
+
function show(api: TuiPluginApi, title: string, res: CommandResult): void {
|
|
49
|
+
const DialogAlert = api.ui.DialogAlert
|
|
50
|
+
const message = (res.text && res.text.trim()) || (res.ok ? "(no output)" : "command failed")
|
|
51
|
+
api.ui.dialog.setSize("large")
|
|
52
|
+
api.ui.dialog.replace(() => (
|
|
53
|
+
<DialogAlert title={title} message={message} onConfirm={() => api.ui.dialog.clear()} />
|
|
54
|
+
))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Run a fixed slash-command now and show the result. */
|
|
58
|
+
function runNow(api: TuiPluginApi, title: string, input: string): void {
|
|
59
|
+
api.ui.toast({ variant: "info", title: "mesh", message: `${input} …`, duration: 1200 })
|
|
60
|
+
void send(input).then((res) => show(api, title, res))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Open a prompt, then dispatch `<slash> <entered args>`. */
|
|
64
|
+
function runWithArgs(
|
|
65
|
+
api: TuiPluginApi,
|
|
66
|
+
title: string,
|
|
67
|
+
slash: string,
|
|
68
|
+
placeholder: string,
|
|
69
|
+
): void {
|
|
70
|
+
const DialogPrompt = api.ui.DialogPrompt
|
|
71
|
+
api.ui.dialog.setSize("medium")
|
|
72
|
+
api.ui.dialog.replace(() => (
|
|
73
|
+
<DialogPrompt
|
|
74
|
+
title={title}
|
|
75
|
+
value=""
|
|
76
|
+
placeholder={placeholder}
|
|
77
|
+
onConfirm={(args: string) => {
|
|
78
|
+
api.ui.dialog.clear()
|
|
79
|
+
const input = args && args.trim() ? `${slash} ${args.trim()}` : slash
|
|
80
|
+
api.ui.toast({ variant: "info", title: "mesh", message: `${input} …`, duration: 1200 })
|
|
81
|
+
void send(input).then((res) => show(api, title, res))
|
|
82
|
+
}}
|
|
83
|
+
onCancel={() => api.ui.dialog.clear()}
|
|
84
|
+
/>
|
|
85
|
+
))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface Spec {
|
|
89
|
+
name: string
|
|
90
|
+
slashName: string
|
|
91
|
+
title: string
|
|
92
|
+
/** if set, prompt for args first with this placeholder; else run immediately */
|
|
93
|
+
arg?: string
|
|
94
|
+
/** the underlying kernel slash-command (defaults to `/${slashName}`) */
|
|
95
|
+
cmd?: string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const SPECS: Spec[] = [
|
|
99
|
+
{ name: "mesh_nodes", slashName: "nodes", title: "Mesh nodes" },
|
|
100
|
+
{ name: "mesh_running", slashName: "running", title: "Running tasks" },
|
|
101
|
+
{ name: "mesh_activity", slashName: "activity", title: "Mesh activity" },
|
|
102
|
+
{ name: "mesh_whoami", slashName: "whoami", title: "This node" },
|
|
103
|
+
{ name: "mesh_help", slashName: "mesh-help", title: "Mesh commands", cmd: "/help" },
|
|
104
|
+
{ name: "mesh_node", slashName: "node", title: "Node control", arg: "<node> <message>", cmd: "/node" },
|
|
105
|
+
{ name: "mesh_build", slashName: "build", title: "Phased build", arg: "<node> <goal>", cmd: "/build" },
|
|
106
|
+
{ name: "mesh_dispatch", slashName: "dispatch", title: "Dispatch task", arg: "<node> <task>", cmd: "/dispatch" },
|
|
107
|
+
{ name: "mesh_follow", slashName: "follow", title: "Follow run", arg: "<run-id>", cmd: "/follow" },
|
|
108
|
+
{ name: "mesh_goal", slashName: "goal", title: "New goal", arg: "<goal description>", cmd: "/goal" },
|
|
109
|
+
{ name: "mesh_mission", slashName: "mission", title: "Mission", arg: "<mission description>", cmd: "/mission" },
|
|
110
|
+
{ name: "mesh_role", slashName: "role", title: "Set role", arg: "<role>", cmd: "/role" },
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
const tui: TuiPlugin = async (api, options) => {
|
|
114
|
+
if (options?.enabled === false) return
|
|
115
|
+
const keys = createBindingLookup({})
|
|
116
|
+
|
|
117
|
+
api.keymap.registerLayer({
|
|
118
|
+
commands: SPECS.map((s) => ({
|
|
119
|
+
name: s.name,
|
|
120
|
+
title: s.title,
|
|
121
|
+
category: "Mesh",
|
|
122
|
+
namespace: "palette" as const,
|
|
123
|
+
slashName: s.slashName,
|
|
124
|
+
run() {
|
|
125
|
+
const cmd = s.cmd ?? `/${s.slashName}`
|
|
126
|
+
if (s.arg) runWithArgs(api, s.title, cmd, s.arg)
|
|
127
|
+
else runNow(api, s.title, cmd)
|
|
128
|
+
},
|
|
129
|
+
})),
|
|
130
|
+
bindings: keys.gather("mesh.global", SPECS.map((s) => s.name)),
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const plugin: TuiPluginModule & { id: string } = {
|
|
135
|
+
id: "infinicode-mesh-commands",
|
|
136
|
+
tui,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export default plugin
|
package/dist/cli.js
CHANGED
|
@@ -16,7 +16,7 @@ import { serve } from './commands/serve.js';
|
|
|
16
16
|
import { mcpCommand } from './commands/mcp.js';
|
|
17
17
|
import { installTui } from './commands/install-tui.js';
|
|
18
18
|
import { DEFAULT_CONFIG } from './kernel/config-schema.js';
|
|
19
|
-
const VERSION = '2.3.
|
|
19
|
+
const VERSION = '2.3.5';
|
|
20
20
|
const config = new Conf({
|
|
21
21
|
projectName: 'infinicode',
|
|
22
22
|
defaults: DEFAULT_CONFIG,
|
package/dist/commands/run.js
CHANGED
|
@@ -16,14 +16,14 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import chalk from 'chalk';
|
|
18
18
|
import ora from 'ora';
|
|
19
|
-
import { existsSync } from 'node:fs';
|
|
19
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
20
20
|
import { dirname, join, resolve } from 'node:path';
|
|
21
|
-
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
22
22
|
import { createRequire } from 'node:module';
|
|
23
23
|
import { ASCII_VIDEO_FPS, ASCII_VIDEO_FRAMES } from '../ascii-video-animation.js';
|
|
24
24
|
import { getProviderPreset } from '../kernel/free-providers.js';
|
|
25
25
|
import { buildKernelFromConfig } from '../kernel/setup.js';
|
|
26
|
-
import { SilentLogger } from '../kernel/index.js';
|
|
26
|
+
import { SilentLogger, Federation, loadOrCreateIdentity, KernelGateway } from '../kernel/index.js';
|
|
27
27
|
import { openAICompatBaseURL } from '../kernel/provider-url.js';
|
|
28
28
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
29
29
|
const PROMPT_ANIMATION_MAX_FRAMES = Math.min(ASCII_VIDEO_FRAMES.length, ASCII_VIDEO_FPS * 3);
|
|
@@ -257,8 +257,11 @@ async function pickDefaultModel(config, masterReachable) {
|
|
|
257
257
|
* in parallel (so all models are exposed, incl. large ones like GLM-5.2 on HF
|
|
258
258
|
* and Nemotron-Ultra on NVIDIA), then route the default model under the policy.
|
|
259
259
|
*/
|
|
260
|
-
async function prepareRouting(config, policyName) {
|
|
261
|
-
|
|
260
|
+
async function prepareRouting(config, policyName, sharedKernel) {
|
|
261
|
+
// Reuse the caller's session kernel when provided (so the gateway routes
|
|
262
|
+
// against the same warmed-up provider pool) — only destroy a kernel we own.
|
|
263
|
+
const kernel = sharedKernel ?? buildKernelFromConfig(config, { logger: new SilentLogger() });
|
|
264
|
+
const ownsKernel = !sharedKernel;
|
|
262
265
|
const modelsByProvider = {};
|
|
263
266
|
try {
|
|
264
267
|
// Let background token-verified health checks run first.
|
|
@@ -297,7 +300,8 @@ async function prepareRouting(config, policyName) {
|
|
|
297
300
|
return { routed: null, modelsByProvider };
|
|
298
301
|
}
|
|
299
302
|
finally {
|
|
300
|
-
|
|
303
|
+
if (ownsKernel)
|
|
304
|
+
kernel.destroy();
|
|
301
305
|
}
|
|
302
306
|
}
|
|
303
307
|
/** Ensure the routed model exists in the TUI's injected provider config. */
|
|
@@ -309,6 +313,135 @@ function ensureModelInjected(providers, providerId, modelId) {
|
|
|
309
313
|
if (!p.models[modelId])
|
|
310
314
|
p.models[modelId] = { name: modelId };
|
|
311
315
|
}
|
|
316
|
+
function runPkgVersion() {
|
|
317
|
+
try {
|
|
318
|
+
return JSON.parse(readFileSync(join(resolve(__dirname, '..', '..'), 'package.json'), 'utf8')).version ?? '0.0.0';
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return '0.0.0';
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/** Is a mesh node already listening at this URL? (e.g. a separate `infinicode serve`). */
|
|
325
|
+
async function meshAlive(url, token) {
|
|
326
|
+
try {
|
|
327
|
+
const res = await fetch(`${url}/fed/manifest`, {
|
|
328
|
+
headers: token ? { authorization: `Bearer ${token}` } : undefined,
|
|
329
|
+
signal: AbortSignal.timeout(1500),
|
|
330
|
+
});
|
|
331
|
+
return res.ok;
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Make the TUI a mesh participant so the in-TUI slash commands (/nodes, /build,
|
|
339
|
+
* /activity …) have a local node to talk to. If a node is already running on the
|
|
340
|
+
* configured port (a separate `infinicode serve`), point the TUI at THAT one;
|
|
341
|
+
* otherwise start an in-process node for the session. Best-effort: on any
|
|
342
|
+
* failure the TUI still launches, just without the live mesh palette.
|
|
343
|
+
*/
|
|
344
|
+
async function startLocalMeshNode(config, kernel) {
|
|
345
|
+
if (process.env.INFINICODE_NO_MESH)
|
|
346
|
+
return null;
|
|
347
|
+
const fed = config.get('federation') ?? {};
|
|
348
|
+
const port = fed.port ?? 47913;
|
|
349
|
+
const token = fed.token;
|
|
350
|
+
const url = `http://127.0.0.1:${port}`;
|
|
351
|
+
try {
|
|
352
|
+
// Reuse an already-running node (don't double-bind the port).
|
|
353
|
+
if (await meshAlive(url, token))
|
|
354
|
+
return { url, token, stop: async () => { } };
|
|
355
|
+
const role = (fed.role ?? 'peer');
|
|
356
|
+
const identity = loadOrCreateIdentity({ role, displayName: fed.displayName });
|
|
357
|
+
const version = runPkgVersion();
|
|
358
|
+
const describe = () => {
|
|
359
|
+
const defs = kernel.workerRuntime.listRegisteredDefinitions();
|
|
360
|
+
return {
|
|
361
|
+
version,
|
|
362
|
+
capabilities: [...new Set(defs.flatMap(d => d.capabilities))],
|
|
363
|
+
workers: defs.map(d => d.type),
|
|
364
|
+
commands: kernel.commands.list().map(c => c.name),
|
|
365
|
+
providers: kernel.providerManager.listInfos().map(p => p.id),
|
|
366
|
+
};
|
|
367
|
+
};
|
|
368
|
+
const federation = new Federation({
|
|
369
|
+
kernel, identity, describe, logger: new SilentLogger(),
|
|
370
|
+
options: {
|
|
371
|
+
version, port, host: fed.host, token, seeds: fed.seeds,
|
|
372
|
+
autoPullConfig: role === 'satellite',
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
kernel.attachFederation(federation);
|
|
376
|
+
await federation.start();
|
|
377
|
+
if (fed.lan)
|
|
378
|
+
federation.startLanDiscovery(undefined, fed.lanPort);
|
|
379
|
+
for (const s of fed.seeds ?? [])
|
|
380
|
+
await federation.connect(s).catch(() => undefined);
|
|
381
|
+
// The kernel is owned by runAgent — the mesh only stops the federation.
|
|
382
|
+
return {
|
|
383
|
+
url, token,
|
|
384
|
+
stop: async () => { await federation.stop(); },
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/** Build the gateway's provider table (baseURL/apiKey/models) from config. */
|
|
392
|
+
function buildGatewayProviders(config, masterUrl, masterReachable, defaultModel, modelsByProvider) {
|
|
393
|
+
const out = [];
|
|
394
|
+
const catalog = (id, fallback) => {
|
|
395
|
+
const fetched = modelsByProvider[id];
|
|
396
|
+
return fetched && fetched.length ? fetched : fallback;
|
|
397
|
+
};
|
|
398
|
+
const shape = (models) => models.map(m => ({ id: m.id, name: m.name, contextLength: m.contextLength }));
|
|
399
|
+
if (masterReachable) {
|
|
400
|
+
out.push({
|
|
401
|
+
id: 'ollama',
|
|
402
|
+
baseURL: `${masterUrl}/v1`,
|
|
403
|
+
models: shape(catalog('ollama', [{ id: defaultModel, name: defaultModel, contextLength: 8192 }])),
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
for (const cfg of config.get('cloudProviders') ?? []) {
|
|
407
|
+
if (!cfg.enabled || !cfg.apiKey)
|
|
408
|
+
continue;
|
|
409
|
+
const preset = getProviderPreset(cfg.id);
|
|
410
|
+
const fallback = (preset?.knownModels ?? []).map(m => ({ id: m.id, name: m.name, contextLength: m.contextLength }));
|
|
411
|
+
const models = shape(catalog(cfg.id, fallback));
|
|
412
|
+
if (cfg.id === 'gemini') {
|
|
413
|
+
// Google's OpenAI-compatibility endpoint keeps the relay uniform.
|
|
414
|
+
out.push({ id: 'gemini', baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai', apiKey: cfg.apiKey, models });
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
const headers = cfg.headerName && cfg.headerName !== 'Authorization' ? { [cfg.headerName]: cfg.apiKey } : undefined;
|
|
418
|
+
out.push({ id: cfg.id, baseURL: openAICompatBaseURL(cfg.baseURL), apiKey: cfg.apiKey, headers, models });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return out;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Build the TUI's provider config as a SINGLE `infinicode` provider pointing at
|
|
425
|
+
* the local kernel gateway. Real API keys stay server-side (the TUI only gets a
|
|
426
|
+
* dummy token); every model in the pool is exposed as `providerId/modelId`, plus
|
|
427
|
+
* an `auto` entry that lets the kernel router pick per request.
|
|
428
|
+
*/
|
|
429
|
+
function buildGatewayTuiConfig(gatewayUrl, providers) {
|
|
430
|
+
const models = { auto: { name: 'AUTO — kernel routes best model' } };
|
|
431
|
+
for (const p of providers) {
|
|
432
|
+
for (const m of p.models) {
|
|
433
|
+
models[`${p.id}/${m.id}`] = modelEntry({ id: `${p.id}/${m.id}`, name: m.name ?? m.id, contextLength: m.contextLength });
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
infinicode: {
|
|
438
|
+
npm: '@ai-sdk/openai-compatible',
|
|
439
|
+
name: 'infinicode (kernel)',
|
|
440
|
+
options: { baseURL: gatewayUrl, apiKey: 'infinicode-gateway' },
|
|
441
|
+
models,
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
}
|
|
312
445
|
export async function runAgent(_masterUrl, _model, _useTui, config) {
|
|
313
446
|
const masterUrl = await resolveConfiguredMasterUrl(config);
|
|
314
447
|
const masterReachable = await isOllamaReachable(masterUrl);
|
|
@@ -316,36 +449,85 @@ export async function runAgent(_masterUrl, _model, _useTui, config) {
|
|
|
316
449
|
const cloudProviders = config.get('cloudProviders') ?? [];
|
|
317
450
|
const enabledCloud = cloudProviders.filter(p => p.enabled);
|
|
318
451
|
const policyName = config.get('policy') ?? 'balanced';
|
|
452
|
+
// Session kernel — lives for the whole TUI session. It is the routing/recovery
|
|
453
|
+
// brain the gateway and the mesh node both share, so every chat turn the TUI
|
|
454
|
+
// makes flows through the same warmed-up provider pool + event bus.
|
|
455
|
+
const sessionKernel = buildKernelFromConfig(config, { logger: new SilentLogger() });
|
|
456
|
+
// Make this TUI session a mesh participant so the in-TUI slash commands
|
|
457
|
+
// (/nodes, /build, /activity, …) have a local node to talk to. Reuses a
|
|
458
|
+
// running `infinicode serve` if one is up, else starts an in-process node.
|
|
459
|
+
const mesh = await startLocalMeshNode(config, sessionKernel);
|
|
460
|
+
if (mesh) {
|
|
461
|
+
process.env.INFINICODE_MESH_URL = mesh.url;
|
|
462
|
+
if (mesh.token)
|
|
463
|
+
process.env.INFINICODE_MESH_TOKEN = mesh.token;
|
|
464
|
+
}
|
|
319
465
|
// One kernel pass: load every provider's full model catalog (in parallel) and
|
|
320
466
|
// route the default model under the policy.
|
|
321
467
|
const routeSpinner = ora('loading model catalog & routing...').start();
|
|
322
|
-
const { routed, modelsByProvider } = await prepareRouting(config, policyName);
|
|
468
|
+
const { routed, modelsByProvider } = await prepareRouting(config, policyName, sessionKernel);
|
|
323
469
|
routeSpinner.stop();
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
470
|
+
// Stand up the OpenAI-compatible kernel gateway. The TUI points at THIS as its
|
|
471
|
+
// single provider, so interactive chat gains full kernel parity: auto-routing,
|
|
472
|
+
// escalate-on-error, provider rotation, health/quota, live telemetry.
|
|
473
|
+
const gwProviders = buildGatewayProviders(config, masterUrl, masterReachable, defaultModel, modelsByProvider);
|
|
474
|
+
let gateway;
|
|
475
|
+
if (!process.env.INFINICODE_NO_GATEWAY && gwProviders.length > 0) {
|
|
476
|
+
try {
|
|
477
|
+
gateway = new KernelGateway({ kernel: sessionKernel, providers: gwProviders, policyName, logger: new SilentLogger() });
|
|
478
|
+
await gateway.start();
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
gateway = undefined; // fall back to direct-provider config below
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
let providers;
|
|
485
|
+
let modelRef;
|
|
486
|
+
if (gateway) {
|
|
487
|
+
providers = buildGatewayTuiConfig(gateway.url, gwProviders);
|
|
488
|
+
modelRef = 'infinicode/auto'; // default to kernel auto-routing
|
|
331
489
|
}
|
|
332
490
|
else {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
491
|
+
// Fallback: talk to providers directly (no kernel in the loop).
|
|
492
|
+
providers = buildProviderConfig(masterUrl, defaultModel, cloudProviders, masterReachable, modelsByProvider);
|
|
493
|
+
let defaultProvider;
|
|
494
|
+
let pickedModel;
|
|
495
|
+
if (routed && providers[routed.providerId]) {
|
|
496
|
+
defaultProvider = routed.providerId;
|
|
497
|
+
pickedModel = routed.modelId;
|
|
498
|
+
ensureModelInjected(providers, routed.providerId, routed.modelId);
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
const fb = await pickDefaultModel(config, masterReachable);
|
|
502
|
+
defaultProvider = fb.provider;
|
|
503
|
+
pickedModel = fb.model;
|
|
504
|
+
}
|
|
505
|
+
modelRef = `${defaultProvider}/${pickedModel}`;
|
|
336
506
|
}
|
|
337
|
-
const
|
|
338
|
-
const providerCount = Object.keys(providers).length;
|
|
507
|
+
const providerCount = gateway ? gwProviders.length : Object.keys(providers).length;
|
|
339
508
|
const modelCount = Object.values(providers).reduce((n, p) => n + Object.keys(p.models ?? {}).length, 0);
|
|
340
509
|
const policy = config.get('policy') ?? 'local-first';
|
|
341
510
|
const pinnedCount = Object.keys(config.get('workerModels') ?? {}).length;
|
|
342
|
-
const routingInfo =
|
|
511
|
+
const routingInfo = gateway
|
|
512
|
+
? `KERNEL ${policy} ${providerCount}P ${pinnedCount}pins`
|
|
513
|
+
: `AUTO ${policy} ${providerCount}P ${pinnedCount}pins`;
|
|
514
|
+
// Runtime TUI plugin that adds the mesh slash-commands (/nodes, /build, …)
|
|
515
|
+
// to the `/` palette. Shipped in the package at .opencode/plugins; loaded via
|
|
516
|
+
// a file:// URL so it resolves regardless of the TUI's cwd. Only injected when
|
|
517
|
+
// a local mesh node is available (else the commands would have nothing to hit).
|
|
518
|
+
const pluginPaths = [];
|
|
519
|
+
if (mesh) {
|
|
520
|
+
const pluginFile = join(resolve(__dirname, '..', '..'), '.opencode', 'plugins', 'mesh-commands.tsx');
|
|
521
|
+
if (existsSync(pluginFile))
|
|
522
|
+
pluginPaths.push(pathToFileURL(pluginFile).href);
|
|
523
|
+
}
|
|
343
524
|
process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
|
|
344
525
|
$schema: 'https://opencode.ai/config.json',
|
|
345
526
|
provider: providers,
|
|
346
527
|
model: modelRef,
|
|
347
528
|
small_model: modelRef,
|
|
348
529
|
enabled_providers: Object.keys(providers),
|
|
530
|
+
...(pluginPaths.length > 0 ? { plugin: pluginPaths } : {}),
|
|
349
531
|
agent: {
|
|
350
532
|
build: {
|
|
351
533
|
model: modelRef,
|
|
@@ -360,6 +542,9 @@ export async function runAgent(_masterUrl, _model, _useTui, config) {
|
|
|
360
542
|
},
|
|
361
543
|
});
|
|
362
544
|
console.log(chalk.dim(`\ninfinicode — launching TUI with ${chalk.cyan(String(providerCount))} provider(s), ${chalk.cyan(String(modelCount))} model(s)`));
|
|
545
|
+
if (gateway) {
|
|
546
|
+
console.log(chalk.dim(` kernel gateway: ${chalk.green(gateway.url)} ${chalk.dim('(auto-routing + escalate-on-error)')}`));
|
|
547
|
+
}
|
|
363
548
|
console.log(chalk.dim(` default model: ${chalk.cyan(modelRef)}`));
|
|
364
549
|
console.log(chalk.dim(` policy: ${chalk.cyan(config.get('policy') ?? 'local-first')}`));
|
|
365
550
|
if (enabledCloud.length > 0) {
|
|
@@ -384,6 +569,11 @@ export async function runAgent(_masterUrl, _model, _useTui, config) {
|
|
|
384
569
|
console.log(chalk.cyan(' infinicode install-tui --url <url>') + chalk.dim(' # or download from a hosted binary'));
|
|
385
570
|
process.exit(1);
|
|
386
571
|
}
|
|
572
|
+
const cleanup = async () => {
|
|
573
|
+
await gateway?.stop().catch(() => undefined);
|
|
574
|
+
await mesh?.stop().catch(() => undefined);
|
|
575
|
+
sessionKernel.destroy();
|
|
576
|
+
};
|
|
387
577
|
const { execa } = await import('execa');
|
|
388
578
|
try {
|
|
389
579
|
await execa(tuiBinary, [], {
|
|
@@ -392,10 +582,12 @@ export async function runAgent(_masterUrl, _model, _useTui, config) {
|
|
|
392
582
|
});
|
|
393
583
|
}
|
|
394
584
|
catch (e) {
|
|
585
|
+
await cleanup();
|
|
395
586
|
const err = e;
|
|
396
587
|
if (err.exitCode) {
|
|
397
588
|
process.exit(err.exitCode);
|
|
398
589
|
}
|
|
399
590
|
throw e;
|
|
400
591
|
}
|
|
592
|
+
await cleanup();
|
|
401
593
|
}
|
|
@@ -61,6 +61,7 @@ export class Federation {
|
|
|
61
61
|
logger,
|
|
62
62
|
getManifest: () => buildManifest(identity, this.deps.describe()),
|
|
63
63
|
getNodes: () => this.nodeStatuses(),
|
|
64
|
+
runCommand: (input) => this.deps.kernel.command(input),
|
|
64
65
|
onRpc: (env, remote) => this.onRpc(env, remote),
|
|
65
66
|
});
|
|
66
67
|
await this.server.start();
|
|
@@ -9,6 +9,9 @@ export interface MeshServerOptions {
|
|
|
9
9
|
getManifest: () => NodeManifest;
|
|
10
10
|
/** Optional: full mesh view (self + peers) for the GET /fed/nodes probe. */
|
|
11
11
|
getNodes?: () => unknown;
|
|
12
|
+
/** Optional: run a kernel slash-command on this node (POST /fed/command).
|
|
13
|
+
* Powers the in-TUI mesh command palette. */
|
|
14
|
+
runCommand?: (input: string) => Promise<unknown>;
|
|
12
15
|
onRpc: RpcHandler;
|
|
13
16
|
logger: Logger;
|
|
14
17
|
}
|
|
@@ -78,6 +78,31 @@ export class MeshServer {
|
|
|
78
78
|
this.openStream(req, res);
|
|
79
79
|
return;
|
|
80
80
|
}
|
|
81
|
+
if (req.method === 'POST' && url.startsWith('/fed/command')) {
|
|
82
|
+
if (!this.opts.runCommand) {
|
|
83
|
+
res.writeHead(404).end('no command handler');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
this.readBody(req)
|
|
87
|
+
.then(async (body) => {
|
|
88
|
+
let input = '';
|
|
89
|
+
try {
|
|
90
|
+
input = JSON.parse(body).input ?? '';
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
res.writeHead(400).end('bad json');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const result = await this.opts.runCommand(input);
|
|
97
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
98
|
+
res.end(JSON.stringify(result ?? { ok: false, text: 'no result' }));
|
|
99
|
+
})
|
|
100
|
+
.catch(err => {
|
|
101
|
+
this.opts.logger.warn(`[federation] command error: ${err instanceof Error ? err.message : String(err)}`);
|
|
102
|
+
res.writeHead(500).end('command failed');
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
81
106
|
if (req.method === 'POST' && url.startsWith('/fed/rpc')) {
|
|
82
107
|
this.readBody(req)
|
|
83
108
|
.then(async (body) => {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenKernel — Gateway module.
|
|
3
|
+
*
|
|
4
|
+
* A local OpenAI-compatible endpoint backed by the kernel's routing + recovery,
|
|
5
|
+
* so the interactive TUI path gains 1:1 parity with the headless mission path.
|
|
6
|
+
*/
|
|
7
|
+
export { KernelGateway, FetchExecutor, type KernelGatewayOptions, type GatewayProvider, type GatewayTarget, type ProviderExecutor, } from './openai-gateway.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenKernel — Gateway module.
|
|
3
|
+
*
|
|
4
|
+
* A local OpenAI-compatible endpoint backed by the kernel's routing + recovery,
|
|
5
|
+
* so the interactive TUI path gains 1:1 parity with the headless mission path.
|
|
6
|
+
*/
|
|
7
|
+
export { KernelGateway, FetchExecutor, } from './openai-gateway.js';
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Kernel } from '../kernel.js';
|
|
2
|
+
import type { Logger, Capability } from '../types.js';
|
|
3
|
+
/** A provider the gateway can execute against (resolved from config). */
|
|
4
|
+
export interface GatewayProvider {
|
|
5
|
+
id: string;
|
|
6
|
+
/** OpenAI-compatible base URL ending in the version segment (…/v1). */
|
|
7
|
+
baseURL: string;
|
|
8
|
+
apiKey?: string;
|
|
9
|
+
/** Extra headers (e.g. a custom auth header name for providers that need it). */
|
|
10
|
+
headers?: Record<string, string>;
|
|
11
|
+
models: Array<{
|
|
12
|
+
id: string;
|
|
13
|
+
name?: string;
|
|
14
|
+
contextLength?: number;
|
|
15
|
+
}>;
|
|
16
|
+
}
|
|
17
|
+
/** A fully-resolved call target (provider + chosen model). */
|
|
18
|
+
export interface GatewayTarget {
|
|
19
|
+
providerId: string;
|
|
20
|
+
baseURL: string;
|
|
21
|
+
apiKey?: string;
|
|
22
|
+
headers?: Record<string, string>;
|
|
23
|
+
model: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The execution plane. Swap this to route calls through Bifrost / a remote
|
|
27
|
+
* gateway / a mock without changing any kernel routing/recovery logic.
|
|
28
|
+
*/
|
|
29
|
+
export interface ProviderExecutor {
|
|
30
|
+
execute(target: GatewayTarget, body: Record<string, unknown>, signal: AbortSignal): Promise<Response>;
|
|
31
|
+
}
|
|
32
|
+
/** Default executor: call the upstream OpenAI-compatible endpoint directly. */
|
|
33
|
+
export declare class FetchExecutor implements ProviderExecutor {
|
|
34
|
+
execute(target: GatewayTarget, body: Record<string, unknown>, signal: AbortSignal): Promise<Response>;
|
|
35
|
+
}
|
|
36
|
+
export interface KernelGatewayOptions {
|
|
37
|
+
kernel: Kernel;
|
|
38
|
+
providers: GatewayProvider[];
|
|
39
|
+
/** Policy name to route under (e.g. 'balanced'). */
|
|
40
|
+
policyName: string;
|
|
41
|
+
host?: string;
|
|
42
|
+
/** Preferred port; the gateway scans upward if it is taken. */
|
|
43
|
+
port?: number;
|
|
44
|
+
logger: Logger;
|
|
45
|
+
executor?: ProviderExecutor;
|
|
46
|
+
/** Capabilities to route interactive chat under. */
|
|
47
|
+
capabilities?: Capability[];
|
|
48
|
+
}
|
|
49
|
+
export declare class KernelGateway {
|
|
50
|
+
private opts;
|
|
51
|
+
private server?;
|
|
52
|
+
private table;
|
|
53
|
+
private executor;
|
|
54
|
+
private boundPort;
|
|
55
|
+
private modelsCache?;
|
|
56
|
+
constructor(opts: KernelGatewayOptions);
|
|
57
|
+
get port(): number;
|
|
58
|
+
get url(): string;
|
|
59
|
+
/** Start listening. Scans ports [port .. port+10] to avoid collisions. */
|
|
60
|
+
start(): Promise<void>;
|
|
61
|
+
private listen;
|
|
62
|
+
stop(): Promise<void>;
|
|
63
|
+
private handle;
|
|
64
|
+
private listModels;
|
|
65
|
+
private handleChat;
|
|
66
|
+
/** Stream bytes through untouched, or relay the JSON body for non-streaming. */
|
|
67
|
+
private relay;
|
|
68
|
+
/** Resolve the initial target: honor an explicit provider/model, else route. */
|
|
69
|
+
private resolveTarget;
|
|
70
|
+
/** Ask the kernel router for the best untried, executable model. */
|
|
71
|
+
private routerPick;
|
|
72
|
+
/**
|
|
73
|
+
* Choose the next target after a failure. 429 / provider-down → rotate to a
|
|
74
|
+
* different provider; everything else (incl. Groq's tool_use_failed) →
|
|
75
|
+
* escalate to a stronger model via the kernel ladder, then fall back to rank.
|
|
76
|
+
*/
|
|
77
|
+
private nextTarget;
|
|
78
|
+
private toTarget;
|
|
79
|
+
private rank;
|
|
80
|
+
private routingRequest;
|
|
81
|
+
private recordError;
|
|
82
|
+
private emitSwitch;
|
|
83
|
+
private sendJson;
|
|
84
|
+
private readBody;
|
|
85
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
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
|
+
const MAX_ATTEMPTS = 4;
|
|
40
|
+
const MODELS_TTL_MS = 30_000;
|
|
41
|
+
export class KernelGateway {
|
|
42
|
+
opts;
|
|
43
|
+
server;
|
|
44
|
+
table = new Map();
|
|
45
|
+
executor;
|
|
46
|
+
boundPort = 0;
|
|
47
|
+
modelsCache;
|
|
48
|
+
constructor(opts) {
|
|
49
|
+
this.opts = opts;
|
|
50
|
+
for (const p of opts.providers)
|
|
51
|
+
this.table.set(p.id, p);
|
|
52
|
+
this.executor = opts.executor ?? new FetchExecutor();
|
|
53
|
+
}
|
|
54
|
+
get port() {
|
|
55
|
+
return this.boundPort;
|
|
56
|
+
}
|
|
57
|
+
get url() {
|
|
58
|
+
return `http://${this.opts.host ?? '127.0.0.1'}:${this.boundPort}/v1`;
|
|
59
|
+
}
|
|
60
|
+
/** Start listening. Scans ports [port .. port+10] to avoid collisions. */
|
|
61
|
+
async start() {
|
|
62
|
+
const host = this.opts.host ?? '127.0.0.1';
|
|
63
|
+
const first = this.opts.port ?? 47915;
|
|
64
|
+
let lastErr;
|
|
65
|
+
for (let candidate = first; candidate <= first + 10; candidate++) {
|
|
66
|
+
try {
|
|
67
|
+
await this.listen(host, candidate);
|
|
68
|
+
this.boundPort = candidate;
|
|
69
|
+
this.opts.logger.info(`[gateway] OpenAI-compatible endpoint on ${host}:${candidate} (${this.table.size} providers)`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
lastErr = err;
|
|
74
|
+
if (err.code !== 'EADDRINUSE')
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
throw lastErr ?? new Error('gateway: no free port');
|
|
79
|
+
}
|
|
80
|
+
listen(host, port) {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const server = createServer((req, res) => void this.handle(req, res));
|
|
83
|
+
server.once('error', reject);
|
|
84
|
+
server.listen(port, host, () => {
|
|
85
|
+
server.off('error', reject);
|
|
86
|
+
this.server = server;
|
|
87
|
+
resolve();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
async stop() {
|
|
92
|
+
if (this.server) {
|
|
93
|
+
await new Promise(resolve => this.server.close(() => resolve()));
|
|
94
|
+
this.server = undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// ── HTTP ────────────────────────────────────────────────────────────────────
|
|
98
|
+
async handle(req, res) {
|
|
99
|
+
const url = req.url ?? '/';
|
|
100
|
+
try {
|
|
101
|
+
if (req.method === 'GET' && (url.startsWith('/v1/models') || url === '/v1' || url === '/')) {
|
|
102
|
+
this.sendJson(res, 200, this.listModels());
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (req.method === 'POST' && url.startsWith('/v1/chat/completions')) {
|
|
106
|
+
const body = JSON.parse(await this.readBody(req));
|
|
107
|
+
await this.handleChat(body, res);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
this.sendJson(res, 404, { error: { message: 'not found', type: 'invalid_request_error' } });
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
114
|
+
this.opts.logger.warn(`[gateway] ${message}`);
|
|
115
|
+
if (!res.headersSent)
|
|
116
|
+
this.sendJson(res, 500, { error: { message, type: 'gateway_error' } });
|
|
117
|
+
else
|
|
118
|
+
try {
|
|
119
|
+
res.end();
|
|
120
|
+
}
|
|
121
|
+
catch { /* ignore */ }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
listModels() {
|
|
125
|
+
const data = [
|
|
126
|
+
{ id: 'auto', object: 'model', created: 0, owned_by: 'infinicode' },
|
|
127
|
+
];
|
|
128
|
+
for (const p of this.table.values()) {
|
|
129
|
+
for (const m of p.models)
|
|
130
|
+
data.push({ id: `${p.id}/${m.id}`, object: 'model', created: 0, owned_by: p.id });
|
|
131
|
+
}
|
|
132
|
+
return { object: 'list', data };
|
|
133
|
+
}
|
|
134
|
+
// ── Chat: route → execute → escalate-on-error → relay ────────────────────────
|
|
135
|
+
async handleChat(body, res) {
|
|
136
|
+
const stream = body['stream'] === true;
|
|
137
|
+
const messages = Array.isArray(body['messages']) ? body['messages'] : [];
|
|
138
|
+
const estTokens = Math.ceil(JSON.stringify(messages).length / 4);
|
|
139
|
+
const requested = typeof body['model'] === 'string' ? body['model'] : '';
|
|
140
|
+
const tried = new Set();
|
|
141
|
+
let target = await this.resolveTarget(requested, estTokens, tried);
|
|
142
|
+
if (!target) {
|
|
143
|
+
this.sendJson(res, 503, { error: { message: 'no providers available', type: 'gateway_error' } });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const controller = new AbortController();
|
|
147
|
+
res.on('close', () => controller.abort());
|
|
148
|
+
let lastStatus = 502;
|
|
149
|
+
let lastErr = 'no attempt made';
|
|
150
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS && target; attempt++) {
|
|
151
|
+
tried.add(`${target.providerId}/${target.model}`);
|
|
152
|
+
const upstream = { ...body, model: target.model, stream };
|
|
153
|
+
try {
|
|
154
|
+
const resp = await this.executor.execute(target, upstream, controller.signal);
|
|
155
|
+
if (resp.ok) {
|
|
156
|
+
this.opts.kernel.providerManager.recordSuccess(target.providerId);
|
|
157
|
+
await this.relay(resp, res, stream);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
lastStatus = resp.status;
|
|
161
|
+
lastErr = await resp.text().catch(() => `${resp.status}`);
|
|
162
|
+
this.recordError(target.providerId, resp.status, lastErr);
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
lastStatus = 502;
|
|
166
|
+
lastErr = err instanceof Error ? err.message : String(err);
|
|
167
|
+
if (controller.signal.aborted)
|
|
168
|
+
return; // client hung up
|
|
169
|
+
this.recordError(target.providerId, 0, lastErr);
|
|
170
|
+
}
|
|
171
|
+
const next = await this.nextTarget(target, `${lastStatus} ${lastErr}`, estTokens, tried);
|
|
172
|
+
if (!next)
|
|
173
|
+
break;
|
|
174
|
+
this.emitSwitch(target, next, `${lastStatus} ${lastErr}`);
|
|
175
|
+
target = next;
|
|
176
|
+
}
|
|
177
|
+
if (!res.headersSent) {
|
|
178
|
+
this.sendJson(res, lastStatus >= 400 ? lastStatus : 502, {
|
|
179
|
+
error: { message: `all routes failed: ${lastErr}`, type: 'gateway_error' },
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** Stream bytes through untouched, or relay the JSON body for non-streaming. */
|
|
184
|
+
async relay(resp, res, stream) {
|
|
185
|
+
if (stream && resp.body) {
|
|
186
|
+
res.writeHead(200, {
|
|
187
|
+
'content-type': resp.headers.get('content-type') ?? 'text/event-stream',
|
|
188
|
+
'cache-control': 'no-cache',
|
|
189
|
+
connection: 'keep-alive',
|
|
190
|
+
});
|
|
191
|
+
const reader = resp.body.getReader();
|
|
192
|
+
for (;;) {
|
|
193
|
+
const { done, value } = await reader.read();
|
|
194
|
+
if (done)
|
|
195
|
+
break;
|
|
196
|
+
res.write(Buffer.from(value));
|
|
197
|
+
}
|
|
198
|
+
res.end();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const text = await resp.text();
|
|
202
|
+
res.writeHead(resp.status, { 'content-type': resp.headers.get('content-type') ?? 'application/json' });
|
|
203
|
+
res.end(text);
|
|
204
|
+
}
|
|
205
|
+
// ── Routing helpers ──────────────────────────────────────────────────────────
|
|
206
|
+
/** Resolve the initial target: honor an explicit provider/model, else route. */
|
|
207
|
+
async resolveTarget(requested, estTokens, tried) {
|
|
208
|
+
const raw = requested.trim();
|
|
209
|
+
const isAuto = raw === '' || raw === 'auto' || raw === 'infinicode/auto' || raw.endsWith('/auto');
|
|
210
|
+
if (!isAuto) {
|
|
211
|
+
const slash = raw.indexOf('/');
|
|
212
|
+
if (slash > 0) {
|
|
213
|
+
const providerId = raw.slice(0, slash);
|
|
214
|
+
const modelId = raw.slice(slash + 1);
|
|
215
|
+
const gp = this.table.get(providerId);
|
|
216
|
+
if (gp)
|
|
217
|
+
return this.toTarget(gp, modelId);
|
|
218
|
+
}
|
|
219
|
+
// bare model id — find its owner
|
|
220
|
+
for (const gp of this.table.values()) {
|
|
221
|
+
if (gp.models.some(m => m.id === raw))
|
|
222
|
+
return this.toTarget(gp, raw);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return this.routerPick(estTokens, tried);
|
|
226
|
+
}
|
|
227
|
+
/** Ask the kernel router for the best untried, executable model. */
|
|
228
|
+
async routerPick(estTokens, tried) {
|
|
229
|
+
const ranked = await this.rank(estTokens);
|
|
230
|
+
for (const d of ranked) {
|
|
231
|
+
const gp = this.table.get(d.providerId);
|
|
232
|
+
if (gp && !tried.has(`${d.providerId}/${d.modelId}`))
|
|
233
|
+
return this.toTarget(gp, d.modelId);
|
|
234
|
+
}
|
|
235
|
+
// last resort: first configured model not yet tried
|
|
236
|
+
for (const gp of this.table.values()) {
|
|
237
|
+
for (const m of gp.models) {
|
|
238
|
+
if (!tried.has(`${gp.id}/${m.id}`))
|
|
239
|
+
return this.toTarget(gp, m.id);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Choose the next target after a failure. 429 / provider-down → rotate to a
|
|
246
|
+
* different provider; everything else (incl. Groq's tool_use_failed) →
|
|
247
|
+
* escalate to a stronger model via the kernel ladder, then fall back to rank.
|
|
248
|
+
*/
|
|
249
|
+
async nextTarget(current, errText, estTokens, tried) {
|
|
250
|
+
const kind = this.opts.kernel.recovery.classify(errText);
|
|
251
|
+
const ranked = await this.rank(estTokens);
|
|
252
|
+
const usable = ranked.filter(d => this.table.has(d.providerId) && !tried.has(`${d.providerId}/${d.modelId}`));
|
|
253
|
+
if (kind === '429' || kind === 'provider-down') {
|
|
254
|
+
const diffProvider = usable.find(d => d.providerId !== current.providerId);
|
|
255
|
+
if (diffProvider)
|
|
256
|
+
return this.toTarget(this.table.get(diffProvider.providerId), diffProvider.modelId);
|
|
257
|
+
return usable[0] ? this.toTarget(this.table.get(usable[0].providerId), usable[0].modelId) : null;
|
|
258
|
+
}
|
|
259
|
+
// escalate: strictly stronger model than the current one
|
|
260
|
+
const esc = this.opts.kernel.router.escalate(await this.routingRequest(estTokens), current.providerId, current.model);
|
|
261
|
+
if (esc && this.table.has(esc.providerId) && !tried.has(`${esc.providerId}/${esc.modelId}`)) {
|
|
262
|
+
return this.toTarget(this.table.get(esc.providerId), esc.modelId);
|
|
263
|
+
}
|
|
264
|
+
return usable[0] ? this.toTarget(this.table.get(usable[0].providerId), usable[0].modelId) : null;
|
|
265
|
+
}
|
|
266
|
+
toTarget(gp, model) {
|
|
267
|
+
return { providerId: gp.id, baseURL: gp.baseURL, apiKey: gp.apiKey, headers: gp.headers, model };
|
|
268
|
+
}
|
|
269
|
+
async rank(estTokens) {
|
|
270
|
+
try {
|
|
271
|
+
return this.opts.kernel.router.rank(await this.routingRequest(estTokens));
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return [];
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
async routingRequest(estTokens) {
|
|
278
|
+
const kernel = this.opts.kernel;
|
|
279
|
+
const now = Date.now();
|
|
280
|
+
if (!this.modelsCache || now - this.modelsCache.at > MODELS_TTL_MS) {
|
|
281
|
+
this.modelsCache = { at: now, models: await kernel.providerManager.getAvailableModels() };
|
|
282
|
+
}
|
|
283
|
+
const policy = kernel.policies.get(this.opts.policyName);
|
|
284
|
+
return {
|
|
285
|
+
capabilities: this.opts.capabilities ?? ['coding', 'reasoning'],
|
|
286
|
+
preferences: { reasoning: 88, speed: 70, cost: 'medium', contextLength: Math.max(16_000, estTokens * 2) },
|
|
287
|
+
policy,
|
|
288
|
+
availableProviders: kernel.providerManager.listInfos(),
|
|
289
|
+
availableModels: this.modelsCache.models,
|
|
290
|
+
estimatedRequestTokens: estTokens,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
// ── Telemetry / health ───────────────────────────────────────────────────────
|
|
294
|
+
recordError(providerId, status, error) {
|
|
295
|
+
if (status === 429)
|
|
296
|
+
this.opts.kernel.providerManager.record429(providerId);
|
|
297
|
+
else
|
|
298
|
+
this.opts.kernel.providerManager.recordFailure(providerId);
|
|
299
|
+
this.opts.kernel.eventBus.emit({
|
|
300
|
+
type: 'RECOVERY',
|
|
301
|
+
timestamp: Date.now(),
|
|
302
|
+
data: { source: 'gateway', providerId, status, error: error.slice(0, 300) },
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
emitSwitch(from, to, reason) {
|
|
306
|
+
const bus = this.opts.kernel.eventBus;
|
|
307
|
+
if (from.providerId !== to.providerId) {
|
|
308
|
+
bus.emit({
|
|
309
|
+
type: 'PROVIDER_CHANGED',
|
|
310
|
+
timestamp: Date.now(),
|
|
311
|
+
data: { source: 'gateway', previousProviderId: from.providerId, providerId: to.providerId, reason: reason.slice(0, 200) },
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
bus.emit({
|
|
315
|
+
type: 'MODEL_CHANGED',
|
|
316
|
+
timestamp: Date.now(),
|
|
317
|
+
data: {
|
|
318
|
+
source: 'gateway',
|
|
319
|
+
previousModelId: `${from.providerId}/${from.model}`,
|
|
320
|
+
modelId: `${to.providerId}/${to.model}`,
|
|
321
|
+
providerId: to.providerId,
|
|
322
|
+
reason: reason.slice(0, 200),
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
this.opts.logger.info(`[gateway] ${from.providerId}/${from.model} → ${to.providerId}/${to.model}`);
|
|
326
|
+
}
|
|
327
|
+
// ── IO ────────────────────────────────────────────────────────────────────────
|
|
328
|
+
sendJson(res, status, body) {
|
|
329
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
330
|
+
res.end(JSON.stringify(body));
|
|
331
|
+
}
|
|
332
|
+
readBody(req) {
|
|
333
|
+
return new Promise((resolve, reject) => {
|
|
334
|
+
let data = '';
|
|
335
|
+
req.on('data', chunk => {
|
|
336
|
+
data += chunk;
|
|
337
|
+
if (data.length > 32_000_000)
|
|
338
|
+
reject(new Error('body too large'));
|
|
339
|
+
});
|
|
340
|
+
req.on('end', () => resolve(data));
|
|
341
|
+
req.on('error', reject);
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
package/dist/kernel/index.d.ts
CHANGED
|
@@ -37,6 +37,7 @@ export type { MissionPlannerDeps, PlanOptions } from './planner.js';
|
|
|
37
37
|
export { normalizeCapabilities, CANONICAL_CAPABILITIES } from './capability-map.js';
|
|
38
38
|
export { phaseBenchmarkFloor, phaseLabel } from './scheduler.js';
|
|
39
39
|
export * from './federation/index.js';
|
|
40
|
+
export * from './gateway/index.js';
|
|
40
41
|
export { createMcpServer, serveMcpStdio } from './mcp/index.js';
|
|
41
42
|
export type { McpServerDeps } from './mcp/index.js';
|
|
42
43
|
export { GoalLoop } from './autonomy/index.js';
|
package/dist/kernel/index.js
CHANGED
|
@@ -29,6 +29,8 @@ export { normalizeCapabilities, CANONICAL_CAPABILITIES } from './capability-map.
|
|
|
29
29
|
export { phaseBenchmarkFloor, phaseLabel } from './scheduler.js';
|
|
30
30
|
// Federation — neutral device mesh (peers, telemetry, config sync, MOLTFED interop)
|
|
31
31
|
export * from './federation/index.js';
|
|
32
|
+
// Gateway — local OpenAI-compatible endpoint backed by kernel routing/recovery
|
|
33
|
+
export * from './gateway/index.js';
|
|
32
34
|
// MCP — north-facing control surface (spawn/dispatch/role/voice/map over the mesh)
|
|
33
35
|
export { createMcpServer, serveMcpStdio } from './mcp/index.js';
|
|
34
36
|
// Autonomy — proactive unattended goal loop (interval/idle/once triggers)
|
package/package.json
CHANGED