@hilbras/omninode 0.1.0 → 0.2.0
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/README.md +32 -9
- package/dist/cli/index.js +473 -9
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +94 -4
- package/dist/index.js +275 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# OmniNode
|
|
2
2
|
|
|
3
|
+
[](https://github.com/Hilbras/OmniNode/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@hilbras/omninode)
|
|
5
|
+
[](https://www.npmjs.com/package/@hilbras/omninode)
|
|
6
|
+
[](https://www.npmjs.com/package/@hilbras/omninode)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](docs/DEVELOPMENT_PLAN.md)
|
|
9
|
+
|
|
3
10
|
**OmniNode** is a provider-agnostic multi-AI orchestration platform. It coordinates
|
|
4
11
|
CLI-based AI agents, AI providers, persistent memory, roles, reports and planning
|
|
5
12
|
into a unified execution system — so multiple AI systems can collaborate on complex
|
|
@@ -11,23 +18,30 @@ existing intelligence; it does not try to become another model.
|
|
|
11
18
|
|
|
12
19
|
## Status
|
|
13
20
|
|
|
14
|
-
**v0.
|
|
21
|
+
**v0.2.0 — Phase 1: Provider Infrastructure** (see [docs/DEVELOPMENT_PLAN.md](docs/DEVELOPMENT_PLAN.md)
|
|
15
22
|
for the full plan and roadmap).
|
|
16
23
|
|
|
17
24
|
What exists today:
|
|
18
25
|
|
|
19
26
|
- Core type contracts: provider, model, agent, role, task, report, pipeline,
|
|
20
27
|
protocol, memory (`src/types/`)
|
|
28
|
+
- **Provider system** (`src/providers/`, `src/registry/`):
|
|
29
|
+
- OpenAI-compatible adapter — works with OpenAI, OpenRouter, local runtimes
|
|
30
|
+
(Ollama, LM Studio, vLLM) and custom gateways exposing `/models` and `/chat/completions`
|
|
31
|
+
- Model discovery with normalization into the model registry (`provider:model` keys)
|
|
32
|
+
- Health checks: connectivity, authentication and server-error classification
|
|
33
|
+
- Authentication from environment variables only — keys never touch config files
|
|
34
|
+
- Chat completions (`IChatProvider`) — the foundation for the planner phases
|
|
35
|
+
- Provider factory mapping config to adapters (OmniHilbras gets a dedicated
|
|
36
|
+
adapter in Phase 2)
|
|
21
37
|
- Typed error hierarchy (`src/errors/`)
|
|
22
38
|
- Leveled logger with pluggable sink (`src/logger/`)
|
|
23
39
|
- Configuration system: `omninode.yaml` with `${ENV_VAR}` expansion and strict
|
|
24
40
|
validation (`src/config/`)
|
|
25
|
-
- CLI
|
|
26
|
-
stubs for the phase-dependent commands (`src/cli/`)
|
|
41
|
+
- CLI: `init`, `provider add/list/test`, `models`, `agent list`, `role list`,
|
|
42
|
+
and honest stubs for the phase-dependent commands (`src/cli/`)
|
|
27
43
|
- Test infrastructure (Vitest) and CI (GitHub Actions)
|
|
28
44
|
|
|
29
|
-
Nothing here depends on any provider or agent — that is the point of Phase 0.
|
|
30
|
-
|
|
31
45
|
## Requirements
|
|
32
46
|
|
|
33
47
|
- Node.js >= 20
|
|
@@ -46,8 +60,17 @@ npm run omninode -- --help
|
|
|
46
60
|
Start a project:
|
|
47
61
|
|
|
48
62
|
```bash
|
|
49
|
-
omninode init
|
|
50
|
-
|
|
63
|
+
omninode init # writes omninode.yaml in the current directory
|
|
64
|
+
|
|
65
|
+
# Register a provider and verify it end to end
|
|
66
|
+
omninode provider add --name my-gateway --type openai-compatible \
|
|
67
|
+
--base-url https://example.com/v1 --api-key-env-var MY_API_KEY
|
|
68
|
+
omninode provider test my-gateway
|
|
69
|
+
|
|
70
|
+
# Discover models into the registry
|
|
71
|
+
omninode models
|
|
72
|
+
omninode models my-gateway --capability chat
|
|
73
|
+
|
|
51
74
|
omninode agent list
|
|
52
75
|
omninode role list
|
|
53
76
|
```
|
|
@@ -81,8 +104,8 @@ Phases 0–11 are laid out in [docs/DEVELOPMENT_PLAN.md](docs/DEVELOPMENT_PLAN.m
|
|
|
81
104
|
|
|
82
105
|
| Version | Milestone |
|
|
83
106
|
| --- | --- |
|
|
84
|
-
| v0.1.0 | Foundation
|
|
85
|
-
| v0.2.0 | Provider System |
|
|
107
|
+
| v0.1.0 | Foundation |
|
|
108
|
+
| **v0.2.0** | **Provider System (current)** |
|
|
86
109
|
| v0.3.0 | OmniHilbras Integration (optional, like every provider) |
|
|
87
110
|
| v0.4.0 | Agent System |
|
|
88
111
|
| v0.5.0 | Roles & Tasks |
|
package/dist/cli/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { realpathSync } from "fs";
|
|
|
6
6
|
import { Command } from "commander";
|
|
7
7
|
|
|
8
8
|
// src/version.ts
|
|
9
|
-
var OMNINODE_VERSION = "0.
|
|
9
|
+
var OMNINODE_VERSION = "0.2.0";
|
|
10
10
|
|
|
11
11
|
// src/errors/index.ts
|
|
12
12
|
var OmniNodeError = class extends Error {
|
|
@@ -24,6 +24,11 @@ var ConfigError = class extends OmniNodeError {
|
|
|
24
24
|
super(code, message, options);
|
|
25
25
|
}
|
|
26
26
|
};
|
|
27
|
+
var ProviderError = class extends OmniNodeError {
|
|
28
|
+
constructor(code, message, options = {}) {
|
|
29
|
+
super(code, message, options);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
27
32
|
function isOmniNodeError(value) {
|
|
28
33
|
return value instanceof OmniNodeError;
|
|
29
34
|
}
|
|
@@ -238,6 +243,291 @@ function registerProjectCommands(program) {
|
|
|
238
243
|
project.command("init").description("Alias of `omninode init`.").option("-f, --force", "Overwrite an existing omninode.yaml.").option("-n, --name <name>", "Project name to write into the configuration.", "My Project").action(async (options) => runInit(options));
|
|
239
244
|
}
|
|
240
245
|
|
|
246
|
+
// src/cli/commands/providers.ts
|
|
247
|
+
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
248
|
+
import { parseDocument } from "yaml";
|
|
249
|
+
|
|
250
|
+
// src/providers/auth.ts
|
|
251
|
+
function resolveApiKey(config, env = process.env) {
|
|
252
|
+
if (!config.apiKeyEnvVar) return void 0;
|
|
253
|
+
const value = env[config.apiKeyEnvVar];
|
|
254
|
+
if (!value) {
|
|
255
|
+
throw new ProviderError(
|
|
256
|
+
"PROVIDER_AUTH_FAILED",
|
|
257
|
+
`Environment variable "${config.apiKeyEnvVar}" (referenced by provider "${config.name}") is not set.`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
return value;
|
|
261
|
+
}
|
|
262
|
+
function authHeaders(config, env = process.env) {
|
|
263
|
+
const key = resolveApiKey(config, env);
|
|
264
|
+
const base = { ...config.headers ?? {} };
|
|
265
|
+
return key ? { ...base, authorization: `Bearer ${key}` } : base;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// src/providers/http.ts
|
|
269
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
270
|
+
async function requestJson(url, options = {}) {
|
|
271
|
+
const { method = "GET", headers = {}, body, timeoutMs = DEFAULT_TIMEOUT_MS } = options;
|
|
272
|
+
let response;
|
|
273
|
+
try {
|
|
274
|
+
response = await fetch(url, {
|
|
275
|
+
method,
|
|
276
|
+
headers: {
|
|
277
|
+
accept: "application/json",
|
|
278
|
+
...body !== void 0 ? { "content-type": "application/json" } : {},
|
|
279
|
+
...headers
|
|
280
|
+
},
|
|
281
|
+
...body !== void 0 ? { body: JSON.stringify(body) } : {},
|
|
282
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
283
|
+
});
|
|
284
|
+
} catch (error) {
|
|
285
|
+
const reason = error instanceof Error && error.name === "TimeoutError" ? `timed out after ${timeoutMs}ms` : error instanceof Error ? error.message : String(error);
|
|
286
|
+
throw new ProviderError("PROVIDER_UNAVAILABLE", `Request to ${url} failed: ${reason}.`, {
|
|
287
|
+
cause: error
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
let parsed;
|
|
291
|
+
try {
|
|
292
|
+
parsed = await response.json();
|
|
293
|
+
} catch (error) {
|
|
294
|
+
throw new ProviderError("PROVIDER_UNAVAILABLE", `Response from ${url} was not valid JSON.`, {
|
|
295
|
+
cause: error,
|
|
296
|
+
details: { status: response.status }
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
return { status: response.status, body: parsed };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// src/logger/index.ts
|
|
303
|
+
var LEVEL_ORDER = {
|
|
304
|
+
debug: 10,
|
|
305
|
+
info: 20,
|
|
306
|
+
warn: 30,
|
|
307
|
+
error: 40
|
|
308
|
+
};
|
|
309
|
+
var ConsoleLogSink = class {
|
|
310
|
+
constructor(stream = process.stderr) {
|
|
311
|
+
this.stream = stream;
|
|
312
|
+
}
|
|
313
|
+
stream;
|
|
314
|
+
write(_level, line) {
|
|
315
|
+
this.stream.write(line + "\n");
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
var Logger = class _Logger {
|
|
319
|
+
level;
|
|
320
|
+
sink;
|
|
321
|
+
context;
|
|
322
|
+
constructor(options = {}) {
|
|
323
|
+
this.level = options.level ?? "info";
|
|
324
|
+
this.sink = options.sink ?? new ConsoleLogSink();
|
|
325
|
+
this.context = options.context ?? {};
|
|
326
|
+
}
|
|
327
|
+
child(context) {
|
|
328
|
+
return new _Logger({
|
|
329
|
+
level: this.level,
|
|
330
|
+
sink: this.sink,
|
|
331
|
+
context: { ...this.context, ...context }
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
debug(message, data) {
|
|
335
|
+
this.log("debug", message, data);
|
|
336
|
+
}
|
|
337
|
+
info(message, data) {
|
|
338
|
+
this.log("info", message, data);
|
|
339
|
+
}
|
|
340
|
+
warn(message, data) {
|
|
341
|
+
this.log("warn", message, data);
|
|
342
|
+
}
|
|
343
|
+
error(message, data) {
|
|
344
|
+
this.log("error", message, data);
|
|
345
|
+
}
|
|
346
|
+
log(level, message, data) {
|
|
347
|
+
if (LEVEL_ORDER[level] < LEVEL_ORDER[this.level]) return;
|
|
348
|
+
const parts = [(/* @__PURE__ */ new Date()).toISOString(), level.toUpperCase(), message];
|
|
349
|
+
const merged = { ...this.context, ...data };
|
|
350
|
+
if (Object.keys(merged).length > 0) {
|
|
351
|
+
parts.push(JSON.stringify(merged));
|
|
352
|
+
}
|
|
353
|
+
this.sink.write(level, parts.join(" "));
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
var logger = new Logger();
|
|
357
|
+
|
|
358
|
+
// src/providers/openai-compatible/index.ts
|
|
359
|
+
var OpenAICompatibleProvider = class {
|
|
360
|
+
config;
|
|
361
|
+
log;
|
|
362
|
+
constructor(config, log = logger) {
|
|
363
|
+
this.config = { ...config, baseUrl: normalizeBaseUrl(config.baseUrl) };
|
|
364
|
+
this.log = log.child({ provider: config.name });
|
|
365
|
+
}
|
|
366
|
+
async listModels() {
|
|
367
|
+
const { status, body } = await requestJson(`${this.config.baseUrl}/models`, {
|
|
368
|
+
headers: authHeaders(this.config)
|
|
369
|
+
});
|
|
370
|
+
if (status === 401 || status === 403) {
|
|
371
|
+
throw authFailed(this.config, status);
|
|
372
|
+
}
|
|
373
|
+
if (status !== 200) {
|
|
374
|
+
throw unavailable(this.config, `Model discovery returned HTTP ${status}.`, status);
|
|
375
|
+
}
|
|
376
|
+
const entries = extractModelEntries(body, this.config);
|
|
377
|
+
const models = [];
|
|
378
|
+
let skipped = 0;
|
|
379
|
+
for (const entry2 of entries) {
|
|
380
|
+
if (typeof entry2.id !== "string" || entry2.id.length === 0) {
|
|
381
|
+
skipped += 1;
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
models.push({
|
|
385
|
+
provider: this.config.name,
|
|
386
|
+
id: entry2.id,
|
|
387
|
+
displayName: entry2.id,
|
|
388
|
+
status: "available",
|
|
389
|
+
metadata: {
|
|
390
|
+
...typeof entry2.owned_by === "string" ? { ownedBy: entry2.owned_by } : {},
|
|
391
|
+
...typeof entry2.created === "number" ? { createdAt: entry2.created } : {}
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
if (skipped > 0) {
|
|
396
|
+
this.log.warn(`Skipped ${skipped} model entr(y/ies) without a valid id.`);
|
|
397
|
+
}
|
|
398
|
+
return models;
|
|
399
|
+
}
|
|
400
|
+
async healthCheck() {
|
|
401
|
+
const lastCheckedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
402
|
+
try {
|
|
403
|
+
const started = Date.now();
|
|
404
|
+
const models = await this.listModels();
|
|
405
|
+
return {
|
|
406
|
+
name: this.config.name,
|
|
407
|
+
type: this.config.type,
|
|
408
|
+
baseUrl: this.config.baseUrl,
|
|
409
|
+
health: "healthy",
|
|
410
|
+
connected: true,
|
|
411
|
+
modelCount: models.length,
|
|
412
|
+
lastCheckedAt,
|
|
413
|
+
message: `Discovered ${models.length} model(s) in ${Date.now() - started}ms.`
|
|
414
|
+
};
|
|
415
|
+
} catch (error) {
|
|
416
|
+
return {
|
|
417
|
+
name: this.config.name,
|
|
418
|
+
type: this.config.type,
|
|
419
|
+
baseUrl: this.config.baseUrl,
|
|
420
|
+
health: inferHealth(error),
|
|
421
|
+
connected: false,
|
|
422
|
+
modelCount: 0,
|
|
423
|
+
lastCheckedAt,
|
|
424
|
+
message: error instanceof Error ? error.message : String(error)
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
async chat(request) {
|
|
429
|
+
const { status, body } = await requestJson(`${this.config.baseUrl}/chat/completions`, {
|
|
430
|
+
method: "POST",
|
|
431
|
+
headers: authHeaders(this.config),
|
|
432
|
+
body: {
|
|
433
|
+
model: request.model,
|
|
434
|
+
messages: request.messages,
|
|
435
|
+
...request.temperature !== void 0 ? { temperature: request.temperature } : {},
|
|
436
|
+
...request.maxTokens !== void 0 ? { max_tokens: request.maxTokens } : {},
|
|
437
|
+
...request.stop !== void 0 ? { stop: request.stop } : {}
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
if (status === 401 || status === 403) throw authFailed(this.config, status);
|
|
441
|
+
if (status === 404) {
|
|
442
|
+
throw new ProviderError(
|
|
443
|
+
"MODEL_NOT_FOUND",
|
|
444
|
+
`Model "${request.model}" or the chat endpoint was not found on provider "${this.config.name}" (HTTP 404).`,
|
|
445
|
+
{ details: { status } }
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
if (status !== 200) {
|
|
449
|
+
throw unavailable(this.config, `Chat completion returned HTTP ${status}.`, status);
|
|
450
|
+
}
|
|
451
|
+
const payload = body;
|
|
452
|
+
const choices = Array.isArray(payload.choices) ? payload.choices : [];
|
|
453
|
+
const first = choices[0];
|
|
454
|
+
if (!first || typeof first.message?.content !== "string") {
|
|
455
|
+
throw unavailable(this.config, "Chat completion response had no message content.", status);
|
|
456
|
+
}
|
|
457
|
+
const usage = payload.usage ?? {};
|
|
458
|
+
return {
|
|
459
|
+
model: typeof payload.model === "string" ? payload.model : request.model,
|
|
460
|
+
content: first.message.content,
|
|
461
|
+
finishReason: typeof first.finish_reason === "string" ? first.finish_reason : void 0,
|
|
462
|
+
usage: {
|
|
463
|
+
promptTokens: typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : void 0,
|
|
464
|
+
completionTokens: typeof usage.completion_tokens === "number" ? usage.completion_tokens : void 0,
|
|
465
|
+
totalTokens: typeof usage.total_tokens === "number" ? usage.total_tokens : void 0
|
|
466
|
+
},
|
|
467
|
+
raw: body
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
function normalizeBaseUrl(baseUrl) {
|
|
472
|
+
return baseUrl.replace(/\/+$/, "");
|
|
473
|
+
}
|
|
474
|
+
function extractModelEntries(body, config) {
|
|
475
|
+
if (Array.isArray(body)) return body;
|
|
476
|
+
if (body !== null && typeof body === "object" && Array.isArray(body.data)) {
|
|
477
|
+
return body.data;
|
|
478
|
+
}
|
|
479
|
+
throw new ProviderError(
|
|
480
|
+
"PROVIDER_UNAVAILABLE",
|
|
481
|
+
`Provider "${config.name}" returned an unrecognized model list shape.`,
|
|
482
|
+
{ details: { endpoint: "models" } }
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
function authFailed(config, status) {
|
|
486
|
+
const envHint = config.apiKeyEnvVar ? ` Check that $${config.apiKeyEnvVar} is set and valid.` : " The provider requires authentication; configure api_key_env_var for it.";
|
|
487
|
+
return new ProviderError(
|
|
488
|
+
"PROVIDER_AUTH_FAILED",
|
|
489
|
+
`Authentication with provider "${config.name}" failed (HTTP ${status}).${envHint}`,
|
|
490
|
+
{ details: { status } }
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
function unavailable(config, message, status) {
|
|
494
|
+
return new ProviderError("PROVIDER_UNAVAILABLE", `Provider "${config.name}": ${message}`, {
|
|
495
|
+
details: { status }
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
function inferHealth(error) {
|
|
499
|
+
const status = error instanceof ProviderError && typeof error.details?.status === "number" ? error.details.status : void 0;
|
|
500
|
+
return status !== void 0 && status >= 500 ? "degraded" : "unreachable";
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// src/providers/factory.ts
|
|
504
|
+
var OPENAI_COMPATIBLE_TYPES = [
|
|
505
|
+
"openai-compatible",
|
|
506
|
+
"openrouter",
|
|
507
|
+
"local",
|
|
508
|
+
"custom"
|
|
509
|
+
];
|
|
510
|
+
function createProvider(config) {
|
|
511
|
+
if (OPENAI_COMPATIBLE_TYPES.includes(config.type)) {
|
|
512
|
+
return new OpenAICompatibleProvider(config);
|
|
513
|
+
}
|
|
514
|
+
if (config.type === "omnihilbras") {
|
|
515
|
+
throw new OmniNodeError(
|
|
516
|
+
"NOT_IMPLEMENTED",
|
|
517
|
+
`Provider type "omnihilbras" is planned for Phase 2 (OmniHilbras Integration). If your OmniHilbras endpoint is OpenAI-compatible, use type "openai-compatible" for now.`
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
throw new OmniNodeError("NOT_IMPLEMENTED", `Provider type "${config.type}" has no adapter yet.`);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// src/cli/commands/shared.ts
|
|
524
|
+
function notImplemented(command, when) {
|
|
525
|
+
return new OmniNodeError(
|
|
526
|
+
"NOT_IMPLEMENTED",
|
|
527
|
+
`${command} is planned for ${when}. Edit omninode.yaml directly for now.`
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
241
531
|
// src/cli/commands/providers.ts
|
|
242
532
|
function registerProviderCommands(program) {
|
|
243
533
|
const provider = program.command("provider").description("Manage AI providers configured in omninode.yaml.");
|
|
@@ -246,7 +536,7 @@ function registerProviderCommands(program) {
|
|
|
246
536
|
const providers = config.project.providers;
|
|
247
537
|
if (providers.length === 0) {
|
|
248
538
|
console.log(
|
|
249
|
-
"No providers configured. Add one
|
|
539
|
+
"No providers configured. Add one with `omninode provider add` or edit omninode.yaml."
|
|
250
540
|
);
|
|
251
541
|
return;
|
|
252
542
|
}
|
|
@@ -258,15 +548,188 @@ function registerProviderCommands(program) {
|
|
|
258
548
|
);
|
|
259
549
|
}
|
|
260
550
|
});
|
|
261
|
-
provider.command("add").description(
|
|
262
|
-
|
|
551
|
+
provider.command("add").description(
|
|
552
|
+
"Add a provider to omninode.yaml. OpenAI-compatible gateways (including OpenRouter and local runtimes) are supported today; OmniHilbras gets its own adapter in Phase 2."
|
|
553
|
+
).requiredOption("--name <name>", "Unique provider name used in configuration and the model registry.").requiredOption("--base-url <url>", "Provider base URL, e.g. https://example.com/v1").option(
|
|
554
|
+
"--type <type>",
|
|
555
|
+
"Provider type: openai-compatible, omnihilbras, openrouter, local or custom.",
|
|
556
|
+
"openai-compatible"
|
|
557
|
+
).option(
|
|
558
|
+
"--api-key-env-var <envVar>",
|
|
559
|
+
"Environment variable holding the API key. Keys are never written to the config file."
|
|
560
|
+
).action(async (options) => addProvider(options));
|
|
561
|
+
provider.command("test <name>").description("Test connectivity, authentication and model discovery for a configured provider.").action(async (name) => testProvider(name));
|
|
562
|
+
provider.command("remove <name>").description("Remove a provider (planned for Phase 1 follow-up).").action(() => {
|
|
563
|
+
throw notImplemented("omninode provider remove", "a Phase 1 follow-up");
|
|
263
564
|
});
|
|
264
565
|
}
|
|
265
|
-
function
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
566
|
+
async function addProvider(options) {
|
|
567
|
+
const configPath = findConfigFile();
|
|
568
|
+
if (!configPath) {
|
|
569
|
+
throw new OmniNodeError("CONFIG_NOT_FOUND", "No omninode.yaml found. Run `omninode init` first.");
|
|
570
|
+
}
|
|
571
|
+
const candidate = providerConfigSchema.safeParse({
|
|
572
|
+
name: options.name,
|
|
573
|
+
type: options.type,
|
|
574
|
+
base_url: options.baseUrl,
|
|
575
|
+
...options.apiKeyEnvVar !== void 0 ? { api_key_env_var: options.apiKeyEnvVar } : {}
|
|
576
|
+
});
|
|
577
|
+
if (!candidate.success) {
|
|
578
|
+
const issues = candidate.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
|
|
579
|
+
throw new OmniNodeError("CLI_USAGE", `Invalid provider options: ${issues}`);
|
|
580
|
+
}
|
|
581
|
+
const newProvider = candidate.data;
|
|
582
|
+
const existing = loadConfig();
|
|
583
|
+
if (existing.project.providers.some((p) => p.name === newProvider.name)) {
|
|
584
|
+
throw new OmniNodeError(
|
|
585
|
+
"CLI_USAGE",
|
|
586
|
+
`Provider "${newProvider.name}" already exists in ${configPath}.`
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
const doc = parseDocument(readFileSync2(configPath, "utf8"));
|
|
590
|
+
if (doc.getIn(["project", "providers"]) === void 0) {
|
|
591
|
+
doc.setIn(["project", "providers"], [newProvider]);
|
|
592
|
+
} else {
|
|
593
|
+
doc.addIn(["project", "providers"], newProvider);
|
|
594
|
+
}
|
|
595
|
+
const validated = appConfigSchema.safeParse(doc.toJS());
|
|
596
|
+
if (!validated.success) {
|
|
597
|
+
const issues = validated.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
|
|
598
|
+
throw new ConfigError(
|
|
599
|
+
"CONFIG_INVALID",
|
|
600
|
+
`Editing ${configPath} would produce an invalid configuration: ${issues}`
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
writeFileSync2(configPath, doc.toString(), "utf8");
|
|
604
|
+
console.log(`Added provider "${newProvider.name}" (${newProvider.type}) to ${configPath}.`);
|
|
605
|
+
if (newProvider.api_key_env_var) {
|
|
606
|
+
console.log(
|
|
607
|
+
`Set $${newProvider.api_key_env_var} in your environment, then run: omninode provider test ${newProvider.name}`
|
|
608
|
+
);
|
|
609
|
+
} else {
|
|
610
|
+
console.log(`Run \`omninode provider test ${newProvider.name}\` to verify connectivity.`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
async function testProvider(name) {
|
|
614
|
+
const config = loadConfig();
|
|
615
|
+
const providerConfig = config.project.providers.find((p) => p.name === name);
|
|
616
|
+
if (!providerConfig) {
|
|
617
|
+
const available = config.project.providers.map((p) => p.name).join(", ") || "(none)";
|
|
618
|
+
throw new ProviderError(
|
|
619
|
+
"PROVIDER_NOT_FOUND",
|
|
620
|
+
`Provider "${name}" is not configured. Configured providers: ${available}.`
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
const provider = createProvider(providerConfig);
|
|
624
|
+
console.log(`Testing provider "${name}" (${providerConfig.type}) at ${provider.config.baseUrl}`);
|
|
625
|
+
const health = await provider.healthCheck();
|
|
626
|
+
console.log(` health: ${health.health}`);
|
|
627
|
+
console.log(` connected: ${health.connected}`);
|
|
628
|
+
console.log(` models: ${health.modelCount}`);
|
|
629
|
+
if (health.message) console.log(` detail: ${health.message}`);
|
|
630
|
+
if (!health.connected) process.exitCode = 1;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// src/registry/index.ts
|
|
634
|
+
function registryKey(provider, id) {
|
|
635
|
+
return `${provider}:${id}`;
|
|
636
|
+
}
|
|
637
|
+
var ModelRegistry = class {
|
|
638
|
+
models = /* @__PURE__ */ new Map();
|
|
639
|
+
register(model) {
|
|
640
|
+
this.models.set(registryKey(model.provider, model.id), model);
|
|
641
|
+
}
|
|
642
|
+
get(provider, id) {
|
|
643
|
+
return this.models.get(registryKey(provider, id));
|
|
644
|
+
}
|
|
645
|
+
list(filter = {}) {
|
|
646
|
+
return [...this.models.values()].filter((model) => filter.provider ? model.provider === filter.provider : true).filter(
|
|
647
|
+
(model) => filter.capability ? model.capabilities?.[filter.capability] === true : true
|
|
648
|
+
).sort((a, b) => a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id));
|
|
649
|
+
}
|
|
650
|
+
/** Availability validation (§27 Phase 1): present in the registry and not retired. */
|
|
651
|
+
isAvailable(provider, id) {
|
|
652
|
+
const model = this.get(provider, id);
|
|
653
|
+
return model !== void 0 && (model.status ?? "available") === "available";
|
|
654
|
+
}
|
|
655
|
+
/** Discover models from one provider and register them. */
|
|
656
|
+
async discoverFrom(provider) {
|
|
657
|
+
const discovered = await provider.listModels();
|
|
658
|
+
for (const model of discovered) this.register(model);
|
|
659
|
+
return discovered;
|
|
660
|
+
}
|
|
661
|
+
/** Discover from many providers; a failing provider does not abort the rest. */
|
|
662
|
+
async discoverAll(providers) {
|
|
663
|
+
return Promise.all(
|
|
664
|
+
providers.map(async (provider) => {
|
|
665
|
+
try {
|
|
666
|
+
return {
|
|
667
|
+
provider: provider.config.name,
|
|
668
|
+
models: await this.discoverFrom(provider)
|
|
669
|
+
};
|
|
670
|
+
} catch (error) {
|
|
671
|
+
return {
|
|
672
|
+
provider: provider.config.name,
|
|
673
|
+
models: [],
|
|
674
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
})
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
// src/cli/commands/models.ts
|
|
683
|
+
var CAPABILITIES = [
|
|
684
|
+
"chat",
|
|
685
|
+
"coding",
|
|
686
|
+
"tools",
|
|
687
|
+
"vision",
|
|
688
|
+
"embeddings"
|
|
689
|
+
];
|
|
690
|
+
function registerModelsCommand(program) {
|
|
691
|
+
program.command("models").description("Discover and list models from configured providers (model registry, \xA78).").argument("[provider]", "Only discover from this provider.").option("-c, --capability <capability>", `Filter by capability: ${CAPABILITIES.join(", ")}.`).action(async (providerName, options) => {
|
|
692
|
+
const capability = options.capability;
|
|
693
|
+
if (capability !== void 0 && !CAPABILITIES.includes(capability)) {
|
|
694
|
+
throw new OmniNodeError(
|
|
695
|
+
"CLI_USAGE",
|
|
696
|
+
`Unknown capability "${options.capability}". Valid capabilities: ${CAPABILITIES.join(", ")}.`
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
const config = loadConfig();
|
|
700
|
+
const selected = config.project.providers.filter(
|
|
701
|
+
(p) => providerName ? p.name === providerName : true
|
|
702
|
+
);
|
|
703
|
+
if (selected.length === 0) {
|
|
704
|
+
throw new OmniNodeError(
|
|
705
|
+
"PROVIDER_NOT_FOUND",
|
|
706
|
+
providerName ? `Provider "${providerName}" is not configured.` : "No providers configured. Add one with `omninode provider add`."
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
const registry = new ModelRegistry();
|
|
710
|
+
for (const providerConfig of selected) {
|
|
711
|
+
try {
|
|
712
|
+
const provider = createProvider(providerConfig);
|
|
713
|
+
const discovered = await registry.discoverFrom(provider);
|
|
714
|
+
console.log(`${providerConfig.name}: ${discovered.length} model(s)`);
|
|
715
|
+
} catch (error) {
|
|
716
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
717
|
+
console.log(`${providerConfig.name}: discovery failed \u2014 ${message}`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
const models = registry.list({
|
|
721
|
+
...providerName !== void 0 ? { provider: providerName } : {},
|
|
722
|
+
...capability !== void 0 ? { capability } : {}
|
|
723
|
+
});
|
|
724
|
+
if (models.length === 0) {
|
|
725
|
+
console.log("No models found.");
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
console.log("");
|
|
729
|
+
for (const model of models) {
|
|
730
|
+
console.log(`${model.provider}:${model.id}`);
|
|
731
|
+
}
|
|
732
|
+
});
|
|
270
733
|
}
|
|
271
734
|
|
|
272
735
|
// src/cli/commands/agents.ts
|
|
@@ -345,6 +808,7 @@ function createProgram() {
|
|
|
345
808
|
registerRoleCommands(program);
|
|
346
809
|
registerTaskCommands(program);
|
|
347
810
|
registerReportCommands(program);
|
|
811
|
+
registerModelsCommand(program);
|
|
348
812
|
return program;
|
|
349
813
|
}
|
|
350
814
|
async function run(argv) {
|