@jsonstudio/llms 0.6.215 → 0.6.375
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 +2 -0
- package/dist/conversion/codecs/gemini-openai-codec.js +92 -2
- package/dist/conversion/compat/actions/gemini-web-search.d.ts +17 -0
- package/dist/conversion/compat/actions/gemini-web-search.js +68 -0
- package/dist/conversion/compat/actions/glm-image-content.d.ts +2 -0
- package/dist/conversion/compat/actions/glm-image-content.js +83 -0
- package/dist/conversion/compat/actions/glm-vision-prompt.d.ts +11 -0
- package/dist/conversion/compat/actions/glm-vision-prompt.js +177 -0
- package/dist/conversion/compat/actions/glm-web-search.d.ts +2 -0
- package/dist/conversion/compat/actions/glm-web-search.js +63 -0
- package/dist/conversion/compat/actions/universal-shape-filter.js +11 -0
- package/dist/conversion/compat/profiles/chat-gemini.json +17 -0
- package/dist/conversion/compat/profiles/chat-glm.json +190 -181
- package/dist/conversion/compat/profiles/chat-iflow.json +195 -195
- package/dist/conversion/compat/profiles/chat-lmstudio.json +43 -43
- package/dist/conversion/compat/profiles/chat-qwen.json +20 -20
- package/dist/conversion/compat/profiles/responses-c4m.json +42 -42
- package/dist/conversion/config/sample-config.json +1 -1
- package/dist/conversion/hub/pipeline/compat/compat-pipeline-executor.js +24 -0
- package/dist/conversion/hub/pipeline/compat/compat-types.d.ts +8 -0
- package/dist/conversion/hub/pipeline/hub-pipeline.js +37 -2
- package/dist/conversion/hub/pipeline/target-utils.js +6 -0
- package/dist/conversion/hub/process/chat-process.js +213 -1
- package/dist/conversion/hub/response/provider-response.d.ts +34 -0
- package/dist/conversion/hub/response/provider-response.js +84 -24
- package/dist/conversion/hub/response/server-side-tools.d.ts +26 -0
- package/dist/conversion/hub/response/server-side-tools.js +383 -0
- package/dist/conversion/hub/semantic-mappers/gemini-mapper.js +123 -3
- package/dist/conversion/hub/semantic-mappers/responses-mapper.js +17 -1
- package/dist/conversion/hub/standardized-bridge.js +14 -0
- package/dist/conversion/hub/types/standardized.d.ts +1 -0
- package/dist/conversion/responses/responses-openai-bridge.js +82 -3
- package/dist/conversion/shared/anthropic-message-utils.js +92 -3
- package/dist/conversion/shared/bridge-message-utils.js +137 -10
- package/dist/conversion/shared/responses-output-builder.js +43 -2
- package/dist/conversion/shared/tool-filter-pipeline.js +1 -0
- package/dist/conversion/shared/tool-mapping.js +25 -2
- package/dist/router/virtual-router/bootstrap.js +308 -43
- package/dist/router/virtual-router/classifier.js +11 -17
- package/dist/router/virtual-router/context-advisor.d.ts +0 -2
- package/dist/router/virtual-router/context-advisor.js +0 -12
- package/dist/router/virtual-router/engine.d.ts +16 -2
- package/dist/router/virtual-router/engine.js +317 -96
- package/dist/router/virtual-router/features.js +1 -1
- package/dist/router/virtual-router/message-utils.js +36 -24
- package/dist/router/virtual-router/provider-registry.js +2 -1
- package/dist/router/virtual-router/token-counter.js +14 -3
- package/dist/router/virtual-router/types.d.ts +66 -2
- package/dist/router/virtual-router/types.js +2 -1
- package/dist/servertool/engine.d.ts +27 -0
- package/dist/servertool/engine.js +60 -0
- package/dist/servertool/flow-types.d.ts +40 -0
- package/dist/servertool/flow-types.js +1 -0
- package/dist/servertool/handlers/vision.d.ts +1 -0
- package/dist/servertool/handlers/vision.js +194 -0
- package/dist/servertool/handlers/web-search.d.ts +1 -0
- package/dist/servertool/handlers/web-search.js +638 -0
- package/dist/servertool/orchestration-types.d.ts +33 -0
- package/dist/servertool/orchestration-types.js +1 -0
- package/dist/servertool/registry.d.ts +18 -0
- package/dist/servertool/registry.js +27 -0
- package/dist/servertool/server-side-tools.d.ts +8 -0
- package/dist/servertool/server-side-tools.js +208 -0
- package/dist/servertool/types.d.ts +88 -0
- package/dist/servertool/types.js +1 -0
- package/dist/servertool/vision-tool.d.ts +2 -0
- package/dist/servertool/vision-tool.js +185 -0
- package/dist/sse/json-to-sse/event-generators/responses.js +15 -3
- package/dist/sse/sse-to-json/builders/response-builder.js +6 -3
- package/dist/sse/sse-to-json/gemini-sse-to-json-converter.js +27 -1
- package/dist/sse/types/gemini-types.d.ts +20 -1
- package/dist/sse/types/responses-types.js +1 -1
- package/package.json +1 -1
|
@@ -18,6 +18,8 @@ export class VirtualRouterEngine {
|
|
|
18
18
|
debug = console; // thin hook; host may monkey-patch for colored logging
|
|
19
19
|
healthConfig = null;
|
|
20
20
|
statsCenter = getStatsCenter();
|
|
21
|
+
// Derived flags from VirtualRouterConfig/routing used by process / response layers.
|
|
22
|
+
webSearchForce = false;
|
|
21
23
|
initialize(config) {
|
|
22
24
|
this.validateConfig(config);
|
|
23
25
|
this.routing = config.routing;
|
|
@@ -29,6 +31,7 @@ export class VirtualRouterEngine {
|
|
|
29
31
|
this.classifier = new RoutingClassifier(config.classifier);
|
|
30
32
|
this.contextRouting = config.contextRouting ?? { warnRatio: 0.9, hardLimit: false };
|
|
31
33
|
this.contextAdvisor.configure(this.contextRouting);
|
|
34
|
+
this.webSearchForce = config.webSearch?.force === true;
|
|
32
35
|
this.routeStats = new Map();
|
|
33
36
|
for (const routeName of Object.keys(this.routing)) {
|
|
34
37
|
this.routeStats.set(routeName, { hits: 0 });
|
|
@@ -36,10 +39,24 @@ export class VirtualRouterEngine {
|
|
|
36
39
|
}
|
|
37
40
|
route(request, metadata) {
|
|
38
41
|
const features = buildRoutingFeatures(request, metadata);
|
|
39
|
-
const classification =
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
const classification = metadata.routeHint && metadata.routeHint.trim()
|
|
43
|
+
? {
|
|
44
|
+
routeName: metadata.routeHint.trim(),
|
|
45
|
+
confidence: 1,
|
|
46
|
+
reasoning: `route_hint:${metadata.routeHint.trim()}`,
|
|
47
|
+
fallback: false,
|
|
48
|
+
candidates: [metadata.routeHint.trim()]
|
|
49
|
+
}
|
|
50
|
+
: this.classifier.classify(features);
|
|
51
|
+
const requestedRoute = this.normalizeRouteAlias(classification.routeName || DEFAULT_ROUTE);
|
|
52
|
+
const selection = this.selectProvider(requestedRoute, metadata, classification, features);
|
|
53
|
+
const baseTarget = this.providerRegistry.buildTarget(selection.providerKey);
|
|
54
|
+
const forceVision = this.routeHasForceFlag('vision');
|
|
55
|
+
const target = {
|
|
56
|
+
...baseTarget,
|
|
57
|
+
...(this.webSearchForce ? { forceWebSearch: true } : {}),
|
|
58
|
+
...(forceVision ? { forceVision: true } : {})
|
|
59
|
+
};
|
|
43
60
|
this.healthManager.recordSuccess(selection.providerKey);
|
|
44
61
|
this.incrementRouteStat(selection.routeUsed, selection.providerKey);
|
|
45
62
|
try {
|
|
@@ -48,7 +65,7 @@ export class VirtualRouterEngine {
|
|
|
48
65
|
timestamp: Date.now(),
|
|
49
66
|
entryEndpoint: metadata.entryEndpoint || '/v1/chat/completions',
|
|
50
67
|
routeName: selection.routeUsed,
|
|
51
|
-
pool: selection.routeUsed,
|
|
68
|
+
pool: selection.poolId || selection.routeUsed,
|
|
52
69
|
providerKey: selection.providerKey,
|
|
53
70
|
modelId: target.modelId || undefined
|
|
54
71
|
});
|
|
@@ -57,20 +74,21 @@ export class VirtualRouterEngine {
|
|
|
57
74
|
// stats must never break routing
|
|
58
75
|
}
|
|
59
76
|
const hitReason = this.buildHitReason(selection.routeUsed, selection.providerKey, classification, features);
|
|
60
|
-
const formatted = this.formatVirtualRouterHit(selection.routeUsed, selection.providerKey, target.modelId || '', hitReason);
|
|
77
|
+
const formatted = this.formatVirtualRouterHit(selection.routeUsed, selection.poolId, selection.providerKey, target.modelId || '', hitReason);
|
|
61
78
|
if (formatted) {
|
|
62
79
|
this.debug?.log?.(formatted);
|
|
63
80
|
}
|
|
64
81
|
else {
|
|
65
82
|
this.debug?.log?.('[virtual-router-hit]', selection.routeUsed, selection.providerKey, target.modelId || '', hitReason ? `reason=${hitReason}` : '');
|
|
66
83
|
}
|
|
67
|
-
const didFallback = selection.routeUsed !==
|
|
84
|
+
const didFallback = selection.routeUsed !== requestedRoute;
|
|
68
85
|
return {
|
|
69
86
|
target,
|
|
70
87
|
decision: {
|
|
71
88
|
routeName: selection.routeUsed,
|
|
72
89
|
providerKey: selection.providerKey,
|
|
73
90
|
pool: selection.pool,
|
|
91
|
+
poolId: selection.poolId,
|
|
74
92
|
confidence: classification.confidence,
|
|
75
93
|
reasoning: classification.reasoning,
|
|
76
94
|
fallback: didFallback
|
|
@@ -79,6 +97,7 @@ export class VirtualRouterEngine {
|
|
|
79
97
|
routeName: selection.routeUsed,
|
|
80
98
|
providerKey: selection.providerKey,
|
|
81
99
|
pool: selection.pool,
|
|
100
|
+
poolId: selection.poolId,
|
|
82
101
|
reasoning: classification.reasoning,
|
|
83
102
|
fallback: didFallback,
|
|
84
103
|
confidence: classification.confidence
|
|
@@ -108,10 +127,10 @@ export class VirtualRouterEngine {
|
|
|
108
127
|
}
|
|
109
128
|
getStatus() {
|
|
110
129
|
const routes = {};
|
|
111
|
-
for (const [route,
|
|
130
|
+
for (const [route, pools] of Object.entries(this.routing)) {
|
|
112
131
|
const stats = this.routeStats.get(route) ?? { hits: 0 };
|
|
113
132
|
routes[route] = {
|
|
114
|
-
providers:
|
|
133
|
+
providers: this.flattenPoolTargets(pools),
|
|
115
134
|
hits: stats.hits,
|
|
116
135
|
lastUsedProvider: stats.lastProvider
|
|
117
136
|
};
|
|
@@ -121,6 +140,14 @@ export class VirtualRouterEngine {
|
|
|
121
140
|
health: this.healthManager.getSnapshot()
|
|
122
141
|
};
|
|
123
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* 将分类器产生的逻辑路由名直接归一化为配置中的路由键。
|
|
145
|
+
* 不再维护 "websearch" 之类的别名,调用方应显式使用 "web_search" 或 "search" 等实际路由名。
|
|
146
|
+
*/
|
|
147
|
+
normalizeRouteAlias(routeName) {
|
|
148
|
+
const base = routeName && routeName.trim() ? routeName.trim() : DEFAULT_ROUTE;
|
|
149
|
+
return base;
|
|
150
|
+
}
|
|
124
151
|
validateConfig(config) {
|
|
125
152
|
if (!config.routing || typeof config.routing !== 'object') {
|
|
126
153
|
throw new VirtualRouterError('routing configuration is required', VirtualRouterErrorCode.CONFIG_ERROR);
|
|
@@ -128,32 +155,39 @@ export class VirtualRouterEngine {
|
|
|
128
155
|
if (!config.providers || Object.keys(config.providers).length === 0) {
|
|
129
156
|
throw new VirtualRouterError('providers configuration is required', VirtualRouterErrorCode.CONFIG_ERROR);
|
|
130
157
|
}
|
|
131
|
-
const
|
|
132
|
-
if (!
|
|
158
|
+
const defaultPools = config.routing[DEFAULT_ROUTE];
|
|
159
|
+
if (!this.routeHasTargets(defaultPools)) {
|
|
133
160
|
throw new VirtualRouterError('default route must be configured with at least one provider', VirtualRouterErrorCode.CONFIG_ERROR);
|
|
134
161
|
}
|
|
162
|
+
if (!this.hasPrimaryPool(defaultPools)) {
|
|
163
|
+
throw new VirtualRouterError('default route must define at least one non-backup pool', VirtualRouterErrorCode.CONFIG_ERROR);
|
|
164
|
+
}
|
|
135
165
|
const providerKeys = new Set(Object.keys(config.providers));
|
|
136
|
-
for (const [routeName,
|
|
137
|
-
if (!
|
|
166
|
+
for (const [routeName, pools] of Object.entries(config.routing)) {
|
|
167
|
+
if (!this.routeHasTargets(pools)) {
|
|
138
168
|
if (routeName === DEFAULT_ROUTE) {
|
|
139
169
|
throw new VirtualRouterError('default route cannot be empty', VirtualRouterErrorCode.CONFIG_ERROR);
|
|
140
170
|
}
|
|
141
171
|
continue;
|
|
142
172
|
}
|
|
143
|
-
for (const
|
|
144
|
-
if (!
|
|
145
|
-
|
|
173
|
+
for (const pool of pools) {
|
|
174
|
+
if (!Array.isArray(pool.targets) || !pool.targets.length) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
for (const providerKey of pool.targets) {
|
|
178
|
+
if (!providerKeys.has(providerKey)) {
|
|
179
|
+
throw new VirtualRouterError(`Route ${routeName} references unknown provider ${providerKey}`, VirtualRouterErrorCode.CONFIG_ERROR);
|
|
180
|
+
}
|
|
146
181
|
}
|
|
147
182
|
}
|
|
148
183
|
}
|
|
149
184
|
}
|
|
150
185
|
selectProvider(requestedRoute, metadata, classification, features) {
|
|
151
|
-
const candidates = this.buildRouteCandidates(requestedRoute, classification.candidates);
|
|
186
|
+
const candidates = this.buildRouteCandidates(requestedRoute, classification.candidates, features);
|
|
152
187
|
const stickyKey = this.resolveStickyKey(metadata);
|
|
153
188
|
const attempted = [];
|
|
154
189
|
const visitedRoutes = new Set();
|
|
155
|
-
const
|
|
156
|
-
const routeQueue = this.initializeRouteQueue(candidates, fallbackRoute);
|
|
190
|
+
const routeQueue = this.initializeRouteQueue(candidates);
|
|
157
191
|
const estimatedTokens = typeof features.estimatedTokens === 'number' && Number.isFinite(features.estimatedTokens)
|
|
158
192
|
? Math.max(0, features.estimatedTokens)
|
|
159
193
|
: 0;
|
|
@@ -162,33 +196,93 @@ export class VirtualRouterEngine {
|
|
|
162
196
|
if (visitedRoutes.has(routeName)) {
|
|
163
197
|
continue;
|
|
164
198
|
}
|
|
165
|
-
const
|
|
166
|
-
if (!
|
|
199
|
+
const routePools = this.routing[routeName];
|
|
200
|
+
if (!this.routeHasTargets(routePools)) {
|
|
167
201
|
visitedRoutes.add(routeName);
|
|
168
|
-
attempted.push(routeName);
|
|
169
|
-
continue;
|
|
170
|
-
}
|
|
171
|
-
const contextResult = this.contextAdvisor.classify(pool, estimatedTokens, (key) => this.providerRegistry.get(key));
|
|
172
|
-
if (this.maybeDeferToFallback(routeName, contextResult, routeQueue, visitedRoutes, fallbackRoute)) {
|
|
202
|
+
attempted.push(`${routeName}:empty`);
|
|
173
203
|
continue;
|
|
174
204
|
}
|
|
175
205
|
visitedRoutes.add(routeName);
|
|
176
|
-
const
|
|
177
|
-
for (const
|
|
178
|
-
const providerKey = this.
|
|
179
|
-
routeName,
|
|
180
|
-
candidates: candidatePool,
|
|
181
|
-
stickyKey,
|
|
182
|
-
availabilityCheck: (key) => this.healthManager.isAvailable(key)
|
|
183
|
-
});
|
|
206
|
+
const orderedPools = this.sortRoutePools(routePools);
|
|
207
|
+
for (const poolTier of orderedPools) {
|
|
208
|
+
const { providerKey, poolTargets, tierId, failureHint } = this.trySelectFromTier(routeName, poolTier, stickyKey, estimatedTokens, features);
|
|
184
209
|
if (providerKey) {
|
|
185
|
-
return { providerKey, routeUsed: routeName, pool };
|
|
210
|
+
return { providerKey, routeUsed: routeName, pool: poolTargets, poolId: tierId };
|
|
211
|
+
}
|
|
212
|
+
if (failureHint) {
|
|
213
|
+
attempted.push(failureHint);
|
|
186
214
|
}
|
|
187
215
|
}
|
|
188
|
-
attempted.push(this.describeAttempt(routeName, contextResult));
|
|
189
216
|
}
|
|
190
217
|
throw new VirtualRouterError(`All providers unavailable for route ${requestedRoute}`, VirtualRouterErrorCode.PROVIDER_NOT_AVAILABLE, { routeName: requestedRoute, attempted });
|
|
191
218
|
}
|
|
219
|
+
trySelectFromTier(routeName, tier, stickyKey, estimatedTokens, features) {
|
|
220
|
+
let targets = Array.isArray(tier.targets) ? tier.targets : [];
|
|
221
|
+
const serverToolRequired = features.metadata?.serverToolRequired === true;
|
|
222
|
+
if (serverToolRequired) {
|
|
223
|
+
const filtered = [];
|
|
224
|
+
for (const key of targets) {
|
|
225
|
+
try {
|
|
226
|
+
const profile = this.providerRegistry.get(key);
|
|
227
|
+
if (!profile.serverToolsDisabled) {
|
|
228
|
+
filtered.push(key);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
// ignore unknown providers when filtering for servertools
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
targets = filtered;
|
|
236
|
+
}
|
|
237
|
+
// 当当前请求包含图片且路由为 default/thinking 时,优先在该路由池内选择
|
|
238
|
+
// Responses/Gemini 类型的 Provider,以便一次完成多模态推理;如果不存在则回退到原始列表。
|
|
239
|
+
if (features.hasImageAttachment && (routeName === DEFAULT_ROUTE || routeName === 'thinking')) {
|
|
240
|
+
const prioritized = [];
|
|
241
|
+
const fallthrough = [];
|
|
242
|
+
for (const key of targets) {
|
|
243
|
+
try {
|
|
244
|
+
const profile = this.providerRegistry.get(key);
|
|
245
|
+
if (profile.providerType === 'responses') {
|
|
246
|
+
prioritized.push(key);
|
|
247
|
+
}
|
|
248
|
+
else if (profile.providerType === 'gemini') {
|
|
249
|
+
prioritized.push(key);
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
fallthrough.push(key);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
fallthrough.push(key);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (prioritized.length) {
|
|
260
|
+
targets = prioritized;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (!targets.length) {
|
|
264
|
+
return { providerKey: null, poolTargets: [], tierId: tier.id, failureHint: `${routeName}:${tier.id}:empty` };
|
|
265
|
+
}
|
|
266
|
+
const contextResult = this.contextAdvisor.classify(targets, estimatedTokens, (key) => this.providerRegistry.get(key));
|
|
267
|
+
const prioritizedPools = this.buildContextCandidatePools(contextResult);
|
|
268
|
+
for (const candidatePool of prioritizedPools) {
|
|
269
|
+
const providerKey = this.loadBalancer.select({
|
|
270
|
+
routeName: `${routeName}:${tier.id}`,
|
|
271
|
+
candidates: candidatePool,
|
|
272
|
+
stickyKey,
|
|
273
|
+
availabilityCheck: (key) => this.healthManager.isAvailable(key)
|
|
274
|
+
});
|
|
275
|
+
if (providerKey) {
|
|
276
|
+
return { providerKey, poolTargets: tier.targets, tierId: tier.id };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
providerKey: null,
|
|
281
|
+
poolTargets: tier.targets,
|
|
282
|
+
tierId: tier.id,
|
|
283
|
+
failureHint: this.describeAttempt(routeName, tier.id, contextResult)
|
|
284
|
+
};
|
|
285
|
+
}
|
|
192
286
|
incrementRouteStat(routeName, providerKey) {
|
|
193
287
|
if (!this.routeStats.has(routeName)) {
|
|
194
288
|
this.routeStats.set(routeName, { hits: 0, lastProvider: providerKey });
|
|
@@ -201,63 +295,34 @@ export class VirtualRouterEngine {
|
|
|
201
295
|
providerHealthConfig() {
|
|
202
296
|
return this.healthManager.getConfig();
|
|
203
297
|
}
|
|
204
|
-
initializeRouteQueue(candidates
|
|
205
|
-
|
|
206
|
-
if (fallbackRoute && !queue.includes(fallbackRoute)) {
|
|
207
|
-
queue.push(fallbackRoute);
|
|
208
|
-
}
|
|
209
|
-
return queue;
|
|
210
|
-
}
|
|
211
|
-
resolveFallbackRoute() {
|
|
212
|
-
const candidate = this.contextRouting?.fallbackRoute;
|
|
213
|
-
if (!candidate) {
|
|
214
|
-
return undefined;
|
|
215
|
-
}
|
|
216
|
-
const pool = this.routing[candidate];
|
|
217
|
-
if (!Array.isArray(pool) || pool.length === 0) {
|
|
218
|
-
return undefined;
|
|
219
|
-
}
|
|
220
|
-
return candidate;
|
|
221
|
-
}
|
|
222
|
-
maybeDeferToFallback(routeName, contextResult, queue, visited, fallbackRoute) {
|
|
223
|
-
if (!fallbackRoute || fallbackRoute === routeName || visited.has(fallbackRoute)) {
|
|
224
|
-
return false;
|
|
225
|
-
}
|
|
226
|
-
if (!this.contextAdvisor.prefersFallback(contextResult)) {
|
|
227
|
-
return false;
|
|
228
|
-
}
|
|
229
|
-
const fallbackPool = this.routing[fallbackRoute];
|
|
230
|
-
if (!Array.isArray(fallbackPool) || fallbackPool.length === 0) {
|
|
231
|
-
return false;
|
|
232
|
-
}
|
|
233
|
-
queue.unshift(routeName);
|
|
234
|
-
queue.unshift(fallbackRoute);
|
|
235
|
-
return true;
|
|
298
|
+
initializeRouteQueue(candidates) {
|
|
299
|
+
return Array.from(new Set(candidates));
|
|
236
300
|
}
|
|
237
301
|
buildContextCandidatePools(result) {
|
|
238
302
|
const ordered = [];
|
|
239
303
|
if (result.safe.length) {
|
|
240
304
|
ordered.push(result.safe);
|
|
305
|
+
// 如果存在安全候选,直接放弃当前处于警戒阈值的模型
|
|
306
|
+
return ordered;
|
|
241
307
|
}
|
|
242
308
|
if (result.risky.length) {
|
|
243
309
|
ordered.push(result.risky);
|
|
244
310
|
}
|
|
245
|
-
|
|
246
|
-
ordered.push(result.overflow);
|
|
247
|
-
}
|
|
311
|
+
// ratio >= 1 视为上下文溢出,直接标记为不可用
|
|
248
312
|
return ordered;
|
|
249
313
|
}
|
|
250
|
-
describeAttempt(routeName, result) {
|
|
314
|
+
describeAttempt(routeName, poolId, result) {
|
|
315
|
+
const prefix = poolId ? `${routeName}:${poolId}` : routeName;
|
|
251
316
|
if (result.safe.length > 0) {
|
|
252
|
-
return `${
|
|
317
|
+
return `${prefix}:health`;
|
|
253
318
|
}
|
|
254
319
|
if (result.risky.length > 0) {
|
|
255
|
-
return `${
|
|
320
|
+
return `${prefix}:context_risky`;
|
|
256
321
|
}
|
|
257
322
|
if (result.overflow.length > 0) {
|
|
258
|
-
return `${
|
|
323
|
+
return `${prefix}:max_context_window`;
|
|
259
324
|
}
|
|
260
|
-
return
|
|
325
|
+
return prefix;
|
|
261
326
|
}
|
|
262
327
|
resolveStickyKey(metadata) {
|
|
263
328
|
const resume = metadata.responsesResume;
|
|
@@ -348,12 +413,32 @@ export class VirtualRouterEngine {
|
|
|
348
413
|
return 'client_error';
|
|
349
414
|
return 'unknown';
|
|
350
415
|
}
|
|
351
|
-
buildRouteCandidates(requestedRoute, classificationCandidates) {
|
|
352
|
-
const
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
416
|
+
buildRouteCandidates(requestedRoute, classificationCandidates, features) {
|
|
417
|
+
const forceVision = this.routeHasForceFlag('vision');
|
|
418
|
+
const normalized = this.normalizeRouteAlias(requestedRoute || DEFAULT_ROUTE);
|
|
419
|
+
const baseList = [];
|
|
420
|
+
if (classificationCandidates && classificationCandidates.length) {
|
|
421
|
+
for (const candidate of classificationCandidates) {
|
|
422
|
+
baseList.push(this.normalizeRouteAlias(candidate));
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
else if (normalized) {
|
|
426
|
+
baseList.push(normalized);
|
|
427
|
+
}
|
|
428
|
+
// 当检测到当前请求包含图片时,确保 default/thinking 也参与候选集,
|
|
429
|
+
// 以便优先尝试内建多模态模型(Responses/Gemini),再回落到 vision 路由池。
|
|
430
|
+
if (features.hasImageAttachment && !forceVision) {
|
|
431
|
+
const visionAwareRoutes = [DEFAULT_ROUTE, 'thinking'];
|
|
432
|
+
for (const routeName of visionAwareRoutes) {
|
|
433
|
+
if (this.routeHasTargets(this.routing[routeName]) && !baseList.includes(routeName)) {
|
|
434
|
+
baseList.push(routeName);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
let ordered = this.sortByPriority(baseList);
|
|
439
|
+
if (features.hasImageAttachment && !forceVision) {
|
|
440
|
+
ordered = this.reorderForInlineVision(ordered);
|
|
441
|
+
}
|
|
357
442
|
const deduped = [];
|
|
358
443
|
for (const routeName of ordered) {
|
|
359
444
|
if (routeName && !deduped.includes(routeName)) {
|
|
@@ -363,17 +448,59 @@ export class VirtualRouterEngine {
|
|
|
363
448
|
if (!deduped.includes(DEFAULT_ROUTE)) {
|
|
364
449
|
deduped.push(DEFAULT_ROUTE);
|
|
365
450
|
}
|
|
366
|
-
const filtered = deduped.filter((routeName) =>
|
|
367
|
-
|
|
368
|
-
return Array.isArray(pool) && pool.length > 0;
|
|
369
|
-
});
|
|
370
|
-
if (!filtered.includes(DEFAULT_ROUTE) &&
|
|
371
|
-
Array.isArray(this.routing[DEFAULT_ROUTE]) &&
|
|
372
|
-
this.routing[DEFAULT_ROUTE].length > 0) {
|
|
451
|
+
const filtered = deduped.filter((routeName) => this.routeHasTargets(this.routing[routeName]));
|
|
452
|
+
if (!filtered.includes(DEFAULT_ROUTE) && this.routeHasTargets(this.routing[DEFAULT_ROUTE])) {
|
|
373
453
|
filtered.push(DEFAULT_ROUTE);
|
|
374
454
|
}
|
|
375
455
|
return filtered.length ? filtered : [DEFAULT_ROUTE];
|
|
376
456
|
}
|
|
457
|
+
reorderForInlineVision(routeNames) {
|
|
458
|
+
const unique = Array.from(new Set(routeNames.filter(Boolean)));
|
|
459
|
+
if (!unique.length) {
|
|
460
|
+
return unique;
|
|
461
|
+
}
|
|
462
|
+
// 仅当 default/thinking 中存在 Responses/Gemini 提供方时,才将其提前作为「一次完成」优先级。
|
|
463
|
+
const inlinePreferred = [];
|
|
464
|
+
const inlineRoutes = [DEFAULT_ROUTE, 'thinking'];
|
|
465
|
+
for (const routeName of inlineRoutes) {
|
|
466
|
+
if (this.routeSupportsInlineVision(routeName) && !inlinePreferred.includes(routeName)) {
|
|
467
|
+
inlinePreferred.push(routeName);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (!inlinePreferred.length) {
|
|
471
|
+
return unique;
|
|
472
|
+
}
|
|
473
|
+
const remaining = [];
|
|
474
|
+
for (const routeName of unique) {
|
|
475
|
+
if (!inlinePreferred.includes(routeName)) {
|
|
476
|
+
remaining.push(routeName);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return [...inlinePreferred, ...remaining];
|
|
480
|
+
}
|
|
481
|
+
routeSupportsInlineVision(routeName) {
|
|
482
|
+
const pools = this.routing[routeName];
|
|
483
|
+
if (!Array.isArray(pools)) {
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
for (const pool of pools) {
|
|
487
|
+
if (!Array.isArray(pool.targets)) {
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
for (const providerKey of pool.targets) {
|
|
491
|
+
try {
|
|
492
|
+
const profile = this.providerRegistry.get(providerKey);
|
|
493
|
+
if (profile.providerType === 'responses' || profile.providerType === 'gemini') {
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
// ignore unknown provider keys during capability probing
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
377
504
|
sortByPriority(routeNames) {
|
|
378
505
|
return [...routeNames].sort((a, b) => this.routeWeight(a) - this.routeWeight(b));
|
|
379
506
|
}
|
|
@@ -381,6 +508,59 @@ export class VirtualRouterEngine {
|
|
|
381
508
|
const idx = ROUTE_PRIORITY.indexOf(routeName);
|
|
382
509
|
return idx >= 0 ? idx : ROUTE_PRIORITY.length;
|
|
383
510
|
}
|
|
511
|
+
routeHasForceFlag(routeName) {
|
|
512
|
+
const pools = this.routing[routeName];
|
|
513
|
+
if (!Array.isArray(pools)) {
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
return pools.some((pool) => pool.force);
|
|
517
|
+
}
|
|
518
|
+
routeHasTargets(pools) {
|
|
519
|
+
if (!Array.isArray(pools)) {
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
return pools.some((pool) => Array.isArray(pool.targets) && pool.targets.length > 0);
|
|
523
|
+
}
|
|
524
|
+
hasPrimaryPool(pools) {
|
|
525
|
+
if (!Array.isArray(pools)) {
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
return pools.some((pool) => !pool.backup && Array.isArray(pool.targets) && pool.targets.length > 0);
|
|
529
|
+
}
|
|
530
|
+
sortRoutePools(pools) {
|
|
531
|
+
if (!Array.isArray(pools)) {
|
|
532
|
+
return [];
|
|
533
|
+
}
|
|
534
|
+
return pools
|
|
535
|
+
.filter((pool) => Array.isArray(pool.targets) && pool.targets.length > 0)
|
|
536
|
+
.sort((a, b) => {
|
|
537
|
+
if (a.backup && !b.backup)
|
|
538
|
+
return 1;
|
|
539
|
+
if (!a.backup && b.backup)
|
|
540
|
+
return -1;
|
|
541
|
+
if (a.priority !== b.priority) {
|
|
542
|
+
return b.priority - a.priority;
|
|
543
|
+
}
|
|
544
|
+
return a.id.localeCompare(b.id);
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
flattenPoolTargets(pools) {
|
|
548
|
+
const flattened = [];
|
|
549
|
+
if (!Array.isArray(pools)) {
|
|
550
|
+
return flattened;
|
|
551
|
+
}
|
|
552
|
+
for (const pool of pools) {
|
|
553
|
+
if (!Array.isArray(pool.targets)) {
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
for (const target of pool.targets) {
|
|
557
|
+
if (typeof target === 'string' && target && !flattened.includes(target)) {
|
|
558
|
+
flattened.push(target);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return flattened;
|
|
563
|
+
}
|
|
384
564
|
buildHitReason(routeUsed, providerKey, classification, features) {
|
|
385
565
|
const reasoning = classification.reasoning || '';
|
|
386
566
|
const primary = reasoning.split('|')[0] || '';
|
|
@@ -395,8 +575,8 @@ export class VirtualRouterEngine {
|
|
|
395
575
|
if (routeUsed === 'coding') {
|
|
396
576
|
return this.decorateWithDetail(primary || 'coding', primary, commandDetail);
|
|
397
577
|
}
|
|
398
|
-
if (routeUsed === '
|
|
399
|
-
return this.decorateWithDetail(primary ||
|
|
578
|
+
if (routeUsed === 'web_search' || routeUsed === 'search') {
|
|
579
|
+
return this.decorateWithDetail(primary || routeUsed, primary, commandDetail);
|
|
400
580
|
}
|
|
401
581
|
if (routeUsed === DEFAULT_ROUTE && classification.fallback) {
|
|
402
582
|
return primary || 'fallback:default';
|
|
@@ -422,18 +602,31 @@ export class VirtualRouterEngine {
|
|
|
422
602
|
}
|
|
423
603
|
return `${baseLabel}(${normalizedDetail})`;
|
|
424
604
|
}
|
|
425
|
-
formatVirtualRouterHit(routeName, providerKey, modelId, hitReason) {
|
|
605
|
+
formatVirtualRouterHit(routeName, poolId, providerKey, modelId, hitReason) {
|
|
426
606
|
try {
|
|
607
|
+
// 生成本地时间戳
|
|
608
|
+
const now = new Date();
|
|
609
|
+
const hours = String(now.getHours()).padStart(2, '0');
|
|
610
|
+
const minutes = String(now.getMinutes()).padStart(2, '0');
|
|
611
|
+
const seconds = String(now.getSeconds()).padStart(2, '0');
|
|
612
|
+
const timestamp = `${hours}:${minutes}:${seconds}`;
|
|
427
613
|
const prefixColor = '\x1b[38;5;208m';
|
|
428
614
|
const reset = '\x1b[0m';
|
|
615
|
+
const timeColor = '\x1b[90m'; // 灰色
|
|
429
616
|
const routeColor = this.resolveRouteColor(routeName);
|
|
430
617
|
const prefix = `${prefixColor}[virtual-router-hit]${reset}`;
|
|
431
|
-
const
|
|
618
|
+
const timeLabel = `${timeColor}${timestamp}${reset}`;
|
|
619
|
+
const { providerLabel, resolvedModel } = this.describeTargetProvider(providerKey, modelId);
|
|
620
|
+
const routeLabel = poolId ? `${routeName}/${poolId}` : routeName;
|
|
621
|
+
const targetLabel = `${routeLabel} -> ${providerLabel}${resolvedModel ? '.' + resolvedModel : ''}`;
|
|
432
622
|
const reasonLabel = hitReason ? ` reason=${hitReason}` : '';
|
|
433
|
-
return `${prefix} ${routeColor}${targetLabel}${reasonLabel}${reset}`;
|
|
623
|
+
return `${prefix} ${timeLabel} ${routeColor}${targetLabel}${reasonLabel}${reset}`;
|
|
434
624
|
}
|
|
435
625
|
catch {
|
|
436
|
-
|
|
626
|
+
const now = new Date();
|
|
627
|
+
const timestamp = now.toLocaleTimeString('zh-CN', { hour12: false });
|
|
628
|
+
const routeLabel = poolId ? `${routeName}/${poolId}` : routeName;
|
|
629
|
+
return `[virtual-router-hit] ${timestamp} ${routeLabel} -> ${providerKey}${modelId ? '.' + modelId : ''}${hitReason ? ` reason=${hitReason}` : ''}`;
|
|
437
630
|
}
|
|
438
631
|
}
|
|
439
632
|
resolveRouteColor(routeName) {
|
|
@@ -443,7 +636,8 @@ export class VirtualRouterEngine {
|
|
|
443
636
|
thinking: '\x1b[34m',
|
|
444
637
|
coding: '\x1b[35m',
|
|
445
638
|
longcontext: '\x1b[38;5;141m',
|
|
446
|
-
|
|
639
|
+
web_search: '\x1b[32m',
|
|
640
|
+
search: '\x1b[38;5;34m',
|
|
447
641
|
vision: '\x1b[38;5;207m',
|
|
448
642
|
background: '\x1b[90m'
|
|
449
643
|
};
|
|
@@ -473,4 +667,31 @@ export class VirtualRouterEngine {
|
|
|
473
667
|
}
|
|
474
668
|
return `${ratio.toFixed(2)}/${Math.round(limit)}`;
|
|
475
669
|
}
|
|
670
|
+
describeTargetProvider(providerKey, fallbackModelId) {
|
|
671
|
+
const parsed = this.parseProviderKey(providerKey);
|
|
672
|
+
if (!parsed) {
|
|
673
|
+
return { providerLabel: providerKey, resolvedModel: fallbackModelId };
|
|
674
|
+
}
|
|
675
|
+
const aliasLabel = parsed.keyAlias ? `${parsed.providerId}[${parsed.keyAlias}]` : parsed.providerId;
|
|
676
|
+
const resolvedModel = parsed.modelId || fallbackModelId;
|
|
677
|
+
return { providerLabel: aliasLabel, resolvedModel };
|
|
678
|
+
}
|
|
679
|
+
parseProviderKey(providerKey) {
|
|
680
|
+
const trimmed = typeof providerKey === 'string' ? providerKey.trim() : '';
|
|
681
|
+
if (!trimmed) {
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
const parts = trimmed.split('.');
|
|
685
|
+
if (parts.length < 2) {
|
|
686
|
+
return { providerId: trimmed };
|
|
687
|
+
}
|
|
688
|
+
if (parts.length === 2) {
|
|
689
|
+
return { providerId: parts[0], modelId: parts[1] };
|
|
690
|
+
}
|
|
691
|
+
return {
|
|
692
|
+
providerId: parts[0],
|
|
693
|
+
keyAlias: parts[1],
|
|
694
|
+
modelId: parts.slice(2).join('.')
|
|
695
|
+
};
|
|
696
|
+
}
|
|
476
697
|
}
|
|
@@ -14,7 +14,7 @@ export function buildRoutingFeatures(request, metadata) {
|
|
|
14
14
|
const estimatedTokens = computeRequestTokens(request, latestUserText);
|
|
15
15
|
const hasThinking = detectKeyword(normalizedUserText, THINKING_KEYWORDS);
|
|
16
16
|
const hasVisionTool = detectVisionTool(request);
|
|
17
|
-
const hasImageAttachment =
|
|
17
|
+
const hasImageAttachment = detectImageAttachment(latestUserMessage);
|
|
18
18
|
const hasCodingTool = detectCodingTool(request);
|
|
19
19
|
const hasWebTool = detectWebTool(request);
|
|
20
20
|
const hasThinkingKeyword = hasThinking || detectExtendedThinkingKeyword(normalizedUserText);
|