@jsonstudio/llms 0.6.230 → 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 +9 -1
- 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.js +25 -28
- 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 -184
- 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 +18 -0
- package/dist/conversion/hub/pipeline/compat/compat-types.d.ts +6 -0
- package/dist/conversion/hub/pipeline/hub-pipeline.js +28 -1
- package/dist/conversion/hub/pipeline/target-utils.js +6 -0
- package/dist/conversion/hub/process/chat-process.js +100 -18
- package/dist/conversion/hub/response/provider-response.d.ts +13 -1
- package/dist/conversion/hub/response/provider-response.js +84 -35
- package/dist/conversion/hub/response/server-side-tools.js +61 -4
- 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/responses/responses-openai-bridge.js +35 -2
- 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/router/virtual-router/bootstrap.js +44 -12
- package/dist/router/virtual-router/classifier.js +11 -17
- package/dist/router/virtual-router/engine.d.ts +9 -0
- package/dist/router/virtual-router/engine.js +160 -18
- 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 +45 -0
- 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/sse-to-json/builders/response-builder.js +6 -3
- package/package.json +1 -1
|
@@ -27,7 +27,7 @@ export function bootstrapVirtualRouterConfig(input) {
|
|
|
27
27
|
if (!Object.keys(routingSource).length) {
|
|
28
28
|
throw new VirtualRouterError('Virtual Router routing table cannot be empty', VirtualRouterErrorCode.CONFIG_ERROR);
|
|
29
29
|
}
|
|
30
|
-
const webSearch = normalizeWebSearch(section.webSearch);
|
|
30
|
+
const webSearch = normalizeWebSearch(section.webSearch, routingSource);
|
|
31
31
|
validateWebSearchRouting(webSearch, routingSource);
|
|
32
32
|
const { runtimeEntries, aliasIndex } = buildProviderRuntimeEntries(providersSource);
|
|
33
33
|
const { routing, targetKeys } = expandRoutingTable(routingSource, aliasIndex);
|
|
@@ -121,7 +121,8 @@ function buildProviderRuntimeEntries(providers) {
|
|
|
121
121
|
streaming: normalizedProvider.streaming,
|
|
122
122
|
modelStreaming: normalizedProvider.modelStreaming,
|
|
123
123
|
modelContextTokens: normalizedProvider.modelContextTokens,
|
|
124
|
-
defaultContextTokens: normalizedProvider.defaultContextTokens
|
|
124
|
+
defaultContextTokens: normalizedProvider.defaultContextTokens,
|
|
125
|
+
...(normalizedProvider.serverToolsDisabled ? { serverToolsDisabled: true } : {})
|
|
125
126
|
};
|
|
126
127
|
}
|
|
127
128
|
}
|
|
@@ -158,7 +159,8 @@ function expandRoutingTable(routingSource, aliasIndex) {
|
|
|
158
159
|
id: pool.id,
|
|
159
160
|
priority: pool.priority,
|
|
160
161
|
backup: pool.backup,
|
|
161
|
-
targets: expandedTargets
|
|
162
|
+
targets: expandedTargets,
|
|
163
|
+
...(pool.force ? { force: true } : {})
|
|
162
164
|
});
|
|
163
165
|
}
|
|
164
166
|
}
|
|
@@ -194,7 +196,8 @@ function buildProviderProfiles(targetKeys, runtimeEntries) {
|
|
|
194
196
|
processMode: runtime.processMode || 'chat',
|
|
195
197
|
responsesConfig: runtime.responsesConfig,
|
|
196
198
|
streaming: streamingPref,
|
|
197
|
-
maxContextTokens: contextTokens
|
|
199
|
+
maxContextTokens: contextTokens,
|
|
200
|
+
...(runtime.serverToolsDisabled ? { serverToolsDisabled: true } : {})
|
|
198
201
|
};
|
|
199
202
|
targetRuntime[targetKey] = {
|
|
200
203
|
...runtime,
|
|
@@ -274,12 +277,15 @@ function normalizeRoutePoolEntry(routeName, entry, index, total) {
|
|
|
274
277
|
(typeof record.type === 'string' && record.type.toLowerCase() === 'backup');
|
|
275
278
|
const priority = normalizePriorityValue(record.priority, total - index);
|
|
276
279
|
const targets = normalizeRouteTargets(record);
|
|
280
|
+
const force = record.force === true ||
|
|
281
|
+
(typeof record.force === 'string' && record.force.trim().toLowerCase() === 'true');
|
|
277
282
|
return targets.length
|
|
278
283
|
? {
|
|
279
284
|
id,
|
|
280
285
|
priority,
|
|
281
286
|
backup,
|
|
282
|
-
targets
|
|
287
|
+
targets,
|
|
288
|
+
...(force ? { force: true } : {})
|
|
283
289
|
}
|
|
284
290
|
: null;
|
|
285
291
|
}
|
|
@@ -386,6 +392,12 @@ function normalizeProvider(providerId, raw) {
|
|
|
386
392
|
const streaming = resolveProviderStreamingPreference(provider, responsesNode);
|
|
387
393
|
const modelStreaming = normalizeModelStreaming(provider);
|
|
388
394
|
const { modelContextTokens, defaultContextTokens } = normalizeModelContextTokens(provider);
|
|
395
|
+
const serverToolsDisabled = provider.serverToolsDisabled === true ||
|
|
396
|
+
(typeof provider.serverToolsDisabled === 'string' &&
|
|
397
|
+
provider.serverToolsDisabled.trim().toLowerCase() === 'true') ||
|
|
398
|
+
(provider.serverTools &&
|
|
399
|
+
typeof provider.serverTools === 'object' &&
|
|
400
|
+
provider.serverTools.enabled === false);
|
|
389
401
|
return {
|
|
390
402
|
providerId,
|
|
391
403
|
providerType,
|
|
@@ -398,7 +410,8 @@ function normalizeProvider(providerId, raw) {
|
|
|
398
410
|
streaming,
|
|
399
411
|
modelStreaming,
|
|
400
412
|
modelContextTokens,
|
|
401
|
-
defaultContextTokens
|
|
413
|
+
defaultContextTokens,
|
|
414
|
+
...(serverToolsDisabled ? { serverToolsDisabled: true } : {})
|
|
402
415
|
};
|
|
403
416
|
}
|
|
404
417
|
function normalizeModelStreaming(provider) {
|
|
@@ -539,9 +552,9 @@ function validateWebSearchRouting(webSearch, routingSource) {
|
|
|
539
552
|
if (!webSearch) {
|
|
540
553
|
return;
|
|
541
554
|
}
|
|
542
|
-
const routePools = routingSource['web_search'];
|
|
555
|
+
const routePools = routingSource['web_search'] ?? routingSource['search'];
|
|
543
556
|
if (!Array.isArray(routePools) || !routePools.length) {
|
|
544
|
-
throw new VirtualRouterError('Virtual Router webSearch.engines configured but routing.web_search route is missing or empty', VirtualRouterErrorCode.CONFIG_ERROR);
|
|
557
|
+
throw new VirtualRouterError('Virtual Router webSearch.engines configured but routing.web_search (or search) route is missing or empty', VirtualRouterErrorCode.CONFIG_ERROR);
|
|
545
558
|
}
|
|
546
559
|
const targets = new Set();
|
|
547
560
|
for (const pool of routePools) {
|
|
@@ -556,11 +569,11 @@ function validateWebSearchRouting(webSearch, routingSource) {
|
|
|
556
569
|
}
|
|
557
570
|
for (const engine of webSearch.engines) {
|
|
558
571
|
if (!targets.has(engine.providerKey)) {
|
|
559
|
-
throw new VirtualRouterError(`Virtual Router webSearch engine "${engine.id}" references providerKey "${engine.providerKey}" which is not present in routing.web_search`, VirtualRouterErrorCode.CONFIG_ERROR);
|
|
572
|
+
throw new VirtualRouterError(`Virtual Router webSearch engine "${engine.id}" references providerKey "${engine.providerKey}" which is not present in routing.web_search/search`, VirtualRouterErrorCode.CONFIG_ERROR);
|
|
560
573
|
}
|
|
561
574
|
}
|
|
562
575
|
}
|
|
563
|
-
function normalizeWebSearch(input) {
|
|
576
|
+
function normalizeWebSearch(input, routingSource) {
|
|
564
577
|
if (!input || typeof input !== 'object') {
|
|
565
578
|
return undefined;
|
|
566
579
|
}
|
|
@@ -588,6 +601,12 @@ function normalizeWebSearch(input) {
|
|
|
588
601
|
: undefined;
|
|
589
602
|
const isDefault = node.default === true ||
|
|
590
603
|
(typeof node.default === 'string' && node.default.trim().toLowerCase() === 'true');
|
|
604
|
+
const serverToolsDisabled = node.serverToolsDisabled === true ||
|
|
605
|
+
(typeof node.serverToolsDisabled === 'string' &&
|
|
606
|
+
node.serverToolsDisabled.trim().toLowerCase() === 'true') ||
|
|
607
|
+
(node.serverTools &&
|
|
608
|
+
typeof node.serverTools === 'object' &&
|
|
609
|
+
node.serverTools.enabled === false);
|
|
591
610
|
// Deduplicate by id; first wins, subsequent are ignored.
|
|
592
611
|
if (engines.some((engine) => engine.id === id)) {
|
|
593
612
|
continue;
|
|
@@ -596,13 +615,15 @@ function normalizeWebSearch(input) {
|
|
|
596
615
|
id,
|
|
597
616
|
providerKey,
|
|
598
617
|
description,
|
|
599
|
-
default: isDefault
|
|
618
|
+
default: isDefault,
|
|
619
|
+
...(serverToolsDisabled ? { serverToolsDisabled: true } : {})
|
|
600
620
|
});
|
|
601
621
|
}
|
|
602
622
|
if (!engines.length) {
|
|
603
623
|
return undefined;
|
|
604
624
|
}
|
|
605
625
|
let injectPolicy;
|
|
626
|
+
let force;
|
|
606
627
|
const rawPolicy = record.injectPolicy ?? record?.inject_policy;
|
|
607
628
|
if (typeof rawPolicy === 'string') {
|
|
608
629
|
const normalized = rawPolicy.trim().toLowerCase();
|
|
@@ -610,9 +631,20 @@ function normalizeWebSearch(input) {
|
|
|
610
631
|
injectPolicy = normalized;
|
|
611
632
|
}
|
|
612
633
|
}
|
|
634
|
+
if (record.force === true ||
|
|
635
|
+
(typeof record.force === 'string' && record.force.trim().toLowerCase() === 'true')) {
|
|
636
|
+
force = true;
|
|
637
|
+
}
|
|
638
|
+
else {
|
|
639
|
+
const webSearchPools = routingSource['web_search'] ?? routingSource['search'] ?? [];
|
|
640
|
+
if (Array.isArray(webSearchPools) && webSearchPools.some((pool) => pool.force)) {
|
|
641
|
+
force = true;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
613
644
|
return {
|
|
614
645
|
engines,
|
|
615
|
-
injectPolicy: injectPolicy ?? 'selective'
|
|
646
|
+
injectPolicy: injectPolicy ?? 'selective',
|
|
647
|
+
...(force ? { force } : {})
|
|
616
648
|
};
|
|
617
649
|
}
|
|
618
650
|
function extractProviderAuthEntries(providerId, raw) {
|
|
@@ -17,34 +17,28 @@ export class RoutingClassifier {
|
|
|
17
17
|
const thinkingContinuation = lastToolCategory === 'read';
|
|
18
18
|
const searchContinuation = lastToolCategory === 'search';
|
|
19
19
|
const toolsContinuation = lastToolCategory === 'other';
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const evaluations = {
|
|
23
|
-
thinking: { triggered: true, reason: reasoning }
|
|
24
|
-
};
|
|
25
|
-
const candidates = this.ensureDefaultCandidate(['thinking']);
|
|
26
|
-
return this.buildResult('thinking', reasoning, evaluations, candidates);
|
|
27
|
-
}
|
|
20
|
+
// 用户输入优先级最高(仅次于 vision),确保每次新的用户输入都走 thinking 路由进行工具检查
|
|
21
|
+
// thinking_continuation 用于区分"工具轮次中的 read 类调用"与"用户新输入"
|
|
28
22
|
const evaluationMap = {
|
|
29
23
|
vision: {
|
|
30
|
-
triggered: features.
|
|
31
|
-
reason: 'vision:
|
|
24
|
+
triggered: features.hasImageAttachment,
|
|
25
|
+
reason: 'vision:image-detected'
|
|
26
|
+
},
|
|
27
|
+
thinking: {
|
|
28
|
+
triggered: latestMessageFromUser,
|
|
29
|
+
reason: 'thinking:user-input'
|
|
32
30
|
},
|
|
33
31
|
longcontext: {
|
|
34
32
|
triggered: reachedLongContext,
|
|
35
33
|
reason: 'longcontext:token-threshold'
|
|
36
34
|
},
|
|
37
|
-
websearch: {
|
|
38
|
-
triggered: features.hasWebTool || searchContinuation,
|
|
39
|
-
reason: searchContinuation ? 'websearch:last-tool-search' : 'websearch:web-tools-detected'
|
|
40
|
-
},
|
|
41
35
|
coding: {
|
|
42
36
|
triggered: codingContinuation,
|
|
43
37
|
reason: 'coding:last-tool-write'
|
|
44
38
|
},
|
|
45
|
-
|
|
46
|
-
triggered: thinkingContinuation
|
|
47
|
-
reason:
|
|
39
|
+
thinking_continuation: {
|
|
40
|
+
triggered: thinkingContinuation,
|
|
41
|
+
reason: 'thinking:last-tool-read'
|
|
48
42
|
},
|
|
49
43
|
tools: {
|
|
50
44
|
triggered: toolsContinuation || features.hasTools || features.hasToolCallResponses,
|
|
@@ -12,6 +12,7 @@ export declare class VirtualRouterEngine {
|
|
|
12
12
|
private readonly debug;
|
|
13
13
|
private healthConfig;
|
|
14
14
|
private readonly statsCenter;
|
|
15
|
+
private webSearchForce;
|
|
15
16
|
initialize(config: VirtualRouterConfig): void;
|
|
16
17
|
route(request: StandardizedRequest | ProcessedRequest, metadata: RouterMetadataInput): {
|
|
17
18
|
target: TargetMetadata;
|
|
@@ -28,6 +29,11 @@ export declare class VirtualRouterEngine {
|
|
|
28
29
|
}>;
|
|
29
30
|
health: import("./types.js").ProviderHealthState[];
|
|
30
31
|
};
|
|
32
|
+
/**
|
|
33
|
+
* 将分类器产生的逻辑路由名直接归一化为配置中的路由键。
|
|
34
|
+
* 不再维护 "websearch" 之类的别名,调用方应显式使用 "web_search" 或 "search" 等实际路由名。
|
|
35
|
+
*/
|
|
36
|
+
private normalizeRouteAlias;
|
|
31
37
|
private validateConfig;
|
|
32
38
|
private selectProvider;
|
|
33
39
|
private trySelectFromTier;
|
|
@@ -40,8 +46,11 @@ export declare class VirtualRouterEngine {
|
|
|
40
46
|
private mapProviderError;
|
|
41
47
|
private deriveReason;
|
|
42
48
|
private buildRouteCandidates;
|
|
49
|
+
private reorderForInlineVision;
|
|
50
|
+
private routeSupportsInlineVision;
|
|
43
51
|
private sortByPriority;
|
|
44
52
|
private routeWeight;
|
|
53
|
+
private routeHasForceFlag;
|
|
45
54
|
private routeHasTargets;
|
|
46
55
|
private hasPrimaryPool;
|
|
47
56
|
private sortRoutePools;
|
|
@@ -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 {
|
|
@@ -64,7 +81,7 @@ export class VirtualRouterEngine {
|
|
|
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: {
|
|
@@ -123,6 +140,14 @@ export class VirtualRouterEngine {
|
|
|
123
140
|
health: this.healthManager.getSnapshot()
|
|
124
141
|
};
|
|
125
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
|
+
}
|
|
126
151
|
validateConfig(config) {
|
|
127
152
|
if (!config.routing || typeof config.routing !== 'object') {
|
|
128
153
|
throw new VirtualRouterError('routing configuration is required', VirtualRouterErrorCode.CONFIG_ERROR);
|
|
@@ -158,7 +183,7 @@ export class VirtualRouterEngine {
|
|
|
158
183
|
}
|
|
159
184
|
}
|
|
160
185
|
selectProvider(requestedRoute, metadata, classification, features) {
|
|
161
|
-
const candidates = this.buildRouteCandidates(requestedRoute, classification.candidates);
|
|
186
|
+
const candidates = this.buildRouteCandidates(requestedRoute, classification.candidates, features);
|
|
162
187
|
const stickyKey = this.resolveStickyKey(metadata);
|
|
163
188
|
const attempted = [];
|
|
164
189
|
const visitedRoutes = new Set();
|
|
@@ -180,7 +205,7 @@ export class VirtualRouterEngine {
|
|
|
180
205
|
visitedRoutes.add(routeName);
|
|
181
206
|
const orderedPools = this.sortRoutePools(routePools);
|
|
182
207
|
for (const poolTier of orderedPools) {
|
|
183
|
-
const { providerKey, poolTargets, tierId, failureHint } = this.trySelectFromTier(routeName, poolTier, stickyKey, estimatedTokens);
|
|
208
|
+
const { providerKey, poolTargets, tierId, failureHint } = this.trySelectFromTier(routeName, poolTier, stickyKey, estimatedTokens, features);
|
|
184
209
|
if (providerKey) {
|
|
185
210
|
return { providerKey, routeUsed: routeName, pool: poolTargets, poolId: tierId };
|
|
186
211
|
}
|
|
@@ -191,8 +216,50 @@ export class VirtualRouterEngine {
|
|
|
191
216
|
}
|
|
192
217
|
throw new VirtualRouterError(`All providers unavailable for route ${requestedRoute}`, VirtualRouterErrorCode.PROVIDER_NOT_AVAILABLE, { routeName: requestedRoute, attempted });
|
|
193
218
|
}
|
|
194
|
-
trySelectFromTier(routeName, tier, stickyKey, estimatedTokens) {
|
|
195
|
-
|
|
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
|
+
}
|
|
196
263
|
if (!targets.length) {
|
|
197
264
|
return { providerKey: null, poolTargets: [], tierId: tier.id, failureHint: `${routeName}:${tier.id}:empty` };
|
|
198
265
|
}
|
|
@@ -346,12 +413,32 @@ export class VirtualRouterEngine {
|
|
|
346
413
|
return 'client_error';
|
|
347
414
|
return 'unknown';
|
|
348
415
|
}
|
|
349
|
-
buildRouteCandidates(requestedRoute, classificationCandidates) {
|
|
350
|
-
const
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
+
}
|
|
355
442
|
const deduped = [];
|
|
356
443
|
for (const routeName of ordered) {
|
|
357
444
|
if (routeName && !deduped.includes(routeName)) {
|
|
@@ -367,6 +454,53 @@ export class VirtualRouterEngine {
|
|
|
367
454
|
}
|
|
368
455
|
return filtered.length ? filtered : [DEFAULT_ROUTE];
|
|
369
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
|
+
}
|
|
370
504
|
sortByPriority(routeNames) {
|
|
371
505
|
return [...routeNames].sort((a, b) => this.routeWeight(a) - this.routeWeight(b));
|
|
372
506
|
}
|
|
@@ -374,6 +508,13 @@ export class VirtualRouterEngine {
|
|
|
374
508
|
const idx = ROUTE_PRIORITY.indexOf(routeName);
|
|
375
509
|
return idx >= 0 ? idx : ROUTE_PRIORITY.length;
|
|
376
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
|
+
}
|
|
377
518
|
routeHasTargets(pools) {
|
|
378
519
|
if (!Array.isArray(pools)) {
|
|
379
520
|
return false;
|
|
@@ -434,8 +575,8 @@ export class VirtualRouterEngine {
|
|
|
434
575
|
if (routeUsed === 'coding') {
|
|
435
576
|
return this.decorateWithDetail(primary || 'coding', primary, commandDetail);
|
|
436
577
|
}
|
|
437
|
-
if (routeUsed === '
|
|
438
|
-
return this.decorateWithDetail(primary ||
|
|
578
|
+
if (routeUsed === 'web_search' || routeUsed === 'search') {
|
|
579
|
+
return this.decorateWithDetail(primary || routeUsed, primary, commandDetail);
|
|
439
580
|
}
|
|
440
581
|
if (routeUsed === DEFAULT_ROUTE && classification.fallback) {
|
|
441
582
|
return primary || 'fallback:default';
|
|
@@ -495,7 +636,8 @@ export class VirtualRouterEngine {
|
|
|
495
636
|
thinking: '\x1b[34m',
|
|
496
637
|
coding: '\x1b[35m',
|
|
497
638
|
longcontext: '\x1b[38;5;141m',
|
|
498
|
-
|
|
639
|
+
web_search: '\x1b[32m',
|
|
640
|
+
search: '\x1b[38;5;34m',
|
|
499
641
|
vision: '\x1b[38;5;207m',
|
|
500
642
|
background: '\x1b[90m'
|
|
501
643
|
};
|
|
@@ -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);
|
|
@@ -37,30 +37,42 @@ export function detectExtendedThinkingKeyword(text) {
|
|
|
37
37
|
export function detectImageAttachment(message) {
|
|
38
38
|
if (!message)
|
|
39
39
|
return false;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
40
|
+
// 仅基于标准 Chat 语义判断是否携带图片:
|
|
41
|
+
// - content 为数组时查找 { type: 'image' | 'image_url' | 'input_image', ... } 块;
|
|
42
|
+
// - 不再依赖 metadata.attachments,也不再用纯文本关键字或剪贴板标记作为信号。
|
|
43
|
+
if (Array.isArray(message.content)) {
|
|
44
|
+
for (const part of message.content) {
|
|
45
|
+
if (!part || typeof part !== 'object') {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const record = part;
|
|
49
|
+
const typeValue = typeof record.type === 'string' ? record.type.toLowerCase() : '';
|
|
50
|
+
// For chat/standardized content, images may appear as:
|
|
51
|
+
// - { type: "image_url", image_url: { url } }
|
|
52
|
+
// - { type: "image", uri: "...", data: "...", url: "..." }
|
|
53
|
+
// - { type: "input_image", image_url: "data:..." }
|
|
54
|
+
// Treat any non-empty URL/URI/data on an image-* block as a signal.
|
|
55
|
+
let imageCandidate = '';
|
|
56
|
+
if (typeof record.image_url === 'string') {
|
|
57
|
+
imageCandidate = record.image_url ?? '';
|
|
58
|
+
}
|
|
59
|
+
else if (record.image_url &&
|
|
60
|
+
typeof record.image_url?.url === 'string') {
|
|
61
|
+
imageCandidate = record.image_url?.url ?? '';
|
|
62
|
+
}
|
|
63
|
+
else if (typeof record.url === 'string') {
|
|
64
|
+
imageCandidate = record.url ?? '';
|
|
65
|
+
}
|
|
66
|
+
else if (typeof record.uri === 'string') {
|
|
67
|
+
imageCandidate = record.uri ?? '';
|
|
68
|
+
}
|
|
69
|
+
else if (typeof record.data === 'string') {
|
|
70
|
+
imageCandidate = record.data ?? '';
|
|
71
|
+
}
|
|
72
|
+
if (typeValue.includes('image') && imageCandidate.trim().length > 0) {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
64
76
|
}
|
|
65
77
|
return false;
|
|
66
78
|
}
|
|
@@ -62,7 +62,8 @@ export class ProviderRegistry {
|
|
|
62
62
|
processMode: profile.processMode || 'chat',
|
|
63
63
|
responsesConfig: profile.responsesConfig,
|
|
64
64
|
streaming: profile.streaming,
|
|
65
|
-
maxContextTokens: profile.maxContextTokens
|
|
65
|
+
maxContextTokens: profile.maxContextTokens,
|
|
66
|
+
...(profile.serverToolsDisabled ? { serverToolsDisabled: true } : {})
|
|
66
67
|
};
|
|
67
68
|
}
|
|
68
69
|
}
|
|
@@ -78,11 +78,22 @@ function encodeContent(content, encoder) {
|
|
|
78
78
|
total += encodeText(part, encoder);
|
|
79
79
|
}
|
|
80
80
|
else if (part && typeof part === 'object') {
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
const record = part;
|
|
82
|
+
const typeValue = typeof record.type === 'string' ? record.type.toLowerCase() : '';
|
|
83
|
+
// Large binary/image payloads (data URIs, base64, etc.) should not
|
|
84
|
+
// dominate context estimation. For image-like blocks, only count a
|
|
85
|
+
// small textual placeholder instead of the full JSON/body.
|
|
86
|
+
if (typeValue.startsWith('image')) {
|
|
87
|
+
const caption = typeof record.caption === 'string' && record.caption.trim().length
|
|
88
|
+
? record.caption
|
|
89
|
+
: '[image]';
|
|
90
|
+
total += encodeText(caption, encoder);
|
|
91
|
+
}
|
|
92
|
+
else if (typeof record.text === 'string') {
|
|
93
|
+
total += encodeText(record.text, encoder);
|
|
83
94
|
}
|
|
84
95
|
else {
|
|
85
|
-
total += encodeText(JSON.stringify(
|
|
96
|
+
total += encodeText(JSON.stringify(record), encoder);
|
|
86
97
|
}
|
|
87
98
|
}
|
|
88
99
|
}
|