nexus-ai-pro 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/LICENSE +21 -0
- package/README.md +1249 -0
- package/assets/nexus-ai-pro-presentation.jpg +0 -0
- package/dist/agent/loop.d.ts +12 -0
- package/dist/agent/loop.js +89 -0
- package/dist/agent/tool.d.ts +16 -0
- package/dist/agent/tool.js +43 -0
- package/dist/cache/adapters.d.ts +42 -0
- package/dist/cache/adapters.js +76 -0
- package/dist/cache/memory-cache.d.ts +20 -0
- package/dist/cache/memory-cache.js +63 -0
- package/dist/cache/semantic-cache.d.ts +19 -0
- package/dist/cache/semantic-cache.js +71 -0
- package/dist/connectors/web.d.ts +8 -0
- package/dist/connectors/web.js +60 -0
- package/dist/core/nexus.d.ts +71 -0
- package/dist/core/nexus.js +477 -0
- package/dist/core/response-format.d.ts +8 -0
- package/dist/core/response-format.js +121 -0
- package/dist/core/secure-stream.d.ts +3 -0
- package/dist/core/secure-stream.js +41 -0
- package/dist/core/streaming.d.ts +4 -0
- package/dist/core/streaming.js +30 -0
- package/dist/embeddings/providers.d.ts +20 -0
- package/dist/embeddings/providers.js +60 -0
- package/dist/evals/metrics.d.ts +75 -0
- package/dist/evals/metrics.js +157 -0
- package/dist/evals/runner.d.ts +35 -0
- package/dist/evals/runner.js +47 -0
- package/dist/hallucination/consistency.d.ts +14 -0
- package/dist/hallucination/consistency.js +58 -0
- package/dist/hallucination/factual.d.ts +14 -0
- package/dist/hallucination/factual.js +35 -0
- package/dist/hallucination/knowledge-graph.d.ts +27 -0
- package/dist/hallucination/knowledge-graph.js +70 -0
- package/dist/hallucination/rag.d.ts +21 -0
- package/dist/hallucination/rag.js +46 -0
- package/dist/hallucination/retrieval.d.ts +24 -0
- package/dist/hallucination/retrieval.js +71 -0
- package/dist/hallucination/verification.d.ts +36 -0
- package/dist/hallucination/verification.js +110 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.js +53 -0
- package/dist/jobs/batch.d.ts +11 -0
- package/dist/jobs/batch.js +32 -0
- package/dist/jobs/durable-adapters.d.ts +48 -0
- package/dist/jobs/durable-adapters.js +50 -0
- package/dist/jobs/queue.d.ts +24 -0
- package/dist/jobs/queue.js +57 -0
- package/dist/models/registry.d.ts +15 -0
- package/dist/models/registry.js +43 -0
- package/dist/next/route-handler.d.ts +6 -0
- package/dist/next/route-handler.js +35 -0
- package/dist/ops/audit-logger.d.ts +6 -0
- package/dist/ops/audit-logger.js +19 -0
- package/dist/ops/health.d.ts +29 -0
- package/dist/ops/health.js +69 -0
- package/dist/ops/metrics.d.ts +53 -0
- package/dist/ops/metrics.js +122 -0
- package/dist/ops/otel-tracing.d.ts +20 -0
- package/dist/ops/otel-tracing.js +50 -0
- package/dist/ops/rate-limiter.d.ts +11 -0
- package/dist/ops/rate-limiter.js +33 -0
- package/dist/optimizer/budget.d.ts +19 -0
- package/dist/optimizer/budget.js +88 -0
- package/dist/optimizer/cost.d.ts +13 -0
- package/dist/optimizer/cost.js +31 -0
- package/dist/optimizer/densifier.d.ts +10 -0
- package/dist/optimizer/densifier.js +77 -0
- package/dist/optimizer/index.d.ts +12 -0
- package/dist/optimizer/index.js +59 -0
- package/dist/pipeline/pipeline.d.ts +14 -0
- package/dist/pipeline/pipeline.js +106 -0
- package/dist/pipeline/types.d.ts +46 -0
- package/dist/pipeline/types.js +1 -0
- package/dist/providers/anthropic.d.ts +15 -0
- package/dist/providers/anthropic.js +183 -0
- package/dist/providers/base.d.ts +19 -0
- package/dist/providers/base.js +75 -0
- package/dist/providers/cohere.d.ts +16 -0
- package/dist/providers/cohere.js +104 -0
- package/dist/providers/google.d.ts +22 -0
- package/dist/providers/google.js +226 -0
- package/dist/providers/groq.d.ts +15 -0
- package/dist/providers/groq.js +50 -0
- package/dist/providers/mistral.d.ts +15 -0
- package/dist/providers/mistral.js +50 -0
- package/dist/providers/ollama.d.ts +15 -0
- package/dist/providers/ollama.js +108 -0
- package/dist/providers/openai.d.ts +21 -0
- package/dist/providers/openai.js +304 -0
- package/dist/providers/openrouter.d.ts +14 -0
- package/dist/providers/openrouter.js +19 -0
- package/dist/rag/file-ingestion.d.ts +20 -0
- package/dist/rag/file-ingestion.js +55 -0
- package/dist/rag/ingestion.d.ts +21 -0
- package/dist/rag/ingestion.js +47 -0
- package/dist/router/auto-router.d.ts +17 -0
- package/dist/router/auto-router.js +228 -0
- package/dist/router/failover.d.ts +21 -0
- package/dist/router/failover.js +126 -0
- package/dist/router/index.d.ts +12 -0
- package/dist/router/index.js +31 -0
- package/dist/router/rules-router.d.ts +7 -0
- package/dist/router/rules-router.js +68 -0
- package/dist/router/types.d.ts +19 -0
- package/dist/router/types.js +1 -0
- package/dist/security/index.d.ts +31 -0
- package/dist/security/index.js +173 -0
- package/dist/security/injection-calibration.d.ts +17 -0
- package/dist/security/injection-calibration.js +48 -0
- package/dist/security/injection-detector.d.ts +8 -0
- package/dist/security/injection-detector.js +66 -0
- package/dist/security/input-guard.d.ts +7 -0
- package/dist/security/input-guard.js +109 -0
- package/dist/security/output-guard.d.ts +6 -0
- package/dist/security/output-guard.js +144 -0
- package/dist/security/pii-detector.d.ts +8 -0
- package/dist/security/pii-detector.js +66 -0
- package/dist/security/policies.d.ts +4 -0
- package/dist/security/policies.js +61 -0
- package/dist/security/prompt-hardening.d.ts +6 -0
- package/dist/security/prompt-hardening.js +18 -0
- package/dist/security/schema-validator.d.ts +234 -0
- package/dist/security/schema-validator.js +68 -0
- package/dist/security/semantic-injection-classifier.d.ts +19 -0
- package/dist/security/semantic-injection-classifier.js +75 -0
- package/dist/security/upload-scanner.d.ts +29 -0
- package/dist/security/upload-scanner.js +72 -0
- package/dist/testing/provider-conformance.d.ts +24 -0
- package/dist/testing/provider-conformance.js +97 -0
- package/dist/types/agent.d.ts +34 -0
- package/dist/types/agent.js +1 -0
- package/dist/types/config.d.ts +171 -0
- package/dist/types/config.js +1 -0
- package/dist/types/messages.d.ts +96 -0
- package/dist/types/messages.js +1 -0
- package/dist/types/optimizer.d.ts +30 -0
- package/dist/types/optimizer.js +1 -0
- package/dist/types/planning.d.ts +27 -0
- package/dist/types/planning.js +1 -0
- package/dist/types/providers.d.ts +39 -0
- package/dist/types/providers.js +479 -0
- package/dist/types/response.d.ts +55 -0
- package/dist/types/response.js +1 -0
- package/dist/types/security.d.ts +87 -0
- package/dist/types/security.js +1 -0
- package/dist/utils/ids.d.ts +1 -0
- package/dist/utils/ids.js +4 -0
- package/dist/utils/logger.d.ts +7 -0
- package/dist/utils/logger.js +17 -0
- package/dist/utils/tokenizer.d.ts +6 -0
- package/dist/utils/tokenizer.js +38 -0
- package/dist/workflow/chains.d.ts +53 -0
- package/dist/workflow/chains.js +117 -0
- package/dist/workflow/domain.d.ts +22 -0
- package/dist/workflow/domain.js +96 -0
- package/examples/agent.ts +69 -0
- package/examples/basic.ts +37 -0
- package/examples/bullmq-worker.ts +36 -0
- package/examples/classifier-calibration.ts +7 -0
- package/examples/domain-workflows.ts +38 -0
- package/examples/feature-flags.ts +65 -0
- package/examples/minimal.ts +45 -0
- package/examples/ocr-pdf-ingestion.ts +42 -0
- package/examples/optimizer.ts +43 -0
- package/examples/otel-express.ts +47 -0
- package/examples/otel-node-http.ts +46 -0
- package/examples/persisted-vector-store.ts +42 -0
- package/examples/security.ts +69 -0
- package/package.json +165 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { getModelRegistry, listModelsForProvider, resolveModel } from '../models/registry.js';
|
|
2
|
+
export class AutoRouter {
|
|
3
|
+
route(ctx) {
|
|
4
|
+
const strategy = ctx.config.routing?.strategy || 'quality';
|
|
5
|
+
const candidates = this.getCandidates(ctx).map((candidate) => ({
|
|
6
|
+
...candidate,
|
|
7
|
+
score: this.score(candidate.model, strategy, ctx.config) + candidate.weight + this.healthScore(candidate.providerName, ctx),
|
|
8
|
+
reason: `auto route by ${strategy} score`,
|
|
9
|
+
}));
|
|
10
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
11
|
+
const winner = candidates[0];
|
|
12
|
+
if (!winner) {
|
|
13
|
+
throw new Error('No configured providers are available for routing');
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
providerName: winner.providerName,
|
|
17
|
+
model: winner.model,
|
|
18
|
+
reason: `${winner.reason}: ${winner.model}`,
|
|
19
|
+
fallbacks: candidates.slice(1).map((c) => ({ providerName: c.providerName, model: c.model })),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
getCandidates(ctx) {
|
|
23
|
+
const candidates = [];
|
|
24
|
+
const configuredModels = ctx.config.routing?.candidateModels;
|
|
25
|
+
if (configuredModels?.length) {
|
|
26
|
+
for (const model of configuredModels) {
|
|
27
|
+
const resolved = resolveModel(model, ctx.config);
|
|
28
|
+
const providerName = resolved.providerName || model.split('/')[0];
|
|
29
|
+
if (ctx.providers.has(providerName) && this.isAllowed(resolved.model, ctx.config)) {
|
|
30
|
+
candidates.push({ providerName, model: resolved.model, weight: 0 });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return candidates.filter((candidate) => this.matchesRequirements(candidate.model, ctx.config));
|
|
34
|
+
}
|
|
35
|
+
const preferences = ctx.config.routing?.modelPreferences?.[ctx.config.routing?.strategy || 'quality'];
|
|
36
|
+
for (const providerName of ctx.providers.keys()) {
|
|
37
|
+
const preferred = this.preferencesForProvider(providerName, preferences, ctx.config);
|
|
38
|
+
if (preferred.length) {
|
|
39
|
+
candidates.push(...preferred);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return candidates.filter((candidate) => {
|
|
43
|
+
return this.isAllowed(candidate.model, ctx.config)
|
|
44
|
+
&& this.matchesRequirements(candidate.model, ctx.config);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
score(model, strategy, config) {
|
|
48
|
+
const registry = getModelRegistry(config);
|
|
49
|
+
const normalized = model.startsWith('ollama/') ? model.slice(7) : model;
|
|
50
|
+
const caps = registry[model] || registry[normalized] || this.localFallbackCapabilities(model);
|
|
51
|
+
if (strategy === 'privacy')
|
|
52
|
+
return model.startsWith('ollama/') ? 100 : 30;
|
|
53
|
+
if (strategy === 'speed')
|
|
54
|
+
return this.speedScore(model, caps);
|
|
55
|
+
if (strategy === 'cost')
|
|
56
|
+
return this.costScore(model, caps);
|
|
57
|
+
return this.qualityScore(model, caps);
|
|
58
|
+
}
|
|
59
|
+
localFallbackCapabilities(model) {
|
|
60
|
+
return {
|
|
61
|
+
modalities: ['text'],
|
|
62
|
+
streaming: true,
|
|
63
|
+
toolCalling: false,
|
|
64
|
+
maxContextTokens: 8192,
|
|
65
|
+
costPer1kInput: model.startsWith('ollama/') ? 0 : 0.001,
|
|
66
|
+
costPer1kOutput: model.startsWith('ollama/') ? 0 : 0.003,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
costScore(model, caps) {
|
|
70
|
+
if (model.startsWith('ollama/'))
|
|
71
|
+
return 100;
|
|
72
|
+
const totalCost = caps.costPer1kInput + caps.costPer1kOutput;
|
|
73
|
+
return Math.max(1, 100 - totalCost * 1000);
|
|
74
|
+
}
|
|
75
|
+
speedScore(model, caps) {
|
|
76
|
+
if (caps.speedScore)
|
|
77
|
+
return caps.speedScore;
|
|
78
|
+
if (model.includes('mini') || model.includes('haiku') || model.includes('flash'))
|
|
79
|
+
return 95;
|
|
80
|
+
if (model.startsWith('ollama/'))
|
|
81
|
+
return 80;
|
|
82
|
+
return caps.streaming ? 70 : 50;
|
|
83
|
+
}
|
|
84
|
+
qualityScore(model, caps) {
|
|
85
|
+
if (caps.qualityScore)
|
|
86
|
+
return caps.qualityScore;
|
|
87
|
+
if (model.includes('gpt-5.5'))
|
|
88
|
+
return 99;
|
|
89
|
+
if (model.includes('gpt-5.4'))
|
|
90
|
+
return 96;
|
|
91
|
+
if (model.includes('opus') || model.includes('gpt-4o'))
|
|
92
|
+
return 95;
|
|
93
|
+
if (model.includes('sonnet'))
|
|
94
|
+
return 90;
|
|
95
|
+
if (model.includes('gemini-3'))
|
|
96
|
+
return 94;
|
|
97
|
+
if (model.includes('gemini-2.5-pro'))
|
|
98
|
+
return 88;
|
|
99
|
+
if (model.startsWith('ollama/'))
|
|
100
|
+
return 65;
|
|
101
|
+
return Math.min(85, caps.maxContextTokens / 2000);
|
|
102
|
+
}
|
|
103
|
+
preferencesForProvider(providerName, preferences, config) {
|
|
104
|
+
if (preferences?.length) {
|
|
105
|
+
return preferences
|
|
106
|
+
.map((preference) => typeof preference === 'string'
|
|
107
|
+
? { model: preference, weight: 0 }
|
|
108
|
+
: { model: preference.model, weight: preference.weight || 0 })
|
|
109
|
+
.map((preference) => {
|
|
110
|
+
const resolved = resolveModel(preference.model, config);
|
|
111
|
+
return {
|
|
112
|
+
providerName: resolved.providerName || preference.model.split('/')[0],
|
|
113
|
+
model: resolved.model,
|
|
114
|
+
weight: preference.weight,
|
|
115
|
+
};
|
|
116
|
+
})
|
|
117
|
+
.filter((candidate) => candidate.providerName === providerName);
|
|
118
|
+
}
|
|
119
|
+
const registryModels = listModelsForProvider(providerName, config);
|
|
120
|
+
if (registryModels.length) {
|
|
121
|
+
return registryModels
|
|
122
|
+
.filter((model) => this.isProviderRunnable(providerName, model, config))
|
|
123
|
+
.map((model) => ({ providerName, model, weight: 0 }));
|
|
124
|
+
}
|
|
125
|
+
if (providerName === 'ollama') {
|
|
126
|
+
return [
|
|
127
|
+
{ providerName, model: 'ollama/llama3.3', weight: 0 },
|
|
128
|
+
{ providerName, model: 'ollama/llama3.2', weight: 0 },
|
|
129
|
+
{ providerName, model: 'ollama/qwen2.5', weight: 0 },
|
|
130
|
+
{ providerName, model: 'ollama/mistral', weight: 0 },
|
|
131
|
+
{ providerName, model: 'ollama/codellama', weight: 0 },
|
|
132
|
+
];
|
|
133
|
+
}
|
|
134
|
+
if (providerName === 'openrouter') {
|
|
135
|
+
return [
|
|
136
|
+
{ providerName, model: 'openrouter/openai/gpt-5.4-mini', weight: 0 },
|
|
137
|
+
{ providerName, model: 'openrouter/anthropic/claude-sonnet-4', weight: 0 },
|
|
138
|
+
{ providerName, model: 'openrouter/google/gemini-2.5-flash', weight: 0 },
|
|
139
|
+
{ providerName, model: 'openrouter/meta-llama/llama-3.3-70b-instruct', weight: 0 },
|
|
140
|
+
];
|
|
141
|
+
}
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
isAllowed(model, config) {
|
|
145
|
+
const allow = config.routing?.allowModels;
|
|
146
|
+
const deny = config.routing?.denyModels || [];
|
|
147
|
+
if (allow?.length && !allow.some((entry) => this.matchesModelPattern(model, entry)))
|
|
148
|
+
return false;
|
|
149
|
+
return !deny.some((entry) => this.matchesModelPattern(model, entry));
|
|
150
|
+
}
|
|
151
|
+
matchesRequirements(model, config) {
|
|
152
|
+
const requirements = config.routing?.requiredCapabilities;
|
|
153
|
+
if (!requirements)
|
|
154
|
+
return true;
|
|
155
|
+
const registry = getModelRegistry(config);
|
|
156
|
+
const normalized = model.startsWith('ollama/') ? model.slice(7) : model;
|
|
157
|
+
const caps = registry[model] || registry[normalized] || this.localFallbackCapabilities(model);
|
|
158
|
+
if (requirements.streaming !== undefined && caps.streaming !== requirements.streaming)
|
|
159
|
+
return false;
|
|
160
|
+
if (requirements.toolCalling !== undefined && caps.toolCalling !== requirements.toolCalling)
|
|
161
|
+
return false;
|
|
162
|
+
if (requirements.structuredOutputs !== undefined && caps.structuredOutputs !== requirements.structuredOutputs)
|
|
163
|
+
return false;
|
|
164
|
+
if (requirements.jsonMode !== undefined && caps.jsonMode !== requirements.jsonMode)
|
|
165
|
+
return false;
|
|
166
|
+
if (requirements.reasoning !== undefined && Boolean(caps.reasoning) !== requirements.reasoning)
|
|
167
|
+
return false;
|
|
168
|
+
if (requirements.minContextTokens && caps.maxContextTokens < requirements.minContextTokens)
|
|
169
|
+
return false;
|
|
170
|
+
if (requirements.maxInputCostPer1k !== undefined && caps.costPer1kInput > requirements.maxInputCostPer1k)
|
|
171
|
+
return false;
|
|
172
|
+
if (requirements.maxOutputCostPer1k !== undefined && caps.costPer1kOutput > requirements.maxOutputCostPer1k)
|
|
173
|
+
return false;
|
|
174
|
+
if (requirements.statuses?.length && caps.status && !requirements.statuses.includes(caps.status))
|
|
175
|
+
return false;
|
|
176
|
+
if (requirements.modalities?.length) {
|
|
177
|
+
for (const modality of requirements.modalities) {
|
|
178
|
+
if (!caps.modalities.includes(modality))
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
matchesModelPattern(model, pattern) {
|
|
185
|
+
if (pattern === model)
|
|
186
|
+
return true;
|
|
187
|
+
if (!pattern.includes('*'))
|
|
188
|
+
return false;
|
|
189
|
+
const regex = new RegExp(`^${pattern.split('*').map((part) => {
|
|
190
|
+
return part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
191
|
+
}).join('.*')}$`);
|
|
192
|
+
return regex.test(model);
|
|
193
|
+
}
|
|
194
|
+
isProviderRunnable(providerName, model, config) {
|
|
195
|
+
const registry = getModelRegistry(config);
|
|
196
|
+
const caps = registry[model];
|
|
197
|
+
if (!caps?.endpoints)
|
|
198
|
+
return true;
|
|
199
|
+
if (providerName === 'openai')
|
|
200
|
+
return caps.endpoints.includes('chat');
|
|
201
|
+
if (providerName === 'anthropic')
|
|
202
|
+
return caps.endpoints.includes('messages');
|
|
203
|
+
if (providerName === 'google')
|
|
204
|
+
return caps.endpoints.includes('generateContent');
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
healthScore(providerName, ctx) {
|
|
208
|
+
if (!ctx.config.health?.enabled || !ctx.health?.length)
|
|
209
|
+
return 0;
|
|
210
|
+
const snapshot = ctx.health.find((item) => item.providerName === providerName);
|
|
211
|
+
if (!snapshot)
|
|
212
|
+
return 0;
|
|
213
|
+
return snapshot.healthy ? snapshot.score / 10 : -100;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
export function routeDirectModel(model, ctx) {
|
|
217
|
+
const resolved = resolveModel(model, ctx.config);
|
|
218
|
+
const providerName = resolved.providerName || model.split('/')[0];
|
|
219
|
+
if (!ctx.providers.has(providerName)) {
|
|
220
|
+
throw new Error(`Provider "${providerName}" for model "${model}" is not configured`);
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
providerName,
|
|
224
|
+
model: resolved.model,
|
|
225
|
+
reason: `direct model route → ${model}${model === resolved.model ? '' : ` resolved to ${resolved.model}`}`,
|
|
226
|
+
fallbacks: [],
|
|
227
|
+
};
|
|
228
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { CompletionRequest } from '../types/messages.js';
|
|
2
|
+
import type { NexusResponse, NexusStream } from '../types/response.js';
|
|
3
|
+
import type { BaseProvider } from '../providers/base.js';
|
|
4
|
+
import type { RouteDecision } from './types.js';
|
|
5
|
+
import type { RetryConfig } from '../types/config.js';
|
|
6
|
+
export interface ExecutionOptions {
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
retry?: RetryConfig;
|
|
9
|
+
onAttemptSuccess?: (providerName: string, latencyMs: number) => void;
|
|
10
|
+
onAttemptFailure?: (providerName: string, error: unknown) => void;
|
|
11
|
+
}
|
|
12
|
+
export declare class FailoverExecutor {
|
|
13
|
+
complete(request: CompletionRequest, decision: RouteDecision, providers: Map<string, BaseProvider>, options?: ExecutionOptions): Promise<NexusResponse>;
|
|
14
|
+
stream(request: CompletionRequest, decision: RouteDecision, providers: Map<string, BaseProvider>, options?: ExecutionOptions): NexusStream;
|
|
15
|
+
private streamAttempts;
|
|
16
|
+
private withTimeout;
|
|
17
|
+
private mergeRetry;
|
|
18
|
+
private categorizeError;
|
|
19
|
+
private retryDelay;
|
|
20
|
+
private delay;
|
|
21
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
export class FailoverExecutor {
|
|
2
|
+
async complete(request, decision, providers, options = {}) {
|
|
3
|
+
const attempts = [{ providerName: decision.providerName, model: decision.model }, ...decision.fallbacks];
|
|
4
|
+
const errors = [];
|
|
5
|
+
const retry = this.mergeRetry(options.retry, request.retry);
|
|
6
|
+
for (const attempt of attempts) {
|
|
7
|
+
const provider = providers.get(attempt.providerName);
|
|
8
|
+
if (!provider)
|
|
9
|
+
continue;
|
|
10
|
+
for (let retryIndex = 0; retryIndex <= retry.maxRetries; retryIndex += 1) {
|
|
11
|
+
try {
|
|
12
|
+
const started = Date.now();
|
|
13
|
+
const response = await this.withTimeout(provider.complete({ ...request, model: attempt.model }), request.timeoutMs || options.timeoutMs);
|
|
14
|
+
options.onAttemptSuccess?.(attempt.providerName, Date.now() - started);
|
|
15
|
+
response.meta.routingDecision = {
|
|
16
|
+
reason: decision.reason,
|
|
17
|
+
fallbacksConsidered: attempts.length - 1,
|
|
18
|
+
};
|
|
19
|
+
if (retryIndex > 0) {
|
|
20
|
+
response.meta.guardrailsApplied.push(`provider-retry-${retryIndex}`);
|
|
21
|
+
}
|
|
22
|
+
return response;
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
options.onAttemptFailure?.(attempt.providerName, err);
|
|
26
|
+
const category = this.categorizeError(err);
|
|
27
|
+
const message = `${attempt.providerName}/${attempt.model}: ${err instanceof Error ? err.message : String(err)}`;
|
|
28
|
+
errors.push(message);
|
|
29
|
+
if (!retry.enabled || retryIndex >= retry.maxRetries || !retry.retryOn.includes(category)) {
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
await this.delay(this.retryDelay(retry, retryIndex));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
throw new Error(`All routing attempts failed: ${errors.join(' | ')}`);
|
|
37
|
+
}
|
|
38
|
+
stream(request, decision, providers, options = {}) {
|
|
39
|
+
const self = this;
|
|
40
|
+
return {
|
|
41
|
+
[Symbol.asyncIterator]() {
|
|
42
|
+
const attempts = [{ providerName: decision.providerName, model: decision.model }, ...decision.fallbacks];
|
|
43
|
+
return self.streamAttempts(request, attempts, providers, options);
|
|
44
|
+
},
|
|
45
|
+
abort() {
|
|
46
|
+
// Each provider stream owns its own abort lifecycle once selected.
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
async *streamAttempts(request, attempts, providers, options) {
|
|
51
|
+
const retry = this.mergeRetry(options.retry, request.retry);
|
|
52
|
+
const errors = [];
|
|
53
|
+
for (const attempt of attempts) {
|
|
54
|
+
const provider = providers.get(attempt.providerName);
|
|
55
|
+
if (!provider)
|
|
56
|
+
continue;
|
|
57
|
+
for (let retryIndex = 0; retryIndex <= retry.maxRetries; retryIndex += 1) {
|
|
58
|
+
const started = Date.now();
|
|
59
|
+
let emitted = false;
|
|
60
|
+
try {
|
|
61
|
+
for await (const chunk of provider.stream({ ...request, model: attempt.model })) {
|
|
62
|
+
if (chunk.type === 'error')
|
|
63
|
+
throw new Error(chunk.error || 'stream error');
|
|
64
|
+
emitted = emitted || chunk.type === 'text' || chunk.type === 'tool_call';
|
|
65
|
+
yield chunk;
|
|
66
|
+
}
|
|
67
|
+
options.onAttemptSuccess?.(attempt.providerName, Date.now() - started);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
options.onAttemptFailure?.(attempt.providerName, error);
|
|
72
|
+
errors.push(`${attempt.providerName}/${attempt.model}: ${error instanceof Error ? error.message : String(error)}`);
|
|
73
|
+
if (emitted || !retry.enabled || retryIndex >= retry.maxRetries || !retry.retryOn.includes(this.categorizeError(error))) {
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
await this.delay(this.retryDelay(retry, retryIndex));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
yield { type: 'error', error: `All streaming attempts failed: ${errors.join(' | ')}` };
|
|
81
|
+
}
|
|
82
|
+
async withTimeout(promise, timeoutMs) {
|
|
83
|
+
if (!timeoutMs || timeoutMs <= 0)
|
|
84
|
+
return promise;
|
|
85
|
+
let timeout;
|
|
86
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
87
|
+
timeout = setTimeout(() => reject(new Error(`Provider call timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
88
|
+
});
|
|
89
|
+
try {
|
|
90
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
if (timeout)
|
|
94
|
+
clearTimeout(timeout);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
mergeRetry(global, request) {
|
|
98
|
+
return {
|
|
99
|
+
enabled: request?.enabled ?? global?.enabled ?? false,
|
|
100
|
+
maxRetries: request?.maxRetries ?? global?.maxRetries ?? 1,
|
|
101
|
+
baseDelayMs: request?.baseDelayMs ?? global?.baseDelayMs ?? 250,
|
|
102
|
+
maxDelayMs: request?.maxDelayMs ?? global?.maxDelayMs ?? 2000,
|
|
103
|
+
backoff: request?.backoff ?? global?.backoff ?? 'exponential',
|
|
104
|
+
retryOn: global?.retryOn || ['timeout', 'rate-limit', 'server-error', 'network'],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
categorizeError(error) {
|
|
108
|
+
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
|
109
|
+
if (message.includes('timed out') || message.includes('timeout'))
|
|
110
|
+
return 'timeout';
|
|
111
|
+
if (message.includes('429') || message.includes('rate limit'))
|
|
112
|
+
return 'rate-limit';
|
|
113
|
+
if (message.includes('500') || message.includes('502') || message.includes('503') || message.includes('504'))
|
|
114
|
+
return 'server-error';
|
|
115
|
+
if (message.includes('network') || message.includes('fetch failed') || message.includes('econnreset'))
|
|
116
|
+
return 'network';
|
|
117
|
+
return 'unknown';
|
|
118
|
+
}
|
|
119
|
+
retryDelay(retry, retryIndex) {
|
|
120
|
+
const multiplier = retry.backoff === 'exponential' ? 2 ** retryIndex : 1;
|
|
121
|
+
return Math.min(retry.baseDelayMs * multiplier, retry.maxDelayMs);
|
|
122
|
+
}
|
|
123
|
+
delay(ms) {
|
|
124
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CompletionRequest } from '../types/messages.js';
|
|
2
|
+
import type { NexusAIConfig } from '../types/config.js';
|
|
3
|
+
import type { BaseProvider } from '../providers/base.js';
|
|
4
|
+
import type { RouteDecision } from './types.js';
|
|
5
|
+
import type { ProviderHealthSnapshot } from '../ops/health.js';
|
|
6
|
+
export declare class Router {
|
|
7
|
+
private autoRouter;
|
|
8
|
+
private rulesRouter;
|
|
9
|
+
route(request: CompletionRequest, config: NexusAIConfig, providers: Map<string, BaseProvider>, health?: ProviderHealthSnapshot[]): RouteDecision;
|
|
10
|
+
}
|
|
11
|
+
export type { RouteDecision, RouterContext } from './types.js';
|
|
12
|
+
export { FailoverExecutor } from './failover.js';
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { AutoRouter, routeDirectModel } from './auto-router.js';
|
|
2
|
+
import { RulesRouter } from './rules-router.js';
|
|
3
|
+
export class Router {
|
|
4
|
+
autoRouter = new AutoRouter();
|
|
5
|
+
rulesRouter = new RulesRouter();
|
|
6
|
+
route(request, config, providers, health) {
|
|
7
|
+
const ctx = { request, config, providers, health };
|
|
8
|
+
const routingMode = config.routing?.mode || 'auto';
|
|
9
|
+
if (request.model !== 'auto') {
|
|
10
|
+
return routeDirectModel(request.model, ctx);
|
|
11
|
+
}
|
|
12
|
+
if (routingMode === 'rules' || routingMode === 'hybrid') {
|
|
13
|
+
const decision = this.rulesRouter.route(ctx);
|
|
14
|
+
if (decision)
|
|
15
|
+
return decision;
|
|
16
|
+
if (routingMode === 'rules')
|
|
17
|
+
return this.autoRouter.route(ctx);
|
|
18
|
+
}
|
|
19
|
+
if (routingMode === 'hybrid') {
|
|
20
|
+
return this.autoRouter.route(ctx);
|
|
21
|
+
}
|
|
22
|
+
if (routingMode === 'direct') {
|
|
23
|
+
const model = config.defaultModel;
|
|
24
|
+
if (!model)
|
|
25
|
+
throw new Error('routing.mode="direct" requires config.defaultModel');
|
|
26
|
+
return routeDirectModel(model, { ...ctx, request: { ...request, model } });
|
|
27
|
+
}
|
|
28
|
+
return this.autoRouter.route(ctx);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export { FailoverExecutor } from './failover.js';
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { resolveModel } from '../models/registry.js';
|
|
2
|
+
export class RulesRouter {
|
|
3
|
+
route(ctx) {
|
|
4
|
+
const rules = ctx.config.routing?.rules;
|
|
5
|
+
if (!rules || rules.length === 0)
|
|
6
|
+
return null;
|
|
7
|
+
for (const rule of rules) {
|
|
8
|
+
if (rule.when === '*' || this.matches(rule.when, ctx)) {
|
|
9
|
+
const resolved = resolveModel(rule.use, ctx.config);
|
|
10
|
+
const providerName = resolved.providerName || rule.use.split('/')[0];
|
|
11
|
+
if (ctx.providers.has(providerName)) {
|
|
12
|
+
return {
|
|
13
|
+
providerName,
|
|
14
|
+
model: resolved.model,
|
|
15
|
+
reason: `matched routing rule -> ${rule.use}${rule.use === resolved.model ? '' : ` resolved to ${resolved.model}`}`,
|
|
16
|
+
fallbacks: [],
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
matches(conditions, ctx) {
|
|
24
|
+
for (const [key, expected] of Object.entries(conditions)) {
|
|
25
|
+
const actual = this.readConditionValue(key, ctx);
|
|
26
|
+
if (typeof expected === 'object' && expected !== null && !Array.isArray(expected)) {
|
|
27
|
+
if (!this.matchesOperator(actual, expected))
|
|
28
|
+
return false;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (actual !== expected)
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
readConditionValue(key, ctx) {
|
|
37
|
+
if (key === 'model')
|
|
38
|
+
return ctx.request.model;
|
|
39
|
+
if (key === 'userId')
|
|
40
|
+
return ctx.request.userId;
|
|
41
|
+
if (key === 'hasTools')
|
|
42
|
+
return Boolean(ctx.request.tools?.length);
|
|
43
|
+
if (key === 'messageCount')
|
|
44
|
+
return ctx.request.messages.length;
|
|
45
|
+
if (key === 'containsPII')
|
|
46
|
+
return false;
|
|
47
|
+
if (key === 'taskType')
|
|
48
|
+
return ctx.request.metadata?.taskType;
|
|
49
|
+
return ctx.request.metadata?.[key];
|
|
50
|
+
}
|
|
51
|
+
matchesOperator(actual, operators) {
|
|
52
|
+
for (const [op, expected] of Object.entries(operators)) {
|
|
53
|
+
if (op === 'gt' && !(Number(actual) > Number(expected)))
|
|
54
|
+
return false;
|
|
55
|
+
if (op === 'gte' && !(Number(actual) >= Number(expected)))
|
|
56
|
+
return false;
|
|
57
|
+
if (op === 'lt' && !(Number(actual) < Number(expected)))
|
|
58
|
+
return false;
|
|
59
|
+
if (op === 'lte' && !(Number(actual) <= Number(expected)))
|
|
60
|
+
return false;
|
|
61
|
+
if (op === 'eq' && actual !== expected)
|
|
62
|
+
return false;
|
|
63
|
+
if (op === 'in' && Array.isArray(expected) && !expected.includes(actual))
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CompletionRequest } from '../types/messages.js';
|
|
2
|
+
import type { NexusAIConfig } from '../types/config.js';
|
|
3
|
+
import type { BaseProvider } from '../providers/base.js';
|
|
4
|
+
import type { ProviderHealthSnapshot } from '../ops/health.js';
|
|
5
|
+
export interface RouteDecision {
|
|
6
|
+
providerName: string;
|
|
7
|
+
model: string;
|
|
8
|
+
reason: string;
|
|
9
|
+
fallbacks: Array<{
|
|
10
|
+
providerName: string;
|
|
11
|
+
model: string;
|
|
12
|
+
}>;
|
|
13
|
+
}
|
|
14
|
+
export interface RouterContext {
|
|
15
|
+
request: CompletionRequest;
|
|
16
|
+
config: NexusAIConfig;
|
|
17
|
+
providers: Map<string, BaseProvider>;
|
|
18
|
+
health?: ProviderHealthSnapshot[];
|
|
19
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { CompletionRequest } from '../types/messages.js';
|
|
2
|
+
import type { SecurityConfig, SecurityFinding, SecurityLevel, SecurityResult } from '../types/security.js';
|
|
3
|
+
import type { NexusResponse } from '../types/response.js';
|
|
4
|
+
export declare class NexusSecurityError extends Error {
|
|
5
|
+
findings: SecurityFinding[];
|
|
6
|
+
constructor(findings: SecurityFinding[]);
|
|
7
|
+
}
|
|
8
|
+
export declare class SecurityPipeline {
|
|
9
|
+
private config;
|
|
10
|
+
private schemaValidator;
|
|
11
|
+
private injectionDetector;
|
|
12
|
+
private piiDetector;
|
|
13
|
+
private outputGuard;
|
|
14
|
+
private inputGuard;
|
|
15
|
+
private semanticInjectionClassifier?;
|
|
16
|
+
constructor(config?: SecurityLevel | SecurityConfig);
|
|
17
|
+
protectInput(request: CompletionRequest): SecurityResult<CompletionRequest>;
|
|
18
|
+
assertSafe(result: SecurityResult<CompletionRequest>): void;
|
|
19
|
+
protectOutput(response: NexusResponse): SecurityResult<NexusResponse>;
|
|
20
|
+
private normalizeConfig;
|
|
21
|
+
private applyPreset;
|
|
22
|
+
private shouldRunInjectionDetection;
|
|
23
|
+
private shouldRunPII;
|
|
24
|
+
private getBlockingFindings;
|
|
25
|
+
}
|
|
26
|
+
export { SchemaValidator } from './schema-validator.js';
|
|
27
|
+
export { InjectionDetector } from './injection-detector.js';
|
|
28
|
+
export { PIIDetector } from './pii-detector.js';
|
|
29
|
+
export { OutputGuard } from './output-guard.js';
|
|
30
|
+
export { InputGuard } from './input-guard.js';
|
|
31
|
+
export { SemanticInjectionClassifier } from './semantic-injection-classifier.js';
|