@jsonstudio/rcc 0.89.333 → 0.89.524
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/dist/build-info.js +3 -3
- package/dist/build-info.js.map +1 -1
- package/dist/cli.js +62 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/token-daemon.d.ts +2 -0
- package/dist/commands/token-daemon.js +183 -0
- package/dist/commands/token-daemon.js.map +1 -0
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/modules/llmswitch/bridge.d.ts +1 -1
- package/dist/modules/llmswitch/bridge.js +3 -2
- package/dist/modules/llmswitch/bridge.js.map +1 -1
- package/dist/modules/pipeline/utils/colored-logger.js +3 -1
- package/dist/modules/pipeline/utils/colored-logger.js.map +1 -1
- package/dist/providers/auth/gemini-cli-userinfo-helper.js +12 -2
- package/dist/providers/auth/gemini-cli-userinfo-helper.js.map +1 -1
- package/dist/providers/auth/oauth-lifecycle.js +261 -22
- package/dist/providers/auth/oauth-lifecycle.js.map +1 -1
- package/dist/providers/core/config/oauth-flows.d.ts +23 -0
- package/dist/providers/core/config/oauth-flows.js +92 -5
- package/dist/providers/core/config/oauth-flows.js.map +1 -1
- package/dist/providers/core/config/provider-oauth-configs.js +9 -3
- package/dist/providers/core/config/provider-oauth-configs.js.map +1 -1
- package/dist/providers/core/config/service-profiles.js +18 -10
- package/dist/providers/core/config/service-profiles.js.map +1 -1
- package/dist/providers/core/runtime/gemini-cli-http-provider.js +87 -20
- package/dist/providers/core/runtime/gemini-cli-http-provider.js.map +1 -1
- package/dist/providers/core/runtime/http-request-executor.d.ts +1 -0
- package/dist/providers/core/runtime/http-request-executor.js +41 -1
- package/dist/providers/core/runtime/http-request-executor.js.map +1 -1
- package/dist/providers/core/runtime/http-transport-provider.d.ts +2 -0
- package/dist/providers/core/runtime/http-transport-provider.js +37 -2
- package/dist/providers/core/runtime/http-transport-provider.js.map +1 -1
- package/dist/providers/core/runtime/responses-provider.js +8 -3
- package/dist/providers/core/runtime/responses-provider.js.map +1 -1
- package/dist/providers/core/runtime/vision-debug-utils.d.ts +13 -0
- package/dist/providers/core/runtime/vision-debug-utils.js +114 -0
- package/dist/providers/core/runtime/vision-debug-utils.js.map +1 -0
- package/dist/providers/core/strategies/oauth-auth-code-flow.js +75 -26
- package/dist/providers/core/strategies/oauth-auth-code-flow.js.map +1 -1
- package/dist/providers/core/utils/http-client.js +2 -1
- package/dist/providers/core/utils/http-client.js.map +1 -1
- package/dist/providers/core/utils/snapshot-writer.d.ts +1 -1
- package/dist/providers/core/utils/snapshot-writer.js.map +1 -1
- package/dist/server/handlers/sse-dispatcher.js +22 -2
- package/dist/server/handlers/sse-dispatcher.js.map +1 -1
- package/dist/server/runtime/http-server/index.d.ts +9 -0
- package/dist/server/runtime/http-server/index.js +512 -144
- package/dist/server/runtime/http-server/index.js.map +1 -1
- package/dist/server/runtime/http-server/request-executor.d.ts +10 -0
- package/dist/server/runtime/http-server/request-executor.js +553 -159
- package/dist/server/runtime/http-server/request-executor.js.map +1 -1
- package/dist/server/runtime/http-server/routes.d.ts +5 -0
- package/dist/server/runtime/http-server/routes.js +69 -0
- package/dist/server/runtime/http-server/routes.js.map +1 -1
- package/dist/server/runtime/http-server/runtime-manager.js +18 -0
- package/dist/server/runtime/http-server/runtime-manager.js.map +1 -1
- package/dist/server/utils/utf8-chunk-buffer.d.ts +43 -0
- package/dist/server/utils/utf8-chunk-buffer.js +132 -0
- package/dist/server/utils/utf8-chunk-buffer.js.map +1 -0
- package/dist/token-daemon/index.d.ts +7 -0
- package/dist/token-daemon/index.js +242 -0
- package/dist/token-daemon/index.js.map +1 -0
- package/dist/token-daemon/server-utils.d.ts +33 -0
- package/dist/token-daemon/server-utils.js +155 -0
- package/dist/token-daemon/server-utils.js.map +1 -0
- package/dist/token-daemon/token-daemon.d.ts +20 -0
- package/dist/token-daemon/token-daemon.js +144 -0
- package/dist/token-daemon/token-daemon.js.map +1 -0
- package/dist/token-daemon/token-types.d.ts +44 -0
- package/dist/token-daemon/token-types.js +18 -0
- package/dist/token-daemon/token-types.js.map +1 -0
- package/dist/token-daemon/token-utils.d.ts +17 -0
- package/dist/token-daemon/token-utils.js +153 -0
- package/dist/token-daemon/token-utils.js.map +1 -0
- package/dist/tools/semantic-replay.js +7 -6
- package/dist/tools/semantic-replay.js.map +1 -1
- package/dist/utils/error-handler-registry.d.ts +36 -0
- package/dist/utils/error-handler-registry.js +93 -7
- package/dist/utils/error-handler-registry.js.map +1 -1
- package/node_modules/@jsonstudio/llms/README.md +2 -0
- package/node_modules/@jsonstudio/llms/dist/conversion/codecs/gemini-openai-codec.js +137 -5
- package/node_modules/@jsonstudio/llms/dist/conversion/compat/actions/gemini-web-search.d.ts +17 -0
- package/node_modules/@jsonstudio/llms/dist/conversion/compat/actions/gemini-web-search.js +68 -0
- package/node_modules/@jsonstudio/llms/dist/conversion/compat/actions/glm-image-content.d.ts +2 -0
- package/node_modules/@jsonstudio/llms/dist/conversion/compat/actions/glm-image-content.js +83 -0
- package/node_modules/@jsonstudio/llms/dist/conversion/compat/actions/glm-vision-prompt.d.ts +11 -0
- package/node_modules/@jsonstudio/llms/dist/conversion/compat/actions/glm-vision-prompt.js +177 -0
- package/node_modules/@jsonstudio/llms/dist/conversion/compat/actions/glm-web-search.d.ts +2 -0
- package/node_modules/@jsonstudio/llms/dist/conversion/compat/actions/glm-web-search.js +63 -0
- package/node_modules/@jsonstudio/llms/dist/conversion/compat/actions/universal-shape-filter.js +11 -0
- package/node_modules/@jsonstudio/llms/dist/conversion/compat/profiles/chat-gemini.json +17 -0
- package/node_modules/@jsonstudio/llms/dist/conversion/compat/profiles/chat-glm.json +190 -181
- package/node_modules/@jsonstudio/llms/dist/conversion/compat/profiles/chat-iflow.json +195 -195
- package/node_modules/@jsonstudio/llms/dist/conversion/compat/profiles/chat-lmstudio.json +43 -43
- package/node_modules/@jsonstudio/llms/dist/conversion/compat/profiles/chat-qwen.json +20 -20
- package/node_modules/@jsonstudio/llms/dist/conversion/compat/profiles/responses-c4m.json +42 -42
- package/node_modules/@jsonstudio/llms/dist/conversion/config/sample-config.json +1 -1
- package/node_modules/@jsonstudio/llms/dist/conversion/hub/pipeline/compat/compat-pipeline-executor.js +24 -0
- package/node_modules/@jsonstudio/llms/dist/conversion/hub/pipeline/compat/compat-types.d.ts +8 -0
- package/node_modules/@jsonstudio/llms/dist/conversion/hub/pipeline/hub-pipeline.js +39 -4
- package/node_modules/@jsonstudio/llms/dist/conversion/hub/pipeline/target-utils.js +6 -0
- package/node_modules/@jsonstudio/llms/dist/conversion/hub/process/chat-process.js +213 -1
- package/node_modules/@jsonstudio/llms/dist/conversion/hub/response/provider-response.d.ts +34 -0
- package/node_modules/@jsonstudio/llms/dist/conversion/hub/response/provider-response.js +84 -24
- package/node_modules/@jsonstudio/llms/dist/conversion/hub/response/server-side-tools.d.ts +26 -0
- package/node_modules/@jsonstudio/llms/dist/conversion/hub/response/server-side-tools.js +383 -0
- package/node_modules/@jsonstudio/llms/dist/conversion/hub/semantic-mappers/gemini-mapper.js +241 -14
- package/node_modules/@jsonstudio/llms/dist/conversion/hub/semantic-mappers/responses-mapper.js +17 -1
- package/node_modules/@jsonstudio/llms/dist/conversion/hub/standardized-bridge.js +14 -0
- package/node_modules/@jsonstudio/llms/dist/conversion/hub/types/standardized.d.ts +1 -0
- package/node_modules/@jsonstudio/llms/dist/conversion/responses/responses-openai-bridge.js +82 -3
- package/node_modules/@jsonstudio/llms/dist/conversion/shared/anthropic-message-utils.js +92 -3
- package/node_modules/@jsonstudio/llms/dist/conversion/shared/bridge-message-utils.js +137 -10
- package/node_modules/@jsonstudio/llms/dist/conversion/shared/responses-output-builder.js +43 -2
- package/node_modules/@jsonstudio/llms/dist/conversion/shared/snapshot-utils.js +17 -47
- package/node_modules/@jsonstudio/llms/dist/conversion/shared/tool-filter-pipeline.js +1 -0
- package/node_modules/@jsonstudio/llms/dist/conversion/shared/tool-mapping.js +25 -2
- package/node_modules/@jsonstudio/llms/dist/index.d.ts +1 -0
- package/node_modules/@jsonstudio/llms/dist/index.js +1 -0
- package/node_modules/@jsonstudio/llms/dist/router/virtual-router/bootstrap.js +308 -43
- package/node_modules/@jsonstudio/llms/dist/router/virtual-router/classifier.js +11 -17
- package/node_modules/@jsonstudio/llms/dist/router/virtual-router/context-advisor.d.ts +0 -2
- package/node_modules/@jsonstudio/llms/dist/router/virtual-router/context-advisor.js +0 -12
- package/node_modules/@jsonstudio/llms/dist/router/virtual-router/engine.d.ts +17 -2
- package/node_modules/@jsonstudio/llms/dist/router/virtual-router/engine.js +332 -95
- package/node_modules/@jsonstudio/llms/dist/router/virtual-router/features.js +1 -1
- package/node_modules/@jsonstudio/llms/dist/router/virtual-router/message-utils.js +36 -24
- package/node_modules/@jsonstudio/llms/dist/router/virtual-router/provider-registry.js +2 -1
- package/node_modules/@jsonstudio/llms/dist/router/virtual-router/token-counter.js +14 -3
- package/node_modules/@jsonstudio/llms/dist/router/virtual-router/types.d.ts +66 -2
- package/node_modules/@jsonstudio/llms/dist/router/virtual-router/types.js +2 -1
- package/node_modules/@jsonstudio/llms/dist/servertool/engine.d.ts +27 -0
- package/node_modules/@jsonstudio/llms/dist/servertool/engine.js +60 -0
- package/node_modules/@jsonstudio/llms/dist/servertool/flow-types.d.ts +40 -0
- package/node_modules/@jsonstudio/llms/dist/servertool/flow-types.js +1 -0
- package/node_modules/@jsonstudio/llms/dist/servertool/handlers/vision.d.ts +1 -0
- package/node_modules/@jsonstudio/llms/dist/servertool/handlers/vision.js +194 -0
- package/node_modules/@jsonstudio/llms/dist/servertool/handlers/web-search.d.ts +1 -0
- package/node_modules/@jsonstudio/llms/dist/servertool/handlers/web-search.js +638 -0
- package/node_modules/@jsonstudio/llms/dist/servertool/orchestration-types.d.ts +33 -0
- package/node_modules/@jsonstudio/llms/dist/servertool/orchestration-types.js +1 -0
- package/node_modules/@jsonstudio/llms/dist/servertool/registry.d.ts +18 -0
- package/node_modules/@jsonstudio/llms/dist/servertool/registry.js +27 -0
- package/node_modules/@jsonstudio/llms/dist/servertool/server-side-tools.d.ts +8 -0
- package/node_modules/@jsonstudio/llms/dist/servertool/server-side-tools.js +208 -0
- package/node_modules/@jsonstudio/llms/dist/servertool/types.d.ts +88 -0
- package/node_modules/@jsonstudio/llms/dist/servertool/types.js +1 -0
- package/node_modules/@jsonstudio/llms/dist/servertool/vision-tool.d.ts +2 -0
- package/node_modules/@jsonstudio/llms/dist/servertool/vision-tool.js +185 -0
- package/node_modules/@jsonstudio/llms/dist/sse/json-to-sse/event-generators/responses.js +15 -3
- package/node_modules/@jsonstudio/llms/dist/sse/sse-to-json/builders/response-builder.js +6 -3
- package/node_modules/@jsonstudio/llms/dist/sse/sse-to-json/gemini-sse-to-json-converter.js +27 -1
- package/node_modules/@jsonstudio/llms/dist/sse/types/gemini-types.d.ts +20 -1
- package/node_modules/@jsonstudio/llms/dist/sse/types/responses-types.js +1 -1
- package/node_modules/@jsonstudio/llms/dist/telemetry/stats-center.d.ts +73 -0
- package/node_modules/@jsonstudio/llms/dist/telemetry/stats-center.js +280 -0
- package/node_modules/@jsonstudio/llms/package.json +1 -1
- package/package.json +2 -2
- package/scripts/pack-mode.mjs +2 -1
- package/scripts/publish-rcc.mjs +20 -4
- package/scripts/tests/virtual-router-health.mjs +141 -6
- package/dist/tools/replay-request.d.ts +0 -0
- package/dist/tools/replay-request.js +0 -2
- package/dist/tools/replay-request.js.map +0 -1
|
@@ -5,6 +5,7 @@ import { RoutingClassifier } from './classifier.js';
|
|
|
5
5
|
import { buildRoutingFeatures } from './features.js';
|
|
6
6
|
import { ContextAdvisor } from './context-advisor.js';
|
|
7
7
|
import { DEFAULT_MODEL_CONTEXT_TOKENS, DEFAULT_ROUTE, ROUTE_PRIORITY, VirtualRouterError, VirtualRouterErrorCode } from './types.js';
|
|
8
|
+
import { getStatsCenter } from '../../telemetry/stats-center.js';
|
|
8
9
|
export class VirtualRouterEngine {
|
|
9
10
|
routing = {};
|
|
10
11
|
providerRegistry = new ProviderRegistry();
|
|
@@ -16,6 +17,9 @@ export class VirtualRouterEngine {
|
|
|
16
17
|
routeStats = new Map();
|
|
17
18
|
debug = console; // thin hook; host may monkey-patch for colored logging
|
|
18
19
|
healthConfig = null;
|
|
20
|
+
statsCenter = getStatsCenter();
|
|
21
|
+
// Derived flags from VirtualRouterConfig/routing used by process / response layers.
|
|
22
|
+
webSearchForce = false;
|
|
19
23
|
initialize(config) {
|
|
20
24
|
this.validateConfig(config);
|
|
21
25
|
this.routing = config.routing;
|
|
@@ -27,6 +31,7 @@ export class VirtualRouterEngine {
|
|
|
27
31
|
this.classifier = new RoutingClassifier(config.classifier);
|
|
28
32
|
this.contextRouting = config.contextRouting ?? { warnRatio: 0.9, hardLimit: false };
|
|
29
33
|
this.contextAdvisor.configure(this.contextRouting);
|
|
34
|
+
this.webSearchForce = config.webSearch?.force === true;
|
|
30
35
|
this.routeStats = new Map();
|
|
31
36
|
for (const routeName of Object.keys(this.routing)) {
|
|
32
37
|
this.routeStats.set(routeName, { hits: 0 });
|
|
@@ -34,27 +39,56 @@ export class VirtualRouterEngine {
|
|
|
34
39
|
}
|
|
35
40
|
route(request, metadata) {
|
|
36
41
|
const features = buildRoutingFeatures(request, metadata);
|
|
37
|
-
const classification =
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
+
};
|
|
41
60
|
this.healthManager.recordSuccess(selection.providerKey);
|
|
42
61
|
this.incrementRouteStat(selection.routeUsed, selection.providerKey);
|
|
62
|
+
try {
|
|
63
|
+
this.statsCenter.recordVirtualRouterHit({
|
|
64
|
+
requestId: metadata.requestId,
|
|
65
|
+
timestamp: Date.now(),
|
|
66
|
+
entryEndpoint: metadata.entryEndpoint || '/v1/chat/completions',
|
|
67
|
+
routeName: selection.routeUsed,
|
|
68
|
+
pool: selection.poolId || selection.routeUsed,
|
|
69
|
+
providerKey: selection.providerKey,
|
|
70
|
+
modelId: target.modelId || undefined
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// stats must never break routing
|
|
75
|
+
}
|
|
43
76
|
const hitReason = this.buildHitReason(selection.routeUsed, selection.providerKey, classification, features);
|
|
44
|
-
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);
|
|
45
78
|
if (formatted) {
|
|
46
79
|
this.debug?.log?.(formatted);
|
|
47
80
|
}
|
|
48
81
|
else {
|
|
49
82
|
this.debug?.log?.('[virtual-router-hit]', selection.routeUsed, selection.providerKey, target.modelId || '', hitReason ? `reason=${hitReason}` : '');
|
|
50
83
|
}
|
|
51
|
-
const didFallback = selection.routeUsed !==
|
|
84
|
+
const didFallback = selection.routeUsed !== requestedRoute;
|
|
52
85
|
return {
|
|
53
86
|
target,
|
|
54
87
|
decision: {
|
|
55
88
|
routeName: selection.routeUsed,
|
|
56
89
|
providerKey: selection.providerKey,
|
|
57
90
|
pool: selection.pool,
|
|
91
|
+
poolId: selection.poolId,
|
|
58
92
|
confidence: classification.confidence,
|
|
59
93
|
reasoning: classification.reasoning,
|
|
60
94
|
fallback: didFallback
|
|
@@ -63,6 +97,7 @@ export class VirtualRouterEngine {
|
|
|
63
97
|
routeName: selection.routeUsed,
|
|
64
98
|
providerKey: selection.providerKey,
|
|
65
99
|
pool: selection.pool,
|
|
100
|
+
poolId: selection.poolId,
|
|
66
101
|
reasoning: classification.reasoning,
|
|
67
102
|
fallback: didFallback,
|
|
68
103
|
confidence: classification.confidence
|
|
@@ -92,10 +127,10 @@ export class VirtualRouterEngine {
|
|
|
92
127
|
}
|
|
93
128
|
getStatus() {
|
|
94
129
|
const routes = {};
|
|
95
|
-
for (const [route,
|
|
130
|
+
for (const [route, pools] of Object.entries(this.routing)) {
|
|
96
131
|
const stats = this.routeStats.get(route) ?? { hits: 0 };
|
|
97
132
|
routes[route] = {
|
|
98
|
-
providers:
|
|
133
|
+
providers: this.flattenPoolTargets(pools),
|
|
99
134
|
hits: stats.hits,
|
|
100
135
|
lastUsedProvider: stats.lastProvider
|
|
101
136
|
};
|
|
@@ -105,6 +140,14 @@ export class VirtualRouterEngine {
|
|
|
105
140
|
health: this.healthManager.getSnapshot()
|
|
106
141
|
};
|
|
107
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
|
+
}
|
|
108
151
|
validateConfig(config) {
|
|
109
152
|
if (!config.routing || typeof config.routing !== 'object') {
|
|
110
153
|
throw new VirtualRouterError('routing configuration is required', VirtualRouterErrorCode.CONFIG_ERROR);
|
|
@@ -112,32 +155,39 @@ export class VirtualRouterEngine {
|
|
|
112
155
|
if (!config.providers || Object.keys(config.providers).length === 0) {
|
|
113
156
|
throw new VirtualRouterError('providers configuration is required', VirtualRouterErrorCode.CONFIG_ERROR);
|
|
114
157
|
}
|
|
115
|
-
const
|
|
116
|
-
if (!
|
|
158
|
+
const defaultPools = config.routing[DEFAULT_ROUTE];
|
|
159
|
+
if (!this.routeHasTargets(defaultPools)) {
|
|
117
160
|
throw new VirtualRouterError('default route must be configured with at least one provider', VirtualRouterErrorCode.CONFIG_ERROR);
|
|
118
161
|
}
|
|
162
|
+
if (!this.hasPrimaryPool(defaultPools)) {
|
|
163
|
+
throw new VirtualRouterError('default route must define at least one non-backup pool', VirtualRouterErrorCode.CONFIG_ERROR);
|
|
164
|
+
}
|
|
119
165
|
const providerKeys = new Set(Object.keys(config.providers));
|
|
120
|
-
for (const [routeName,
|
|
121
|
-
if (!
|
|
166
|
+
for (const [routeName, pools] of Object.entries(config.routing)) {
|
|
167
|
+
if (!this.routeHasTargets(pools)) {
|
|
122
168
|
if (routeName === DEFAULT_ROUTE) {
|
|
123
169
|
throw new VirtualRouterError('default route cannot be empty', VirtualRouterErrorCode.CONFIG_ERROR);
|
|
124
170
|
}
|
|
125
171
|
continue;
|
|
126
172
|
}
|
|
127
|
-
for (const
|
|
128
|
-
if (!
|
|
129
|
-
|
|
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
|
+
}
|
|
130
181
|
}
|
|
131
182
|
}
|
|
132
183
|
}
|
|
133
184
|
}
|
|
134
185
|
selectProvider(requestedRoute, metadata, classification, features) {
|
|
135
|
-
const candidates = this.buildRouteCandidates(requestedRoute, classification.candidates);
|
|
186
|
+
const candidates = this.buildRouteCandidates(requestedRoute, classification.candidates, features);
|
|
136
187
|
const stickyKey = this.resolveStickyKey(metadata);
|
|
137
188
|
const attempted = [];
|
|
138
189
|
const visitedRoutes = new Set();
|
|
139
|
-
const
|
|
140
|
-
const routeQueue = this.initializeRouteQueue(candidates, fallbackRoute);
|
|
190
|
+
const routeQueue = this.initializeRouteQueue(candidates);
|
|
141
191
|
const estimatedTokens = typeof features.estimatedTokens === 'number' && Number.isFinite(features.estimatedTokens)
|
|
142
192
|
? Math.max(0, features.estimatedTokens)
|
|
143
193
|
: 0;
|
|
@@ -146,33 +196,93 @@ export class VirtualRouterEngine {
|
|
|
146
196
|
if (visitedRoutes.has(routeName)) {
|
|
147
197
|
continue;
|
|
148
198
|
}
|
|
149
|
-
const
|
|
150
|
-
if (!
|
|
199
|
+
const routePools = this.routing[routeName];
|
|
200
|
+
if (!this.routeHasTargets(routePools)) {
|
|
151
201
|
visitedRoutes.add(routeName);
|
|
152
|
-
attempted.push(routeName);
|
|
153
|
-
continue;
|
|
154
|
-
}
|
|
155
|
-
const contextResult = this.contextAdvisor.classify(pool, estimatedTokens, (key) => this.providerRegistry.get(key));
|
|
156
|
-
if (this.maybeDeferToFallback(routeName, contextResult, routeQueue, visitedRoutes, fallbackRoute)) {
|
|
202
|
+
attempted.push(`${routeName}:empty`);
|
|
157
203
|
continue;
|
|
158
204
|
}
|
|
159
205
|
visitedRoutes.add(routeName);
|
|
160
|
-
const
|
|
161
|
-
for (const
|
|
162
|
-
const providerKey = this.
|
|
163
|
-
routeName,
|
|
164
|
-
candidates: candidatePool,
|
|
165
|
-
stickyKey,
|
|
166
|
-
availabilityCheck: (key) => this.healthManager.isAvailable(key)
|
|
167
|
-
});
|
|
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);
|
|
168
209
|
if (providerKey) {
|
|
169
|
-
return { providerKey, routeUsed: routeName, pool };
|
|
210
|
+
return { providerKey, routeUsed: routeName, pool: poolTargets, poolId: tierId };
|
|
211
|
+
}
|
|
212
|
+
if (failureHint) {
|
|
213
|
+
attempted.push(failureHint);
|
|
170
214
|
}
|
|
171
215
|
}
|
|
172
|
-
attempted.push(this.describeAttempt(routeName, contextResult));
|
|
173
216
|
}
|
|
174
217
|
throw new VirtualRouterError(`All providers unavailable for route ${requestedRoute}`, VirtualRouterErrorCode.PROVIDER_NOT_AVAILABLE, { routeName: requestedRoute, attempted });
|
|
175
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
|
+
}
|
|
176
286
|
incrementRouteStat(routeName, providerKey) {
|
|
177
287
|
if (!this.routeStats.has(routeName)) {
|
|
178
288
|
this.routeStats.set(routeName, { hits: 0, lastProvider: providerKey });
|
|
@@ -185,63 +295,34 @@ export class VirtualRouterEngine {
|
|
|
185
295
|
providerHealthConfig() {
|
|
186
296
|
return this.healthManager.getConfig();
|
|
187
297
|
}
|
|
188
|
-
initializeRouteQueue(candidates
|
|
189
|
-
|
|
190
|
-
if (fallbackRoute && !queue.includes(fallbackRoute)) {
|
|
191
|
-
queue.push(fallbackRoute);
|
|
192
|
-
}
|
|
193
|
-
return queue;
|
|
194
|
-
}
|
|
195
|
-
resolveFallbackRoute() {
|
|
196
|
-
const candidate = this.contextRouting?.fallbackRoute;
|
|
197
|
-
if (!candidate) {
|
|
198
|
-
return undefined;
|
|
199
|
-
}
|
|
200
|
-
const pool = this.routing[candidate];
|
|
201
|
-
if (!Array.isArray(pool) || pool.length === 0) {
|
|
202
|
-
return undefined;
|
|
203
|
-
}
|
|
204
|
-
return candidate;
|
|
205
|
-
}
|
|
206
|
-
maybeDeferToFallback(routeName, contextResult, queue, visited, fallbackRoute) {
|
|
207
|
-
if (!fallbackRoute || fallbackRoute === routeName || visited.has(fallbackRoute)) {
|
|
208
|
-
return false;
|
|
209
|
-
}
|
|
210
|
-
if (!this.contextAdvisor.prefersFallback(contextResult)) {
|
|
211
|
-
return false;
|
|
212
|
-
}
|
|
213
|
-
const fallbackPool = this.routing[fallbackRoute];
|
|
214
|
-
if (!Array.isArray(fallbackPool) || fallbackPool.length === 0) {
|
|
215
|
-
return false;
|
|
216
|
-
}
|
|
217
|
-
queue.unshift(routeName);
|
|
218
|
-
queue.unshift(fallbackRoute);
|
|
219
|
-
return true;
|
|
298
|
+
initializeRouteQueue(candidates) {
|
|
299
|
+
return Array.from(new Set(candidates));
|
|
220
300
|
}
|
|
221
301
|
buildContextCandidatePools(result) {
|
|
222
302
|
const ordered = [];
|
|
223
303
|
if (result.safe.length) {
|
|
224
304
|
ordered.push(result.safe);
|
|
305
|
+
// 如果存在安全候选,直接放弃当前处于警戒阈值的模型
|
|
306
|
+
return ordered;
|
|
225
307
|
}
|
|
226
308
|
if (result.risky.length) {
|
|
227
309
|
ordered.push(result.risky);
|
|
228
310
|
}
|
|
229
|
-
|
|
230
|
-
ordered.push(result.overflow);
|
|
231
|
-
}
|
|
311
|
+
// ratio >= 1 视为上下文溢出,直接标记为不可用
|
|
232
312
|
return ordered;
|
|
233
313
|
}
|
|
234
|
-
describeAttempt(routeName, result) {
|
|
314
|
+
describeAttempt(routeName, poolId, result) {
|
|
315
|
+
const prefix = poolId ? `${routeName}:${poolId}` : routeName;
|
|
235
316
|
if (result.safe.length > 0) {
|
|
236
|
-
return `${
|
|
317
|
+
return `${prefix}:health`;
|
|
237
318
|
}
|
|
238
319
|
if (result.risky.length > 0) {
|
|
239
|
-
return `${
|
|
320
|
+
return `${prefix}:context_risky`;
|
|
240
321
|
}
|
|
241
322
|
if (result.overflow.length > 0) {
|
|
242
|
-
return `${
|
|
323
|
+
return `${prefix}:max_context_window`;
|
|
243
324
|
}
|
|
244
|
-
return
|
|
325
|
+
return prefix;
|
|
245
326
|
}
|
|
246
327
|
resolveStickyKey(metadata) {
|
|
247
328
|
const resume = metadata.responsesResume;
|
|
@@ -332,12 +413,32 @@ export class VirtualRouterEngine {
|
|
|
332
413
|
return 'client_error';
|
|
333
414
|
return 'unknown';
|
|
334
415
|
}
|
|
335
|
-
buildRouteCandidates(requestedRoute, classificationCandidates) {
|
|
336
|
-
const
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
+
}
|
|
341
442
|
const deduped = [];
|
|
342
443
|
for (const routeName of ordered) {
|
|
343
444
|
if (routeName && !deduped.includes(routeName)) {
|
|
@@ -347,17 +448,59 @@ export class VirtualRouterEngine {
|
|
|
347
448
|
if (!deduped.includes(DEFAULT_ROUTE)) {
|
|
348
449
|
deduped.push(DEFAULT_ROUTE);
|
|
349
450
|
}
|
|
350
|
-
const filtered = deduped.filter((routeName) =>
|
|
351
|
-
|
|
352
|
-
return Array.isArray(pool) && pool.length > 0;
|
|
353
|
-
});
|
|
354
|
-
if (!filtered.includes(DEFAULT_ROUTE) &&
|
|
355
|
-
Array.isArray(this.routing[DEFAULT_ROUTE]) &&
|
|
356
|
-
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])) {
|
|
357
453
|
filtered.push(DEFAULT_ROUTE);
|
|
358
454
|
}
|
|
359
455
|
return filtered.length ? filtered : [DEFAULT_ROUTE];
|
|
360
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
|
+
}
|
|
361
504
|
sortByPriority(routeNames) {
|
|
362
505
|
return [...routeNames].sort((a, b) => this.routeWeight(a) - this.routeWeight(b));
|
|
363
506
|
}
|
|
@@ -365,6 +508,59 @@ export class VirtualRouterEngine {
|
|
|
365
508
|
const idx = ROUTE_PRIORITY.indexOf(routeName);
|
|
366
509
|
return idx >= 0 ? idx : ROUTE_PRIORITY.length;
|
|
367
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
|
+
}
|
|
368
564
|
buildHitReason(routeUsed, providerKey, classification, features) {
|
|
369
565
|
const reasoning = classification.reasoning || '';
|
|
370
566
|
const primary = reasoning.split('|')[0] || '';
|
|
@@ -379,8 +575,8 @@ export class VirtualRouterEngine {
|
|
|
379
575
|
if (routeUsed === 'coding') {
|
|
380
576
|
return this.decorateWithDetail(primary || 'coding', primary, commandDetail);
|
|
381
577
|
}
|
|
382
|
-
if (routeUsed === '
|
|
383
|
-
return this.decorateWithDetail(primary ||
|
|
578
|
+
if (routeUsed === 'web_search' || routeUsed === 'search') {
|
|
579
|
+
return this.decorateWithDetail(primary || routeUsed, primary, commandDetail);
|
|
384
580
|
}
|
|
385
581
|
if (routeUsed === DEFAULT_ROUTE && classification.fallback) {
|
|
386
582
|
return primary || 'fallback:default';
|
|
@@ -406,18 +602,31 @@ export class VirtualRouterEngine {
|
|
|
406
602
|
}
|
|
407
603
|
return `${baseLabel}(${normalizedDetail})`;
|
|
408
604
|
}
|
|
409
|
-
formatVirtualRouterHit(routeName, providerKey, modelId, hitReason) {
|
|
605
|
+
formatVirtualRouterHit(routeName, poolId, providerKey, modelId, hitReason) {
|
|
410
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}`;
|
|
411
613
|
const prefixColor = '\x1b[38;5;208m';
|
|
412
614
|
const reset = '\x1b[0m';
|
|
615
|
+
const timeColor = '\x1b[90m'; // 灰色
|
|
413
616
|
const routeColor = this.resolveRouteColor(routeName);
|
|
414
617
|
const prefix = `${prefixColor}[virtual-router-hit]${reset}`;
|
|
415
|
-
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 : ''}`;
|
|
416
622
|
const reasonLabel = hitReason ? ` reason=${hitReason}` : '';
|
|
417
|
-
return `${prefix} ${routeColor}${targetLabel}${reasonLabel}${reset}`;
|
|
623
|
+
return `${prefix} ${timeLabel} ${routeColor}${targetLabel}${reasonLabel}${reset}`;
|
|
418
624
|
}
|
|
419
625
|
catch {
|
|
420
|
-
|
|
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}` : ''}`;
|
|
421
630
|
}
|
|
422
631
|
}
|
|
423
632
|
resolveRouteColor(routeName) {
|
|
@@ -427,7 +636,8 @@ export class VirtualRouterEngine {
|
|
|
427
636
|
thinking: '\x1b[34m',
|
|
428
637
|
coding: '\x1b[35m',
|
|
429
638
|
longcontext: '\x1b[38;5;141m',
|
|
430
|
-
|
|
639
|
+
web_search: '\x1b[32m',
|
|
640
|
+
search: '\x1b[38;5;34m',
|
|
431
641
|
vision: '\x1b[38;5;207m',
|
|
432
642
|
background: '\x1b[90m'
|
|
433
643
|
};
|
|
@@ -457,4 +667,31 @@ export class VirtualRouterEngine {
|
|
|
457
667
|
}
|
|
458
668
|
return `${ratio.toFixed(2)}/${Math.round(limit)}`;
|
|
459
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
|
+
}
|
|
460
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);
|