infinicode 2.3.4 → 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/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.4';
19
+ const VERSION = '2.3.5';
20
20
  const config = new Conf({
21
21
  projectName: 'infinicode',
22
22
  defaults: DEFAULT_CONFIG,
@@ -23,7 +23,7 @@ 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, Federation, loadOrCreateIdentity } 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
- const kernel = buildKernelFromConfig(config, { logger: new SilentLogger() });
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
- kernel.destroy();
303
+ if (ownsKernel)
304
+ kernel.destroy();
301
305
  }
302
306
  }
303
307
  /** Ensure the routed model exists in the TUI's injected provider config. */
@@ -337,7 +341,7 @@ async function meshAlive(url, token) {
337
341
  * otherwise start an in-process node for the session. Best-effort: on any
338
342
  * failure the TUI still launches, just without the live mesh palette.
339
343
  */
340
- async function startLocalMeshNode(config) {
344
+ async function startLocalMeshNode(config, kernel) {
341
345
  if (process.env.INFINICODE_NO_MESH)
342
346
  return null;
343
347
  const fed = config.get('federation') ?? {};
@@ -348,7 +352,6 @@ async function startLocalMeshNode(config) {
348
352
  // Reuse an already-running node (don't double-bind the port).
349
353
  if (await meshAlive(url, token))
350
354
  return { url, token, stop: async () => { } };
351
- const kernel = buildKernelFromConfig(config, { logger: new SilentLogger() });
352
355
  const role = (fed.role ?? 'peer');
353
356
  const identity = loadOrCreateIdentity({ role, displayName: fed.displayName });
354
357
  const version = runPkgVersion();
@@ -375,15 +378,70 @@ async function startLocalMeshNode(config) {
375
378
  federation.startLanDiscovery(undefined, fed.lanPort);
376
379
  for (const s of fed.seeds ?? [])
377
380
  await federation.connect(s).catch(() => undefined);
381
+ // The kernel is owned by runAgent — the mesh only stops the federation.
378
382
  return {
379
383
  url, token,
380
- stop: async () => { await federation.stop(); kernel.destroy(); },
384
+ stop: async () => { await federation.stop(); },
381
385
  };
382
386
  }
383
387
  catch {
384
388
  return null;
385
389
  }
386
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
+ }
387
445
  export async function runAgent(_masterUrl, _model, _useTui, config) {
388
446
  const masterUrl = await resolveConfiguredMasterUrl(config);
389
447
  const masterReachable = await isOllamaReachable(masterUrl);
@@ -391,10 +449,14 @@ export async function runAgent(_masterUrl, _model, _useTui, config) {
391
449
  const cloudProviders = config.get('cloudProviders') ?? [];
392
450
  const enabledCloud = cloudProviders.filter(p => p.enabled);
393
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() });
394
456
  // Make this TUI session a mesh participant so the in-TUI slash commands
395
457
  // (/nodes, /build, /activity, …) have a local node to talk to. Reuses a
396
458
  // running `infinicode serve` if one is up, else starts an in-process node.
397
- const mesh = await startLocalMeshNode(config);
459
+ const mesh = await startLocalMeshNode(config, sessionKernel);
398
460
  if (mesh) {
399
461
  process.env.INFINICODE_MESH_URL = mesh.url;
400
462
  if (mesh.token)
@@ -403,27 +465,52 @@ export async function runAgent(_masterUrl, _model, _useTui, config) {
403
465
  // One kernel pass: load every provider's full model catalog (in parallel) and
404
466
  // route the default model under the policy.
405
467
  const routeSpinner = ora('loading model catalog & routing...').start();
406
- const { routed, modelsByProvider } = await prepareRouting(config, policyName);
468
+ const { routed, modelsByProvider } = await prepareRouting(config, policyName, sessionKernel);
407
469
  routeSpinner.stop();
408
- const providers = buildProviderConfig(masterUrl, defaultModel, cloudProviders, masterReachable, modelsByProvider);
409
- let defaultProvider;
410
- let pickedModel;
411
- if (routed && providers[routed.providerId]) {
412
- defaultProvider = routed.providerId;
413
- pickedModel = routed.modelId;
414
- ensureModelInjected(providers, routed.providerId, routed.modelId);
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
415
489
  }
416
490
  else {
417
- const fb = await pickDefaultModel(config, masterReachable);
418
- defaultProvider = fb.provider;
419
- pickedModel = fb.model;
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}`;
420
506
  }
421
- const modelRef = `${defaultProvider}/${pickedModel}`;
422
- const providerCount = Object.keys(providers).length;
507
+ const providerCount = gateway ? gwProviders.length : Object.keys(providers).length;
423
508
  const modelCount = Object.values(providers).reduce((n, p) => n + Object.keys(p.models ?? {}).length, 0);
424
509
  const policy = config.get('policy') ?? 'local-first';
425
510
  const pinnedCount = Object.keys(config.get('workerModels') ?? {}).length;
426
- const routingInfo = `AUTO ${policy} ${providerCount}P ${pinnedCount}pins`;
511
+ const routingInfo = gateway
512
+ ? `KERNEL ${policy} ${providerCount}P ${pinnedCount}pins`
513
+ : `AUTO ${policy} ${providerCount}P ${pinnedCount}pins`;
427
514
  // Runtime TUI plugin that adds the mesh slash-commands (/nodes, /build, …)
428
515
  // to the `/` palette. Shipped in the package at .opencode/plugins; loaded via
429
516
  // a file:// URL so it resolves regardless of the TUI's cwd. Only injected when
@@ -455,6 +542,9 @@ export async function runAgent(_masterUrl, _model, _useTui, config) {
455
542
  },
456
543
  });
457
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
+ }
458
548
  console.log(chalk.dim(` default model: ${chalk.cyan(modelRef)}`));
459
549
  console.log(chalk.dim(` policy: ${chalk.cyan(config.get('policy') ?? 'local-first')}`));
460
550
  if (enabledCloud.length > 0) {
@@ -479,6 +569,11 @@ export async function runAgent(_masterUrl, _model, _useTui, config) {
479
569
  console.log(chalk.cyan(' infinicode install-tui --url <url>') + chalk.dim(' # or download from a hosted binary'));
480
570
  process.exit(1);
481
571
  }
572
+ const cleanup = async () => {
573
+ await gateway?.stop().catch(() => undefined);
574
+ await mesh?.stop().catch(() => undefined);
575
+ sessionKernel.destroy();
576
+ };
482
577
  const { execa } = await import('execa');
483
578
  try {
484
579
  await execa(tuiBinary, [], {
@@ -487,12 +582,12 @@ export async function runAgent(_masterUrl, _model, _useTui, config) {
487
582
  });
488
583
  }
489
584
  catch (e) {
490
- await mesh?.stop().catch(() => undefined);
585
+ await cleanup();
491
586
  const err = e;
492
587
  if (err.exitCode) {
493
588
  process.exit(err.exitCode);
494
589
  }
495
590
  throw e;
496
591
  }
497
- await mesh?.stop().catch(() => undefined);
592
+ await cleanup();
498
593
  }
@@ -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
+ }
@@ -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';
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.3.4",
3
+ "version": "2.3.5",
4
4
  "description": "OpenKernel — provider-agnostic AI execution kernel. Native coding agent + mission-driven execution runtime.",
5
5
  "type": "module",
6
6
  "main": "./dist/kernel/index.js",