nexus-ai-pro 2.1.0 → 2.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.
Files changed (194) hide show
  1. package/API_STABILITY.md +38 -0
  2. package/CHANGELOG.md +145 -1
  3. package/README.md +13 -8
  4. package/dist/cache/memory-cache.d.ts +8 -0
  5. package/dist/cache/memory-cache.js +9 -0
  6. package/dist/cache/semantic-cache.d.ts +6 -3
  7. package/dist/cache/semantic-cache.js +5 -2
  8. package/dist/cli/args.js +3 -0
  9. package/dist/cli/db.d.ts +16 -4
  10. package/dist/cli/db.js +169 -22
  11. package/dist/cli/doctor.d.ts +17 -0
  12. package/dist/cli/doctor.js +53 -0
  13. package/dist/cli.js +12 -1
  14. package/dist/core/lifecycle.js +11 -6
  15. package/dist/core/nexus.d.ts +6 -0
  16. package/dist/core/nexus.js +103 -22
  17. package/dist/doctor/index.d.ts +90 -0
  18. package/dist/doctor/index.js +237 -0
  19. package/dist/graph/functional.d.ts +80 -7
  20. package/dist/graph/functional.js +138 -35
  21. package/dist/graph/graph.d.ts +15 -5
  22. package/dist/graph/graph.js +72 -20
  23. package/dist/images/evals.js +4 -2
  24. package/dist/operations/adapters.d.ts +52 -4
  25. package/dist/operations/adapters.js +194 -7
  26. package/dist/operations/runner.js +1 -0
  27. package/dist/operations/stats.js +8 -1
  28. package/dist/operations/store.d.ts +5 -1
  29. package/dist/operations/store.js +7 -0
  30. package/dist/ops/circuit-breaker.d.ts +52 -0
  31. package/dist/ops/circuit-breaker.js +4 -0
  32. package/dist/ops/circuit-store.d.ts +13 -2
  33. package/dist/ops/circuit-store.js +68 -0
  34. package/dist/ops/circuit-sync.d.ts +17 -0
  35. package/dist/ops/circuit-sync.js +86 -0
  36. package/dist/ops/rate-limiter.d.ts +2 -0
  37. package/dist/ops/rate-limiter.js +2 -0
  38. package/dist/postgres/circuits.d.ts +21 -4
  39. package/dist/postgres/circuits.js +65 -4
  40. package/dist/postgres/client.d.ts +0 -2
  41. package/dist/postgres/client.js +0 -4
  42. package/dist/postgres/evaluate.d.ts +16 -4
  43. package/dist/postgres/evaluate.js +17 -5
  44. package/dist/postgres/index.d.ts +32 -11
  45. package/dist/postgres/index.js +65 -31
  46. package/dist/postgres/migrations.d.ts +60 -0
  47. package/dist/postgres/migrations.js +121 -0
  48. package/dist/postgres/operations.d.ts +14 -3
  49. package/dist/postgres/operations.js +45 -15
  50. package/dist/postgres/prompts.d.ts +11 -2
  51. package/dist/postgres/prompts.js +14 -3
  52. package/dist/postgres/rollups.d.ts +35 -0
  53. package/dist/postgres/rollups.js +129 -0
  54. package/dist/postgres/store.d.ts +11 -2
  55. package/dist/postgres/store.js +14 -3
  56. package/dist/postgres/traces.d.ts +13 -3
  57. package/dist/postgres/traces.js +25 -3
  58. package/dist/postgres/vectors.d.ts +11 -2
  59. package/dist/postgres/vectors.js +14 -3
  60. package/dist/router/auto-router.d.ts +34 -7
  61. package/dist/router/auto-router.js +201 -68
  62. package/dist/server/assistant.d.ts +4 -0
  63. package/dist/server/assistant.js +8 -0
  64. package/dist/server/errors.d.ts +12 -0
  65. package/dist/server/errors.js +7 -0
  66. package/dist/server/index.d.ts +1 -1
  67. package/dist/server/index.js +1 -1
  68. package/dist/server/runs.d.ts +24 -1
  69. package/dist/server/runs.js +90 -14
  70. package/dist/sqlite/index.d.ts +16 -3
  71. package/dist/sqlite/index.js +16 -3
  72. package/dist/sqlite/migrations.d.ts +26 -0
  73. package/dist/sqlite/migrations.js +63 -0
  74. package/dist/sqlite/operations.d.ts +14 -3
  75. package/dist/sqlite/operations.js +42 -15
  76. package/dist/sqlite/store.d.ts +9 -2
  77. package/dist/sqlite/store.js +13 -3
  78. package/dist/sqlite/vectors.d.ts +11 -2
  79. package/dist/sqlite/vectors.js +13 -2
  80. package/dist/store/tenant.d.ts +12 -0
  81. package/dist/store/tenant.js +29 -0
  82. package/dist/tenancy/index.d.ts +113 -0
  83. package/dist/tenancy/index.js +233 -0
  84. package/dist/tracing/instrument.js +2 -0
  85. package/dist/types/config.d.ts +24 -2
  86. package/dist/types/graph.d.ts +11 -1
  87. package/dist/types/messages.d.ts +5 -0
  88. package/dist/types/operations.d.ts +6 -0
  89. package/dist/types/response.d.ts +20 -0
  90. package/dist/types/server.d.ts +10 -1
  91. package/dist/utils/schema-migrations.d.ts +97 -0
  92. package/dist/utils/schema-migrations.js +91 -0
  93. package/dist/utils/signals.d.ts +17 -0
  94. package/dist/utils/signals.js +26 -0
  95. package/dist/utils/tenant.d.ts +14 -0
  96. package/dist/utils/tenant.js +16 -0
  97. package/dist/version.d.ts +1 -1
  98. package/dist/version.js +1 -1
  99. package/dist-cjs/cache/memory-cache.d.ts +8 -0
  100. package/dist-cjs/cache/memory-cache.js +9 -0
  101. package/dist-cjs/cache/semantic-cache.d.ts +6 -3
  102. package/dist-cjs/cache/semantic-cache.js +5 -2
  103. package/dist-cjs/cli/args.js +3 -0
  104. package/dist-cjs/cli/db.d.ts +16 -4
  105. package/dist-cjs/cli/db.js +200 -20
  106. package/dist-cjs/cli/doctor.d.ts +17 -0
  107. package/dist-cjs/cli/doctor.js +56 -0
  108. package/dist-cjs/core/lifecycle.js +11 -6
  109. package/dist-cjs/core/nexus.d.ts +6 -0
  110. package/dist-cjs/core/nexus.js +103 -22
  111. package/dist-cjs/doctor/index.d.ts +90 -0
  112. package/dist-cjs/doctor/index.js +276 -0
  113. package/dist-cjs/graph/functional.d.ts +80 -7
  114. package/dist-cjs/graph/functional.js +137 -34
  115. package/dist-cjs/graph/graph.d.ts +15 -5
  116. package/dist-cjs/graph/graph.js +72 -20
  117. package/dist-cjs/images/evals.js +4 -2
  118. package/dist-cjs/operations/adapters.d.ts +52 -4
  119. package/dist-cjs/operations/adapters.js +193 -6
  120. package/dist-cjs/operations/runner.js +1 -0
  121. package/dist-cjs/operations/stats.js +8 -1
  122. package/dist-cjs/operations/store.d.ts +5 -1
  123. package/dist-cjs/operations/store.js +7 -0
  124. package/dist-cjs/ops/circuit-breaker.d.ts +52 -0
  125. package/dist-cjs/ops/circuit-breaker.js +4 -0
  126. package/dist-cjs/ops/circuit-store.d.ts +13 -2
  127. package/dist-cjs/ops/circuit-store.js +68 -0
  128. package/dist-cjs/ops/circuit-sync.d.ts +17 -0
  129. package/dist-cjs/ops/circuit-sync.js +87 -0
  130. package/dist-cjs/ops/rate-limiter.d.ts +2 -0
  131. package/dist-cjs/ops/rate-limiter.js +2 -0
  132. package/dist-cjs/postgres/circuits.d.ts +21 -4
  133. package/dist-cjs/postgres/circuits.js +98 -3
  134. package/dist-cjs/postgres/client.d.ts +0 -2
  135. package/dist-cjs/postgres/client.js +0 -5
  136. package/dist-cjs/postgres/evaluate.d.ts +16 -4
  137. package/dist-cjs/postgres/evaluate.js +50 -4
  138. package/dist-cjs/postgres/index.d.ts +32 -11
  139. package/dist-cjs/postgres/index.js +69 -20
  140. package/dist-cjs/postgres/migrations.d.ts +60 -0
  141. package/dist-cjs/postgres/migrations.js +129 -0
  142. package/dist-cjs/postgres/operations.d.ts +14 -3
  143. package/dist-cjs/postgres/operations.js +78 -14
  144. package/dist-cjs/postgres/prompts.d.ts +11 -2
  145. package/dist-cjs/postgres/prompts.js +47 -2
  146. package/dist-cjs/postgres/rollups.d.ts +35 -0
  147. package/dist-cjs/postgres/rollups.js +168 -0
  148. package/dist-cjs/postgres/store.d.ts +11 -2
  149. package/dist-cjs/postgres/store.js +47 -2
  150. package/dist-cjs/postgres/traces.d.ts +13 -3
  151. package/dist-cjs/postgres/traces.js +58 -2
  152. package/dist-cjs/postgres/vectors.d.ts +11 -2
  153. package/dist-cjs/postgres/vectors.js +47 -2
  154. package/dist-cjs/router/auto-router.d.ts +34 -7
  155. package/dist-cjs/router/auto-router.js +201 -68
  156. package/dist-cjs/server/assistant.d.ts +4 -0
  157. package/dist-cjs/server/assistant.js +8 -0
  158. package/dist-cjs/server/errors.d.ts +12 -0
  159. package/dist-cjs/server/errors.js +9 -1
  160. package/dist-cjs/server/index.d.ts +1 -1
  161. package/dist-cjs/server/index.js +2 -1
  162. package/dist-cjs/server/runs.d.ts +24 -1
  163. package/dist-cjs/server/runs.js +89 -13
  164. package/dist-cjs/sqlite/index.d.ts +16 -3
  165. package/dist-cjs/sqlite/index.js +31 -10
  166. package/dist-cjs/sqlite/migrations.d.ts +26 -0
  167. package/dist-cjs/sqlite/migrations.js +71 -0
  168. package/dist-cjs/sqlite/operations.d.ts +14 -3
  169. package/dist-cjs/sqlite/operations.js +76 -15
  170. package/dist-cjs/sqlite/store.d.ts +9 -2
  171. package/dist-cjs/sqlite/store.js +47 -3
  172. package/dist-cjs/sqlite/vectors.d.ts +11 -2
  173. package/dist-cjs/sqlite/vectors.js +47 -2
  174. package/dist-cjs/store/tenant.d.ts +12 -0
  175. package/dist-cjs/store/tenant.js +33 -0
  176. package/dist-cjs/tenancy/index.d.ts +113 -0
  177. package/dist-cjs/tenancy/index.js +250 -0
  178. package/dist-cjs/tracing/instrument.js +2 -0
  179. package/dist-cjs/types/config.d.ts +24 -2
  180. package/dist-cjs/types/graph.d.ts +11 -1
  181. package/dist-cjs/types/messages.d.ts +5 -0
  182. package/dist-cjs/types/operations.d.ts +6 -0
  183. package/dist-cjs/types/response.d.ts +20 -0
  184. package/dist-cjs/types/server.d.ts +10 -1
  185. package/dist-cjs/utils/schema-migrations.d.ts +97 -0
  186. package/dist-cjs/utils/schema-migrations.js +102 -0
  187. package/dist-cjs/utils/signals.d.ts +17 -0
  188. package/dist-cjs/utils/signals.js +29 -0
  189. package/dist-cjs/utils/tenant.d.ts +14 -0
  190. package/dist-cjs/utils/tenant.js +22 -0
  191. package/dist-cjs/version.d.ts +1 -1
  192. package/dist-cjs/version.js +1 -1
  193. package/llms.txt +4 -3
  194. package/package.json +71 -2
