@hilbras/omninode 2.0.0-alpha.5 → 2.0.0-alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,9 +18,9 @@ existing intelligence; it does not try to become another model.
18
18
 
19
19
  ## Status
20
20
 
21
- **v2.0.0-alpha.5 — v2 Phase 5: Pipeline Lifecycle v2**
21
+ **v2.0.0-alpha.6 — v2 Phase 6: Provider Infrastructure v2**
22
22
  (the reliability & interoperability line: architecture → execution →
23
- protocol → adapters → pipelines) (the v2
23
+ protocol → adapters → pipelines → providers) (the v2
24
24
  roadmap is [docs/ROADMAP_V2.md](docs/ROADMAP_V2.md); the v1 plan is complete:
25
25
  the
26
26
  full loop from §28 runs in one command —
@@ -31,6 +31,14 @@ for the plan and [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the design).
31
31
 
32
32
  ### What's new in v2 (so far)
33
33
 
34
+ - **Phase 6 — Provider Infrastructure v2**: standardized provider contract
35
+ (`providerId`, secret-free `authentication`, declared `capabilities`,
36
+ `connect()` / `getModel()` discovery), richer model metadata (context
37
+ window, input/output modalities, tool/vision/structured-output/streaming
38
+ support), and **unified error normalization** — every provider failure now
39
+ carries a stable `kind` (auth, rate limit, invalid request, model not
40
+ found, timeout, network, server, unknown) plus `retryable` and
41
+ `retryAfterMs`. Details: [PROVIDERS.md](docs/PROVIDERS.md).
34
42
  - **Phase 5 — Pipeline Lifecycle**: first-class pipeline cancellation
35
43
  (`omninode pipeline cancel <run-id>`: stop scheduling → cancel running
36
44
  tasks → terminate agent processes → persist a `cancelled` run), `pipeline
@@ -319,7 +327,7 @@ required for the core engine.
319
327
  - [Architecture](docs/ARCHITECTURE.md) — module map, workflow, local state
320
328
  - [Security model](docs/SECURITY.md) — secrets, process execution, env isolation, audit log
321
329
  - [API stability policy](docs/API_STABILITY.md) and [public API inventory](docs/API.md)
322
- - [Agent Protocol v2 spec](docs/PROTOCOL.md) · [Deprecations](docs/DEPRECATIONS.md) · [Changelog](CHANGELOG.md) · [v2 roadmap](docs/ROADMAP_V2.md)
330
+ - [Agent Protocol v2 spec](docs/PROTOCOL.md) · [Providers](docs/PROVIDERS.md) · [Deprecations](docs/DEPRECATIONS.md) · [Changelog](CHANGELOG.md) · [v2 roadmap](docs/ROADMAP_V2.md)
323
331
  - [Example project](examples/demo/) — a runnable end-to-end demo (`./demo.sh`)
324
332
 
325
333
  Prefer a container? `docker build -t omninode .` then
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 = "2.0.0-alpha.5";
9
+ var OMNINODE_VERSION = "2.0.0-alpha.6";
10
10
 
11
11
  // src/errors/index.ts
12
12
  var OmniNodeError = class extends Error {
@@ -363,35 +363,32 @@ function authHeaders(config, env = process.env) {
363
363
  }
364
364
 
365
365
  // src/providers/http.ts
366
+ var HttpResponseError = class extends Error {
367
+ constructor(message, status, cause) {
368
+ super(message, { cause });
369
+ this.status = status;
370
+ this.name = "HttpResponseError";
371
+ }
372
+ status;
373
+ };
366
374
  var DEFAULT_TIMEOUT_MS = 3e4;
367
375
  async function requestJson(url, options = {}) {
368
376
  const { method = "GET", headers = {}, body, timeoutMs = DEFAULT_TIMEOUT_MS } = options;
369
- let response;
370
- try {
371
- response = await fetch(url, {
372
- method,
373
- headers: {
374
- accept: "application/json",
375
- ...body !== void 0 ? { "content-type": "application/json" } : {},
376
- ...headers
377
- },
378
- ...body !== void 0 ? { body: JSON.stringify(body) } : {},
379
- signal: AbortSignal.timeout(timeoutMs)
380
- });
381
- } catch (error) {
382
- const reason = error instanceof Error && error.name === "TimeoutError" ? `timed out after ${timeoutMs}ms` : error instanceof Error ? error.message : String(error);
383
- throw new ProviderError("PROVIDER_UNAVAILABLE", `Request to ${url} failed: ${reason}.`, {
384
- cause: error
385
- });
386
- }
377
+ const response = await fetch(url, {
378
+ method,
379
+ headers: {
380
+ accept: "application/json",
381
+ ...body !== void 0 ? { "content-type": "application/json" } : {},
382
+ ...headers
383
+ },
384
+ ...body !== void 0 ? { body: JSON.stringify(body) } : {},
385
+ signal: AbortSignal.timeout(timeoutMs)
386
+ });
387
387
  let parsed;
388
388
  try {
389
389
  parsed = await response.json();
390
390
  } catch (error) {
391
- throw new ProviderError("PROVIDER_UNAVAILABLE", `Response from ${url} was not valid JSON.`, {
392
- cause: error,
393
- details: { status: response.status }
394
- });
391
+ throw new HttpResponseError(`Response from ${url} was not valid JSON.`, response.status, error);
395
392
  }
396
393
  return { status: response.status, body: parsed };
397
394
  }
@@ -452,23 +449,151 @@ var Logger = class _Logger {
452
449
  };
453
450
  var logger = new Logger();
454
451
 
452
+ // src/providers/errors.ts
453
+ function kindToCode(kind) {
454
+ switch (kind) {
455
+ case "AUTHENTICATION_ERROR":
456
+ return "PROVIDER_AUTH_FAILED";
457
+ case "MODEL_NOT_FOUND":
458
+ return "MODEL_NOT_FOUND";
459
+ default:
460
+ return "PROVIDER_UNAVAILABLE";
461
+ }
462
+ }
463
+ function isRetryable(kind) {
464
+ return kind === "RATE_LIMIT_ERROR" || kind === "SERVER_ERROR" || kind === "NETWORK_ERROR";
465
+ }
466
+ function kindFromStatus(status) {
467
+ if (status === 401 || status === 403) return "AUTHENTICATION_ERROR";
468
+ if (status === 429) return "RATE_LIMIT_ERROR";
469
+ if (status === 404) return "MODEL_NOT_FOUND";
470
+ if (status === 408 || status === 504) return "TIMEOUT";
471
+ if (status >= 500) return "SERVER_ERROR";
472
+ if (status >= 400) return "INVALID_REQUEST";
473
+ return "UNKNOWN_ERROR";
474
+ }
475
+ function extractMessage(body) {
476
+ if (body === null || typeof body !== "object") return void 0;
477
+ const root = body;
478
+ const error = root.error ?? root;
479
+ const message = error.message ?? error.detail ?? root.message;
480
+ return typeof message === "string" ? message : void 0;
481
+ }
482
+ function parseRetryAfter(value, now = Date.now()) {
483
+ if (!value) return void 0;
484
+ const seconds = Number(value);
485
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
486
+ const date = Date.parse(value);
487
+ return Number.isNaN(date) ? void 0 : Math.max(0, date - now);
488
+ }
489
+ function isTimeoutError(error) {
490
+ return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError") || typeof error?.code === "string" && error.code === "ABORT_ERR";
491
+ }
492
+ function fallbackMessage(kind, context, status) {
493
+ const { provider, operation } = context;
494
+ switch (kind) {
495
+ case "AUTHENTICATION_ERROR":
496
+ return `Authentication with provider "${provider}" failed (HTTP ${status}). Check that the referenced API key is set and valid.`;
497
+ case "RATE_LIMIT_ERROR":
498
+ return `Provider "${provider}" rate-limited ${operation} (HTTP ${status}). Retry later.`;
499
+ case "MODEL_NOT_FOUND":
500
+ return `Provider "${provider}" does not expose the requested model (HTTP ${status}).`;
501
+ case "TIMEOUT":
502
+ return `Provider "${provider}" timed out during ${operation} (HTTP ${status}).`;
503
+ case "SERVER_ERROR":
504
+ return `Provider "${provider}" returned a server error during ${operation} (HTTP ${status}).`;
505
+ case "INVALID_REQUEST":
506
+ return `Provider "${provider}" rejected ${operation} as invalid (HTTP ${status}).`;
507
+ default:
508
+ return `Provider "${provider}" failed during ${operation} (HTTP ${status}).`;
509
+ }
510
+ }
511
+ function providerHttpError(context) {
512
+ const status = context.status ?? 0;
513
+ const kind = kindFromStatus(status);
514
+ const detail = extractMessage(context.body);
515
+ const retryAfterMs = context.retryAfterMs ?? parseRetryAfter(void 0);
516
+ return new ProviderError(
517
+ context.code ?? kindToCode(kind),
518
+ detail ?? fallbackMessage(kind, context, status),
519
+ {
520
+ cause: context.cause,
521
+ details: {
522
+ kind,
523
+ provider: context.provider,
524
+ operation: context.operation,
525
+ status,
526
+ retryable: isRetryable(kind),
527
+ ...retryAfterMs !== void 0 ? { retryAfterMs } : {},
528
+ ...detail ? { providerMessage: detail } : {}
529
+ }
530
+ }
531
+ );
532
+ }
533
+ function providerTransportError(context) {
534
+ const cause = context.cause;
535
+ const kind = isTimeoutError(cause) ? "TIMEOUT" : cause instanceof SyntaxError || cause?.name === "HttpResponseError" ? "UNKNOWN_ERROR" : "NETWORK_ERROR";
536
+ const message = context.cause instanceof Error ? context.cause.message : String(context.cause);
537
+ return new ProviderError("PROVIDER_UNAVAILABLE", `Provider request failed: ${message}`, {
538
+ cause: context.cause,
539
+ details: {
540
+ kind,
541
+ provider: context.provider,
542
+ operation: context.operation,
543
+ retryable: isRetryable(kind)
544
+ }
545
+ });
546
+ }
547
+
455
548
  // src/providers/openai-compatible/index.ts
456
549
  var OpenAICompatibleProvider = class {
457
550
  config;
551
+ providerId;
552
+ authentication;
458
553
  log;
459
- constructor(config, log = logger) {
554
+ /** Discovered models, so getModel() answers without another round-trip. */
555
+ discovered;
556
+ env;
557
+ constructor(config, log = logger, env = process.env) {
460
558
  this.config = { ...config, baseUrl: normalizeBaseUrl(config.baseUrl) };
559
+ this.env = env;
560
+ this.providerId = config.providerId ?? config.name;
561
+ this.authentication = {
562
+ method: config.apiKeyEnvVar ? "bearer" : "none",
563
+ ...config.apiKeyEnvVar ? { envVar: config.apiKeyEnvVar } : {},
564
+ configured: config.apiKeyEnvVar ? Boolean(env[config.apiKeyEnvVar]) : true
565
+ };
461
566
  this.log = log.child({ provider: config.name });
462
567
  }
568
+ /** Authenticate + discover (roadmap §10, Model Discovery). */
569
+ async connect() {
570
+ authHeaders(this.config, this.env);
571
+ const models = await this.listModels();
572
+ this.discovered = models;
573
+ return models;
574
+ }
575
+ async getModel(modelId) {
576
+ const models = this.discovered ?? await this.connect();
577
+ return models.find((m) => m.id === modelId);
578
+ }
463
579
  async listModels() {
464
- const { status, body } = await requestJson(`${this.config.baseUrl}/models`, {
465
- headers: authHeaders(this.config)
466
- });
467
- if (status === 401 || status === 403) {
468
- throw authFailed(this.config, status);
580
+ let response;
581
+ try {
582
+ response = await requestJson(`${this.config.baseUrl}/models`, {
583
+ headers: authHeaders(this.config, this.env)
584
+ });
585
+ } catch (error) {
586
+ if (error instanceof ProviderError) throw error;
587
+ throw providerTransportError({ provider: this.config.name, operation: "listModels", cause: error });
469
588
  }
589
+ const { status, body } = response;
470
590
  if (status !== 200) {
471
- throw unavailable(this.config, `Model discovery returned HTTP ${status}.`, status);
591
+ throw providerHttpError({
592
+ provider: this.config.name,
593
+ operation: "listModels",
594
+ status,
595
+ body
596
+ });
472
597
  }
473
598
  const entries = extractModelEntries(body, this.config);
474
599
  const models = [];
@@ -478,22 +603,40 @@ var OpenAICompatibleProvider = class {
478
603
  skipped += 1;
479
604
  continue;
480
605
  }
481
- models.push({
482
- provider: this.config.name,
483
- id: entry2.id,
484
- displayName: entry2.id,
485
- status: "available",
486
- metadata: {
487
- ...typeof entry2.owned_by === "string" ? { ownedBy: entry2.owned_by } : {},
488
- ...typeof entry2.created === "number" ? { createdAt: entry2.created } : {}
489
- }
490
- });
606
+ models.push(this.toModelInfo(entry2));
491
607
  }
492
608
  if (skipped > 0) {
493
609
  this.log.warn(`Skipped ${skipped} model entr(y/ies) without a valid id.`);
494
610
  }
495
611
  return models;
496
612
  }
613
+ /** Maps a raw model entry onto the normalized metadata shape (§10). */
614
+ toModelInfo(entry2) {
615
+ const parameters = Array.isArray(entry2.supported_parameters) ? entry2.supported_parameters.filter((p) => typeof p === "string") : void 0;
616
+ const contextWindow = typeof entry2.context_length === "number" ? entry2.context_length : typeof entry2.context_window === "number" ? entry2.context_window : void 0;
617
+ const inputTypes = normalizeModalities(entry2.input_modalities);
618
+ const outputTypes = normalizeModalities(entry2.output_modalities);
619
+ return {
620
+ provider: this.config.name,
621
+ id: entry2.id,
622
+ displayName: entry2.id,
623
+ ...typeof entry2.name === "string" ? { name: entry2.name } : {},
624
+ status: "available",
625
+ ...contextWindow !== void 0 ? { contextWindow } : {},
626
+ ...inputTypes !== void 0 ? { inputTypes } : {},
627
+ ...outputTypes !== void 0 ? { outputTypes } : {},
628
+ ...parameters !== void 0 ? {
629
+ supportsTools: parameters.includes("tools") || parameters.includes("tool_choice"),
630
+ supportsStructuredOutput: parameters.includes("response_format"),
631
+ supportsStreaming: parameters.includes("stream")
632
+ } : {},
633
+ metadata: {
634
+ ...typeof entry2.owned_by === "string" ? { ownedBy: entry2.owned_by } : {},
635
+ ...typeof entry2.created === "number" ? { createdAt: entry2.created } : {},
636
+ ...parameters !== void 0 ? { supportedParameters: parameters } : {}
637
+ }
638
+ };
639
+ }
497
640
  async healthCheck() {
498
641
  const lastCheckedAt = (/* @__PURE__ */ new Date()).toISOString();
499
642
  try {
@@ -523,27 +666,34 @@ var OpenAICompatibleProvider = class {
523
666
  }
524
667
  }
525
668
  async chat(request) {
526
- const { status, body } = await requestJson(`${this.config.baseUrl}/chat/completions`, {
527
- method: "POST",
528
- headers: authHeaders(this.config),
529
- body: {
530
- model: request.model,
531
- messages: request.messages,
532
- ...request.temperature !== void 0 ? { temperature: request.temperature } : {},
533
- ...request.maxTokens !== void 0 ? { max_tokens: request.maxTokens } : {},
534
- ...request.stop !== void 0 ? { stop: request.stop } : {}
535
- }
536
- });
537
- if (status === 401 || status === 403) throw authFailed(this.config, status);
538
- if (status === 404) {
539
- throw new ProviderError(
540
- "MODEL_NOT_FOUND",
541
- `Model "${request.model}" or the chat endpoint was not found on provider "${this.config.name}" (HTTP 404).`,
542
- { details: { status } }
543
- );
669
+ let response;
670
+ try {
671
+ response = await requestJson(`${this.config.baseUrl}/chat/completions`, {
672
+ method: "POST",
673
+ headers: authHeaders(this.config, this.env),
674
+ body: {
675
+ model: request.model,
676
+ messages: request.messages,
677
+ ...request.temperature !== void 0 ? { temperature: request.temperature } : {},
678
+ ...request.maxTokens !== void 0 ? { max_tokens: request.maxTokens } : {},
679
+ ...request.stop !== void 0 ? { stop: request.stop } : {}
680
+ }
681
+ });
682
+ } catch (error) {
683
+ if (error instanceof ProviderError) throw error;
684
+ throw providerTransportError({ provider: this.config.name, operation: "chat", cause: error });
544
685
  }
686
+ const { status, body } = response;
545
687
  if (status !== 200) {
546
- throw unavailable(this.config, `Chat completion returned HTTP ${status}.`, status);
688
+ throw providerHttpError({
689
+ provider: this.config.name,
690
+ operation: "chat",
691
+ status,
692
+ body,
693
+ ...status === 404 ? {
694
+ code: "MODEL_NOT_FOUND"
695
+ } : {}
696
+ });
547
697
  }
548
698
  const payload = body;
549
699
  const choices = Array.isArray(payload.choices) ? payload.choices : [];
@@ -565,6 +715,13 @@ var OpenAICompatibleProvider = class {
565
715
  };
566
716
  }
567
717
  };
718
+ function normalizeModalities(value) {
719
+ const list = Array.isArray(value) ? value : typeof value === "string" ? [value] : void 0;
720
+ if (!list) return void 0;
721
+ const allowed = /* @__PURE__ */ new Set(["text", "image", "audio", "video", "embedding"]);
722
+ const modalities = list.filter((m) => typeof m === "string" && allowed.has(m));
723
+ return modalities.length > 0 ? modalities : void 0;
724
+ }
568
725
  function normalizeBaseUrl(baseUrl) {
569
726
  return baseUrl.replace(/\/+$/, "");
570
727
  }
@@ -579,14 +736,6 @@ function extractModelEntries(body, config) {
579
736
  { details: { endpoint: "models" } }
580
737
  );
581
738
  }
582
- function authFailed(config, status) {
583
- const envHint = config.apiKeyEnvVar ? ` Check that $${config.apiKeyEnvVar} is set and valid.` : " The provider requires authentication; configure api_key_env_var for it.";
584
- return new ProviderError(
585
- "PROVIDER_AUTH_FAILED",
586
- `Authentication with provider "${config.name}" failed (HTTP ${status}).${envHint}`,
587
- { details: { status } }
588
- );
589
- }
590
739
  function unavailable(config, message, status) {
591
740
  return new ProviderError("PROVIDER_UNAVAILABLE", `Provider "${config.name}": ${message}`, {
592
741
  details: { status }
@@ -603,7 +752,7 @@ var OmniHilbrasProvider = class extends OpenAICompatibleProvider {
603
752
  * Run the full connect flow. Throws on authentication or transport failure
604
753
  * (use healthCheck() for a non-throwing probe).
605
754
  */
606
- async connect(registry) {
755
+ async connectAndRegister(registry) {
607
756
  const apiKey = resolveApiKey(this.config);
608
757
  const discovered = await this.listModels();
609
758
  const seen = /* @__PURE__ */ new Set();
@@ -827,7 +976,7 @@ async function testProvider(name, options = {}) {
827
976
  if (options.connect && provider instanceof OmniHilbrasProvider) {
828
977
  const registry = new ModelRegistry();
829
978
  try {
830
- const result = await provider.connect(registry);
979
+ const result = await provider.connectAndRegister(registry);
831
980
  printProviderStatus(result.status);
832
981
  console.log(` registered: ${result.registered}`);
833
982
  } catch (error) {