@@ -1,5 +1,7 @@
1
1
  import type { Store, StoreIndexOptions, StoreItem, StoreNamespace, StorePutOptions, StoreSearchOptions } from '../types/store.js';
2
+ import type { MigrationResult, SchemaMigration } from '../utils/schema-migrations.js';
2
3
  import { type PostgresLikeClient } from './client.js';
4
+ import type { PostgresMigrateOptions } from './migrations.js';
3
5
  /** Options for the Postgres store. */
4
6
  export interface PostgresStoreOptions {
5
7
  /** Table name, optionally schema-qualified. Defaults to `nexus_store`. */
@@ -15,6 +17,10 @@ export interface PostgresStoreOptions {
15
17
  /** Replaces the system clock, for tests. */
16
18
  now?: () => Date;
17
19
  }
20
+ /**
21
+ * The versioned schema, which `migrate()` and `nexus db migrate` apply. Version 1 is the 2.0 schema.
22
+ */
23
+ export declare function storeMigrations(options?: Pick<PostgresStoreOptions, 'table' | 'vectorDimensions'>): SchemaMigration[];
18
24
  /** The schema, as statements. With `vectorDimensions`, pgvector's extension is created too. */
19
25
  export declare function storeMigration(options?: Pick<PostgresStoreOptions, 'table' | 'vectorDimensions'>): string[];
20
26
  /**
@@ -31,8 +37,11 @@ export declare class PostgresStore implements Store {
31
37
  private readonly table;
32
38
  private readonly now;
33
39
  constructor(client: PostgresLikeClient, options?: PostgresStoreOptions);
34
- /** Creates the table, indexes, and pgvector extension if configured. Never runs implicitly. */
35
- migrate(): Promise<void>;
40
+ /**
41
+ * Applies this store's pending migrations, recorded in `nexus_schema_migrations`, under a lock.
42
+ * Never runs implicitly. Safe on a database an older release created: what exists is kept.
43
+ */
44
+ migrate(options?: PostgresMigrateOptions): Promise<MigrationResult>;
36
45
  /** Stores an item, keeping its original `createdAt` when it replaces one. */
37
46
  put<V>(namespace: StoreNamespace, key: string, value: V, options?: StorePutOptions): Promise<void>;
38
47
  /** Reads an item, or `undefined` when it does not exist or has expired. */
@@ -1,8 +1,18 @@
1
1
  import { cosine, textOf } from '../store/helpers.js';
2
- import { fromJson, quoteDerived, quoteTable, runStatements } from './client.js';
2
+ import { fromJson, quoteDerived, quoteTable } from './client.js';
3
3
  import { jsonFieldEquals, likePrefix, SqlParams } from './sql.js';
4
4
  const DEFAULT_TABLE = 'nexus_store';
5
5
  const SEPARATOR = '\u001f';
6
+ export function storeMigrations(options = {}) {
7
+ return [
8
+ {
9
+ component: `store:${options.table ?? DEFAULT_TABLE}`,
10
+ version: 1,
11
+ name: 'create the store table',
12
+ statements: storeMigration(options),
13
+ },
14
+ ];
15
+ }
6
16
  export function storeMigration(options = {}) {
7
17
  const name = options.table ?? DEFAULT_TABLE;
8
18
  const table = quoteTable(name);
@@ -38,8 +48,9 @@ export class PostgresStore {
38
48
  this.table = quoteTable(options.table ?? DEFAULT_TABLE);
39
49
  this.now = options.now ?? (() => new Date());
40
50
  }
41
- async migrate() {
42
- await runStatements(this.client, storeMigration(this.options));
51
+ async migrate(options = {}) {
52
+ const { applyPostgresMigrations } = await import('./migrations.js');
53
+ return applyPostgresMigrations(this.client, storeMigrations(this.options), options);
43
54
  }
44
55
  async put(namespace, key, value, options = {}) {
45
56
  if (!key.trim())
@@ -1,12 +1,19 @@
1
1
  import type { Run, RunFeedback, RunQuery, RunTree, TraceStore } from '../types/tracing.js';
2
+ import type { MigrationResult, SchemaMigration } from '../utils/schema-migrations.js';
2
3
  import { type PostgresLikeClient } from './client.js';
4
+ import type { PostgresMigrateOptions } from './migrations.js';
3
5
  /** Options for the Postgres trace store. */
4
6
  export interface PostgresTraceStoreOptions {
5
7
  /** Table name, optionally schema-qualified. Defaults to `nexus_runs`. */
6
8
  table?: string;
7
9
  }
8
- /** The schema, as statements. */
10
+ /** The schema, as statements: every migration's, in order. */
9
11
  export declare function traceStoreMigration(options?: PostgresTraceStoreOptions): string[];
12
+ /**
13
+ * The versioned schema, which `migrate()` and `nexus db migrate` apply. Version 1 is the 2.0 table;
14
+ * version 2 indexes runs by `metadata.tenantId`, which a tenant-scoped trace store filters on.
15
+ */
16
+ export declare function traceStoreMigrations(options?: PostgresTraceStoreOptions): SchemaMigration[];
10
17
  /**
11
18
  * Traces in Postgres.
12
19
  *
@@ -20,8 +27,11 @@ export declare class PostgresTraceStore implements TraceStore {
20
27
  private readonly options;
21
28
  private readonly table;
22
29
  constructor(client: PostgresLikeClient, options?: PostgresTraceStoreOptions);
23
- /** Creates the table and indexes if they do not exist. Never runs implicitly. */
24
- migrate(): Promise<void>;
30
+ /**
31
+ * Applies this store's pending migrations, recorded in `nexus_schema_migrations`, under a lock.
32
+ * Never runs implicitly. Safe on a database an older release created: what exists is kept.
33
+ */
34
+ migrate(options?: PostgresMigrateOptions): Promise<MigrationResult>;
25
35
  /** Stores a run, replacing an earlier version with the same id. */
26
36
  save(run: Run): Promise<void>;
27
37
  /** Reads a run. */
@@ -1,10 +1,27 @@
1
1
  import { assembleTree } from '../tracing/query.js';
2
- import { fromJson, quoteDerived, quoteTable, runStatements } from './client.js';
2
+ import { fromJson, quoteDerived, quoteTable } from './client.js';
3
3
  import { jsonFieldEquals, SqlParams } from './sql.js';
4
4
  const DEFAULT_TABLE = 'nexus_runs';
5
5
  export function traceStoreMigration(options = {}) {
6
+ return traceStoreMigrations(options).flatMap((migration) => [...migration.statements]);
7
+ }
8
+ export function traceStoreMigrations(options = {}) {
6
9
  const name = options.table ?? DEFAULT_TABLE;
7
10
  const table = quoteTable(name);
11
+ const component = `traces:${name}`;
12
+ return [
13
+ { component, version: 1, name: 'create the runs table', statements: createStatements(name, table) },
14
+ {
15
+ component,
16
+ version: 2,
17
+ name: 'index runs by tenant',
18
+ statements: [
19
+ `CREATE INDEX IF NOT EXISTS ${quoteDerived(name, 'tenant_started')} ON ${table} ((doc #>> '{metadata,tenantId}'), started_at DESC)`,
20
+ ],
21
+ },
22
+ ];
23
+ }
24
+ function createStatements(name, table) {
8
25
  return [
9
26
  `CREATE TABLE IF NOT EXISTS ${table} (
10
27
  id text PRIMARY KEY,
@@ -34,8 +51,9 @@ export class PostgresTraceStore {
34
51
  this.options = options;
35
52
  this.table = quoteTable(options.table ?? DEFAULT_TABLE);
36
53
  }
37
- async migrate() {
38
- await runStatements(this.client, traceStoreMigration(this.options));
54
+ async migrate(options = {}) {
55
+ const { applyPostgresMigrations } = await import('./migrations.js');
56
+ return applyPostgresMigrations(this.client, traceStoreMigrations(this.options), options);
39
57
  }
40
58
  async save(run) {
41
59
  await this.client.query(`INSERT INTO ${this.table} (id, trace_id, parent_id, name, kind, status, started_at, latency_ms, cost, model, provider, doc)
@@ -93,6 +111,10 @@ export class PostgresTraceStore {
93
111
  where.push(`EXISTS (SELECT 1 FROM jsonb_array_elements(coalesce(doc -> 'feedback', '[]'::jsonb)) AS item WHERE item ->> 'key' = ${params.add(query.feedbackKey)})`);
94
112
  }
95
113
  for (const [field, expected] of Object.entries(query.metadata ?? {})) {
114
+ if (field === 'tenantId' && typeof expected === 'string') {
115
+ where.push(`doc #>> '{metadata,tenantId}' = ${params.add(expected)} AND jsonb_typeof(doc #> '{metadata,tenantId}') = 'string'`);
116
+ continue;
117
+ }
96
118
  where.push(jsonFieldEquals(`(doc -> 'metadata')`, field, expected, params));
97
119
  }
98
120
  const { rows } = await this.client.query(`SELECT doc::text AS doc FROM ${this.table}
@@ -1,5 +1,7 @@
1
1
  import { type EmbeddingProvider, type VectorDocument, type VectorSearchOptions, type VectorSearchResult, type VectorStore } from '../hallucination/retrieval.js';
2
+ import type { MigrationResult, SchemaMigration } from '../utils/schema-migrations.js';
2
3
  import { type PostgresLikeClient } from './client.js';
4
+ import type { PostgresMigrateOptions } from './migrations.js';
3
5
  /** Options for the pgvector store. */
4
6
  export interface PostgresVectorStoreOptions {
5
7
  /** Width of every vector. Must match the embedding function's output. */
@@ -15,6 +17,10 @@ export interface PostgresVectorStoreOptions {
15
17
  */
16
18
  index?: 'hnsw' | 'none';
17
19
  }
20
+ /**
21
+ * The versioned schema, which `migrate()` and `nexus db migrate` apply. Version 1 is the 2.0 schema.
22
+ */
23
+ export declare function vectorStoreMigrations(options: Omit<PostgresVectorStoreOptions, 'embed'>): SchemaMigration[];
18
24
  /** The schema, as statements: the pgvector extension, the table, and its index. */
19
25
  export declare function vectorStoreMigration(options: Omit<PostgresVectorStoreOptions, 'embed'>): string[];
20
26
  /**
@@ -30,8 +36,11 @@ export declare class PostgresVectorStore implements VectorStore {
30
36
  private readonly table;
31
37
  private readonly embed;
32
38
  constructor(client: PostgresLikeClient, options: PostgresVectorStoreOptions);
33
- /** Creates the extension, table, and index. Never runs implicitly. */
34
- migrate(): Promise<void>;
39
+ /**
40
+ * Applies this store's pending migrations, recorded in `nexus_schema_migrations`, under a lock.
41
+ * Never runs implicitly. Safe on a database an older release created: what exists is kept.
42
+ */
43
+ migrate(options?: PostgresMigrateOptions): Promise<MigrationResult>;
35
44
  /** Adds chunks, or replaces those whose id exists, embedding those without a vector in one batch. */
36
45
  add(documents: VectorDocument[]): Promise<void>;
37
46
  /** The chunks most similar to a query, best first. */
@@ -1,7 +1,17 @@
1
1
  import { createHashEmbeddings, } from '../hallucination/retrieval.js';
2
- import { fromJson, quoteDerived, quoteTable, runStatements } from './client.js';
2
+ import { fromJson, quoteDerived, quoteTable } from './client.js';
3
3
  import { SqlParams, textArray } from './sql.js';
4
4
  const DEFAULT_TABLE = 'nexus_vectors';
5
+ export function vectorStoreMigrations(options) {
6
+ return [
7
+ {
8
+ component: `vectors:${options.table ?? DEFAULT_TABLE}`,
9
+ version: 1,
10
+ name: 'create the vectors table',
11
+ statements: vectorStoreMigration(options),
12
+ },
13
+ ];
14
+ }
5
15
  export function vectorStoreMigration(options) {
6
16
  const dimensions = options.dimensions;
7
17
  if (!(Number.isInteger(dimensions) && dimensions > 0))
@@ -38,8 +48,9 @@ export class PostgresVectorStore {
38
48
  this.table = quoteTable(options.table ?? DEFAULT_TABLE);
39
49
  this.embed = options.embed ?? ((texts) => createHashEmbeddings(texts, options.dimensions));
40
50
  }
41
- async migrate() {
42
- await runStatements(this.client, vectorStoreMigration(this.options));
51
+ async migrate(options = {}) {
52
+ const { applyPostgresMigrations } = await import('./migrations.js');
53
+ return applyPostgresMigrations(this.client, vectorStoreMigrations(this.options), options);
43
54
  }
44
55
  async add(documents) {
45
56
  if (documents.length === 0)
@@ -1,14 +1,37 @@
1
1
  import type { RouterContext, RouteDecision } from './types.js';
2
+ /**
3
+ * Ranks configured providers' models for a request routed as `auto`.
4
+ *
5
+ * Everything that depends only on configuration — which models are candidates, which pass the
6
+ * allow and deny lists and the capability requirements, and how each scores for the strategy — is
7
+ * computed once into a plan and kept until the configuration changes. A request pays only for what
8
+ * can differ between requests: which circuits are open, and provider health.
9
+ */
2
10
  export declare class AutoRouter {
11
+ private readonly plans;
12
+ private readonly patterns;
13
+ private readonly registryIds;
14
+ /** Routes one request: the best candidate first, every other as a fallback in order. */
3
15
  route(ctx: RouterContext): RouteDecision;
16
+ /** Plans cached by this router, for tests and diagnostics. */
17
+ get planCount(): number;
18
+ /** The plan for a request's configuration, built on first use and kept until the configuration changes. */
19
+ private plan;
4
20
  /**
5
- * Drops providers whose circuit is open.
6
- *
7
- * If that would leave nothing to route to, the original list is kept instead. Every circuit being
8
- * open usually means a shared dependency is down rather than every provider individually, and
9
- * attempting one call is strictly better than failing the request without trying anything.
21
+ * What a plan depends on, normalized into a string: the routing settings, the model settings, and
22
+ * the configured providers. A registry is identified by its object, so replacing
23
+ * `models.registry` builds a new plan; changing one in place does not.
10
24
  */
11
- private withoutOpenCircuits;
25
+ private planKey;
26
+ private registryId;
27
+ private buildPlan;
28
+ /**
29
+ * The plan's candidates in order for this request. Providers whose circuit is open are left out,
30
+ * unless that would leave nothing: every circuit open usually means a shared dependency is down,
31
+ * and one attempt is strictly better than failing without trying. Health moves a whole provider
32
+ * up or down, so the per-provider lists are merged by their adjusted scores rather than re-sorted.
33
+ */
34
+ private rank;
12
35
  private getCandidates;
13
36
  /**
14
37
  * Scores a model for a strategy. For privacy, a model counts as local when its name says so or when
@@ -16,15 +39,19 @@ export declare class AutoRouter {
16
39
  * is preferred without renaming its models.
17
40
  */
18
41
  private score;
42
+ private capabilitiesOf;
19
43
  private localFallbackCapabilities;
20
44
  private costScore;
21
45
  private speedScore;
22
46
  private qualityScore;
23
47
  private preferencesForProvider;
24
48
  private isAllowed;
49
+ /** Whether a model meets the routing requirements: the boolean ones as two bitmask tests, the rest by value. */
25
50
  private matchesRequirements;
51
+ /** Whether a model name matches an allow or deny entry, with `*` as a wildcard. Patterns are compiled once. */
26
52
  private matchesModelPattern;
27
53
  private isProviderRunnable;
28
- private healthScore;
54
+ /** Each provider's health adjustment for this request, or nothing when health does not apply. */
55
+ private healthOffsets;
29
56
  }
30
57
  export declare function routeDirectModel(model: string, ctx: RouterContext): RouteDecision;
@@ -1,36 +1,138 @@
1
1
  import { getModelRegistry, listModelsForProvider, resolveModel } from '../models/registry.js';
2
+ const PLAN_LIMIT = 16;
3
+ const BOOLEAN_CAPABILITIES = ['streaming', 'toolCalling', 'structuredOutputs', 'jsonMode', 'reasoning'];
2
4
  export class AutoRouter {
5
+ plans = new Map();
6
+ patterns = new Map();
7
+ registryIds = new WeakMap();
3
8
  route(ctx) {
4
- const strategy = ctx.config.routing?.strategy || 'quality';
5
- const candidates = this.withoutOpenCircuits(this.getCandidates(ctx), ctx).map((candidate) => ({
6
- ...candidate,
7
- score: this.score(candidate.model, strategy, ctx.config, ctx.providers.get(candidate.providerName)?.info?.isLocal === true) +
8
- candidate.weight +
9
- this.healthScore(candidate.providerName, ctx),
10
- reason: `auto route by ${strategy} score`,
11
- }));
12
- candidates.sort((a, b) => b.score - a.score);
13
- const winner = candidates[0];
9
+ const plan = this.plan(ctx);
10
+ const ranked = this.rank(plan, ctx);
11
+ const winner = ranked[0];
14
12
  if (!winner) {
15
13
  throw new Error('No configured providers are available for routing');
16
14
  }
17
15
  return {
18
16
  providerName: winner.providerName,
19
17
  model: winner.model,
20
- reason: `${winner.reason}: ${winner.model}`,
21
- fallbacks: candidates.slice(1).map((c) => ({ providerName: c.providerName, model: c.model })),
18
+ reason: `auto route by ${plan.strategy} score: ${winner.model}`,
19
+ fallbacks: ranked.slice(1).map((candidate) => candidate.attempt),
22
20
  };
23
21
  }
24
- withoutOpenCircuits(candidates, ctx) {
25
- if (!ctx.openCircuits?.length)
26
- return candidates;
27
- const open = new Set(ctx.openCircuits);
28
- const remaining = candidates.filter((candidate) => !open.has(candidate.providerName));
29
- return remaining.length > 0 ? remaining : candidates;
22
+ get planCount() {
23
+ return this.plans.size;
24
+ }
25
+ plan(ctx) {
26
+ const key = this.planKey(ctx);
27
+ const cached = this.plans.get(key);
28
+ if (cached) {
29
+ this.plans.delete(key);
30
+ this.plans.set(key, cached);
31
+ return cached;
32
+ }
33
+ const plan = this.buildPlan(ctx);
34
+ this.plans.set(key, plan);
35
+ if (this.plans.size > PLAN_LIMIT)
36
+ this.plans.delete(this.plans.keys().next().value);
37
+ return plan;
38
+ }
39
+ planKey(ctx) {
40
+ const routing = ctx.config.routing;
41
+ const models = ctx.config.models;
42
+ const providers = [];
43
+ for (const [name, provider] of ctx.providers)
44
+ providers.push(`${name}${provider.info?.isLocal === true ? '+local' : ''}`);
45
+ return JSON.stringify([
46
+ routing?.strategy ?? 'quality',
47
+ routing?.candidateModels ?? null,
48
+ routing?.modelPreferences ?? null,
49
+ routing?.allowModels ?? null,
50
+ routing?.denyModels ?? null,
51
+ normalizedRequirements(routing?.requiredCapabilities),
52
+ models?.includeDefaults ?? null,
53
+ models?.aliases ?? null,
54
+ models?.registry ? this.registryId(models.registry) : 0,
55
+ providers,
56
+ ]);
30
57
  }
31
- getCandidates(ctx) {
58
+ registryId(registry) {
59
+ let id = this.registryIds.get(registry);
60
+ if (id === undefined) {
61
+ id = this.registryIds.get(this.registryIds) ?? 1;
62
+ this.registryIds.set(registry, id);
63
+ this.registryIds.set(this.registryIds, id + 1);
64
+ }
65
+ return id;
66
+ }
67
+ buildPlan(ctx) {
68
+ const strategy = ctx.config.routing?.strategy || 'quality';
69
+ const registry = getModelRegistry(ctx.config);
70
+ const requirements = compileRequirements(ctx.config.routing?.requiredCapabilities);
71
+ const candidates = this.getCandidates(ctx, registry, requirements);
72
+ const planned = candidates.map((candidate, index) => ({
73
+ providerName: candidate.providerName,
74
+ model: candidate.model,
75
+ base: this.score(candidate.model, strategy, registry, ctx.providers.get(candidate.providerName)?.info?.isLocal === true) + candidate.weight,
76
+ index,
77
+ attempt: Object.freeze({ providerName: candidate.providerName, model: candidate.model }),
78
+ }));
79
+ const ranked = [...planned].sort(byScore);
80
+ const byProvider = new Map();
81
+ for (const candidate of ranked) {
82
+ const list = byProvider.get(candidate.providerName);
83
+ if (list)
84
+ list.push(candidate);
85
+ else
86
+ byProvider.set(candidate.providerName, [candidate]);
87
+ }
88
+ return { strategy, ranked, byProvider };
89
+ }
90
+ rank(plan, ctx) {
91
+ let excluded;
92
+ if (ctx.openCircuits?.length) {
93
+ const open = new Set(ctx.openCircuits);
94
+ if (plan.ranked.some((candidate) => !open.has(candidate.providerName)))
95
+ excluded = open;
96
+ }
97
+ const offsets = this.healthOffsets(ctx);
98
+ if (!offsets) {
99
+ return excluded ? plan.ranked.filter((candidate) => !excluded.has(candidate.providerName)) : plan.ranked;
100
+ }
101
+ const lists = [];
102
+ let total = 0;
103
+ for (const [providerName, list] of plan.byProvider) {
104
+ if (excluded?.has(providerName))
105
+ continue;
106
+ lists.push({ list, offset: offsets.get(providerName) ?? 0, at: 0 });
107
+ total += list.length;
108
+ }
109
+ const merged = new Array(total);
110
+ for (let position = 0; position < total; position += 1) {
111
+ let best = -1;
112
+ let bestScore = Number.NEGATIVE_INFINITY;
113
+ let bestIndex = Number.POSITIVE_INFINITY;
114
+ for (let which = 0; which < lists.length; which += 1) {
115
+ const head = lists[which];
116
+ const candidate = head.list[head.at];
117
+ if (!candidate)
118
+ continue;
119
+ const score = candidate.base + head.offset;
120
+ if (score > bestScore || (score === bestScore && candidate.index < bestIndex)) {
121
+ best = which;
122
+ bestScore = score;
123
+ bestIndex = candidate.index;
124
+ }
125
+ }
126
+ const chosen = lists[best];
127
+ merged[position] = chosen.list[chosen.at];
128
+ chosen.at += 1;
129
+ }
130
+ return merged;
131
+ }
132
+ getCandidates(ctx, registry, requirements) {
32
133
  const candidates = [];
33
134
  const configuredModels = ctx.config.routing?.candidateModels;
135
+ const passes = (model) => !requirements || this.matchesRequirements(model, registry, requirements);
34
136
  if (configuredModels?.length) {
35
137
  for (const model of configuredModels) {
36
138
  const resolved = resolveModel(model, ctx.config);
@@ -39,23 +141,19 @@ export class AutoRouter {
39
141
  candidates.push({ providerName, model: resolved.model, weight: 0 });
40
142
  }
41
143
  }
42
- return candidates.filter((candidate) => this.matchesRequirements(candidate.model, ctx.config));
144
+ return candidates.filter((candidate) => passes(candidate.model));
43
145
  }
44
146
  const preferences = ctx.config.routing?.modelPreferences?.[ctx.config.routing?.strategy || 'quality'];
45
147
  for (const providerName of ctx.providers.keys()) {
46
- const preferred = this.preferencesForProvider(providerName, preferences, ctx.config);
148
+ const preferred = this.preferencesForProvider(providerName, preferences, ctx.config, registry);
47
149
  if (preferred.length) {
48
150
  candidates.push(...preferred);
49
151
  }
50
152
  }
51
- return candidates.filter((candidate) => {
52
- return this.isAllowed(candidate.model, ctx.config) && this.matchesRequirements(candidate.model, ctx.config);
53
- });
153
+ return candidates.filter((candidate) => this.isAllowed(candidate.model, ctx.config) && passes(candidate.model));
54
154
  }
55
- score(model, strategy, config, providerIsLocal = false) {
56
- const registry = getModelRegistry(config);
57
- const normalized = model.startsWith('ollama/') ? model.slice(7) : model;
58
- const caps = registry[model] || registry[normalized] || this.localFallbackCapabilities(model);
155
+ score(model, strategy, registry, providerIsLocal = false) {
156
+ const caps = this.capabilitiesOf(model, registry);
59
157
  if (strategy === 'privacy')
60
158
  return providerIsLocal || isLocalModel(model) ? 100 : 30;
61
159
  if (strategy === 'speed')
@@ -64,6 +162,10 @@ export class AutoRouter {
64
162
  return this.costScore(model, caps);
65
163
  return this.qualityScore(model, caps);
66
164
  }
165
+ capabilitiesOf(model, registry) {
166
+ const normalized = model.startsWith('ollama/') ? model.slice(7) : model;
167
+ return registry[model] || registry[normalized] || this.localFallbackCapabilities(model);
168
+ }
67
169
  localFallbackCapabilities(model) {
68
170
  return {
69
171
  inputModalities: ['text'],
@@ -109,7 +211,7 @@ export class AutoRouter {
109
211
  return 65;
110
212
  return Math.min(85, caps.maxContextTokens / 2000);
111
213
  }
112
- preferencesForProvider(providerName, preferences, config) {
214
+ preferencesForProvider(providerName, preferences, config, registry) {
113
215
  if (preferences?.length) {
114
216
  return preferences
115
217
  .map((preference) => typeof preference === 'string'
@@ -128,7 +230,7 @@ export class AutoRouter {
128
230
  const registryModels = listModelsForProvider(providerName, config);
129
231
  if (registryModels.length) {
130
232
  return registryModels
131
- .filter((model) => this.isProviderRunnable(providerName, model, config))
233
+ .filter((model) => this.isProviderRunnable(providerName, model, registry))
132
234
  .map((model) => ({ providerName, model, weight: 0 }));
133
235
  }
134
236
  if (providerName === 'ollama') {
@@ -169,52 +271,44 @@ export class AutoRouter {
169
271
  return false;
170
272
  return !deny.some((entry) => this.matchesModelPattern(model, entry));
171
273
  }
172
- matchesRequirements(model, config) {
173
- const requirements = config.routing?.requiredCapabilities;
174
- if (!requirements)
175
- return true;
176
- const registry = getModelRegistry(config);
177
- const normalized = model.startsWith('ollama/') ? model.slice(7) : model;
178
- const caps = registry[model] || registry[normalized] || this.localFallbackCapabilities(model);
179
- if (requirements.streaming !== undefined && caps.streaming !== requirements.streaming)
180
- return false;
181
- if (requirements.toolCalling !== undefined && caps.toolCalling !== requirements.toolCalling)
182
- return false;
183
- if (requirements.structuredOutputs !== undefined && caps.structuredOutputs !== requirements.structuredOutputs)
274
+ matchesRequirements(model, registry, requirements) {
275
+ const caps = this.capabilitiesOf(model, registry);
276
+ const { mustBe, mustNotBe } = capabilityMasks(caps);
277
+ if ((mustBe & requirements.mustBe) !== requirements.mustBe)
184
278
  return false;
185
- if (requirements.jsonMode !== undefined && caps.jsonMode !== requirements.jsonMode)
279
+ if ((mustNotBe & requirements.mustNotBe) !== requirements.mustNotBe)
186
280
  return false;
187
- if (requirements.reasoning !== undefined && Boolean(caps.reasoning) !== requirements.reasoning)
281
+ const source = requirements.source;
282
+ if (source.minContextTokens && caps.maxContextTokens < source.minContextTokens)
188
283
  return false;
189
- if (requirements.minContextTokens && caps.maxContextTokens < requirements.minContextTokens)
284
+ if (source.maxInputCostPer1k !== undefined && caps.costPer1kInput > source.maxInputCostPer1k)
190
285
  return false;
191
- if (requirements.maxInputCostPer1k !== undefined && caps.costPer1kInput > requirements.maxInputCostPer1k)
286
+ if (source.maxOutputCostPer1k !== undefined && caps.costPer1kOutput > source.maxOutputCostPer1k)
192
287
  return false;
193
- if (requirements.maxOutputCostPer1k !== undefined && caps.costPer1kOutput > requirements.maxOutputCostPer1k)
288
+ if (source.statuses?.length && caps.status && !source.statuses.includes(caps.status))
194
289
  return false;
195
- if (requirements.statuses?.length && caps.status && !requirements.statuses.includes(caps.status))
290
+ if (source.inputModalities?.some((modality) => !caps.inputModalities.includes(modality)))
196
291
  return false;
197
- if (requirements.inputModalities?.some((modality) => !caps.inputModalities.includes(modality)))
198
- return false;
199
- if (requirements.outputModalities?.some((modality) => !caps.outputModalities.includes(modality)))
292
+ if (source.outputModalities?.some((modality) => !caps.outputModalities.includes(modality)))
200
293
  return false;
201
294
  return true;
202
295
  }
203
296
  matchesModelPattern(model, pattern) {
204
297
  if (pattern === model)
205
298
  return true;
206
- if (!pattern.includes('*'))
207
- return false;
208
- const regex = new RegExp(`^${pattern
209
- .split('*')
210
- .map((part) => {
211
- return part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
212
- })
213
- .join('.*')}$`);
214
- return regex.test(model);
299
+ let regex = this.patterns.get(pattern);
300
+ if (regex === undefined) {
301
+ regex = pattern.includes('*')
302
+ ? new RegExp(`^${pattern
303
+ .split('*')
304
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
305
+ .join('.*')}$`)
306
+ : null;
307
+ this.patterns.set(pattern, regex);
308
+ }
309
+ return regex ? regex.test(model) : false;
215
310
  }
216
- isProviderRunnable(providerName, model, config) {
217
- const registry = getModelRegistry(config);
311
+ isProviderRunnable(providerName, model, registry) {
218
312
  const caps = registry[model];
219
313
  if (!caps?.endpoints)
220
314
  return true;
@@ -226,15 +320,54 @@ export class AutoRouter {
226
320
  return caps.endpoints.includes('generateContent');
227
321
  return true;
228
322
  }
229
- healthScore(providerName, ctx) {
323
+ healthOffsets(ctx) {
230
324
  if (!ctx.config.health?.enabled || !ctx.health?.length)
231
- return 0;
232
- const snapshot = ctx.health.find((item) => item.providerName === providerName);
233
- if (!snapshot)
234
- return 0;
235
- return snapshot.healthy ? snapshot.score / 10 : -100;
325
+ return undefined;
326
+ const offsets = new Map();
327
+ for (const snapshot of ctx.health) {
328
+ if (offsets.has(snapshot.providerName))
329
+ continue;
330
+ offsets.set(snapshot.providerName, snapshot.healthy ? snapshot.score / 10 : -100);
331
+ }
332
+ return offsets;
236
333
  }
237
334
  }
335
+ function compileRequirements(requirements) {
336
+ if (!requirements)
337
+ return undefined;
338
+ let mustBe = 0;
339
+ let mustNotBe = 0;
340
+ BOOLEAN_CAPABILITIES.forEach((name, bit) => {
341
+ const wanted = requirements[name];
342
+ if (wanted === true)
343
+ mustBe |= 1 << bit;
344
+ else if (wanted === false)
345
+ mustNotBe |= 1 << bit;
346
+ });
347
+ return { mustBe, mustNotBe, source: requirements };
348
+ }
349
+ function capabilityMasks(caps) {
350
+ let mustBe = 0;
351
+ let mustNotBe = 0;
352
+ BOOLEAN_CAPABILITIES.forEach((name, bit) => {
353
+ const value = name === 'reasoning' ? Boolean(caps.reasoning) : caps[name];
354
+ if (value === true)
355
+ mustBe |= 1 << bit;
356
+ else if (value === false)
357
+ mustNotBe |= 1 << bit;
358
+ });
359
+ return { mustBe, mustNotBe };
360
+ }
361
+ function normalizedRequirements(requirements) {
362
+ if (!requirements)
363
+ return null;
364
+ return Object.keys(requirements)
365
+ .sort()
366
+ .map((key) => [key, requirements[key]]);
367
+ }
368
+ function byScore(a, b) {
369
+ return b.base - a.base || a.index - b.index;
370
+ }
238
371
  function isLocalModel(model) {
239
372
  return (model.startsWith('ollama/') ||
240
373
  model.startsWith('lmstudio/') ||