@jsonstudio/llms 0.6.795 → 0.6.938
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/bridge/routecodex-adapter.d.ts +74 -0
- package/dist/config-unified/enhanced-path-resolver.d.ts +5 -0
- package/dist/config-unified/unified-config.d.ts +26 -0
- package/dist/conversion/codec-registry.d.ts +10 -0
- package/dist/conversion/codecs/gemini-openai-codec.d.ts +16 -0
- package/dist/conversion/codecs/openai-openai-codec.d.ts +12 -0
- package/dist/conversion/codecs/responses-openai-codec.d.ts +12 -0
- package/dist/conversion/compat/profiles/chat-gemini.json +12 -0
- package/dist/conversion/config/config-manager.d.ts +212 -0
- package/dist/conversion/hub/config/types.d.ts +26 -0
- package/dist/conversion/hub/core/detour-registry.d.ts +9 -0
- package/dist/conversion/hub/core/hub-context.d.ts +21 -0
- package/dist/conversion/hub/core/index.d.ts +3 -0
- package/dist/conversion/hub/core/stage-driver.d.ts +30 -0
- package/dist/conversion/hub/format-adapters/anthropic-format-adapter.d.ts +16 -0
- package/dist/conversion/hub/format-adapters/chat-format-adapter.d.ts +17 -0
- package/dist/conversion/hub/format-adapters/gemini-format-adapter.d.ts +16 -0
- package/dist/conversion/hub/format-adapters/index.d.ts +21 -0
- package/dist/conversion/hub/hub-feature.d.ts +1 -0
- package/dist/conversion/hub/node-support.d.ts +19 -0
- package/dist/conversion/hub/pipeline/compat/compat-pipeline-executor.js +11 -0
- package/dist/conversion/hub/pipeline/compat/compat-types.d.ts +3 -0
- package/dist/conversion/hub/pipeline/hub-pipeline.d.ts +7 -0
- package/dist/conversion/hub/pipeline/hub-pipeline.js +71 -14
- package/dist/conversion/hub/pipeline/stages/req_process/req_process_stage1_tool_governance/index.js +4 -0
- package/dist/conversion/hub/pipeline/stages/resp_outbound/resp_outbound_stage1_client_remap/index.js +23 -1
- package/dist/conversion/hub/pipelines/inbound.d.ts +22 -0
- package/dist/conversion/hub/pipelines/outbound.d.ts +22 -0
- package/dist/conversion/hub/policy/policy-engine.d.ts +46 -0
- package/dist/conversion/hub/policy/policy-engine.js +176 -0
- package/dist/conversion/hub/policy/protocol-spec.d.ts +50 -0
- package/dist/conversion/hub/policy/protocol-spec.js +105 -0
- package/dist/conversion/hub/process/chat-process.d.ts +32 -0
- package/dist/conversion/hub/registry.d.ts +28 -0
- package/dist/conversion/hub/response/chat-response-utils.d.ts +6 -0
- package/dist/conversion/hub/response/provider-response.js +31 -0
- package/dist/conversion/hub/semantic-mappers/gemini-mapper.d.ts +7 -0
- package/dist/conversion/hub/semantic-mappers/gemini-mapper.js +87 -1
- package/dist/conversion/hub/semantic-mappers/index.d.ts +4 -0
- package/dist/conversion/hub/semantic-mappers/responses-mapper.d.ts +21 -0
- package/dist/conversion/hub/standardized-bridge.d.ts +12 -0
- package/dist/conversion/hub/types/chat-schema.d.ts +112 -0
- package/dist/conversion/hub/types/errors.d.ts +5 -0
- package/dist/conversion/hub/types/format-envelope.d.ts +7 -0
- package/dist/conversion/hub/types/index.d.ts +6 -0
- package/dist/conversion/hub/types/json.d.ts +9 -0
- package/dist/conversion/hub/types/node.d.ts +31 -0
- package/dist/conversion/responses/responses-openai-bridge.js +263 -10
- package/dist/conversion/schema-validator.d.ts +7 -0
- package/dist/conversion/shared/args-mapping.d.ts +18 -0
- package/dist/conversion/shared/chat-request-filters.d.ts +9 -0
- package/dist/conversion/shared/errors.d.ts +1 -1
- package/dist/conversion/shared/gemini-tool-utils.js +61 -0
- package/dist/conversion/shared/jsonish.d.ts +3 -0
- package/dist/conversion/shared/mcp-injection.d.ts +2 -0
- package/dist/conversion/shared/media.d.ts +1 -0
- package/dist/conversion/shared/openai-message-normalize.d.ts +1 -0
- package/dist/conversion/shared/payload-budget.d.ts +13 -0
- package/dist/conversion/shared/reasoning-mapping.d.ts +5 -0
- package/dist/conversion/shared/responses-request-adapter.d.ts +1 -28
- package/dist/conversion/shared/responses-request-adapter.js +1 -430
- package/dist/conversion/shared/snapshot-hooks.js +112 -4
- package/dist/conversion/shared/tool-governor.js +8 -2
- package/dist/conversion/shared/tool-harvester.d.ts +31 -0
- package/dist/conversion/shared/tool-mapping.js +10 -29
- package/dist/conversion/types.d.ts +33 -0
- package/dist/filters/builtin/add-fields-filter.d.ts +8 -0
- package/dist/filters/builtin/blacklist-filter.d.ts +8 -0
- package/dist/filters/builtin/whitelist-filter.d.ts +8 -0
- package/dist/filters/engine.d.ts +16 -0
- package/dist/filters/special/request-tool-choice-policy.d.ts +11 -0
- package/dist/filters/special/response-finish-invariants.d.ts +11 -0
- package/dist/filters/special/response-openai-to-responses-bridge.d.ts +13 -0
- package/dist/filters/special/response-tool-arguments-blacklist.d.ts +12 -0
- package/dist/filters/special/response-tool-arguments-schema-converge.d.ts +13 -0
- package/dist/filters/special/response-tool-arguments-stringify.d.ts +9 -0
- package/dist/filters/special/response-tool-arguments-whitelist.d.ts +11 -0
- package/dist/filters/special/tool-filter-hooks.d.ts +19 -0
- package/dist/filters/special/tool-post-constraints.d.ts +31 -0
- package/dist/filters/types.d.ts +68 -0
- package/dist/filters/utils/fieldmap-loader.d.ts +2 -0
- package/dist/filters/utils/snapshot-writer.d.ts +10 -0
- package/dist/guidance/index.d.ts +3 -0
- package/dist/guidance/index.js +78 -83
- package/dist/http/sse-response.d.ts +22 -0
- package/dist/router/virtual-router/bootstrap.d.ts +6 -0
- package/dist/router/virtual-router/bootstrap.js +49 -5
- package/dist/router/virtual-router/classifier.d.ts +10 -0
- package/dist/router/virtual-router/engine-selection.js +147 -15
- package/dist/router/virtual-router/engine.js +177 -31
- package/dist/router/virtual-router/error-center.d.ts +10 -0
- package/dist/router/virtual-router/features.d.ts +3 -0
- package/dist/router/virtual-router/routing-instructions.d.ts +23 -1
- package/dist/router/virtual-router/routing-instructions.js +120 -30
- package/dist/router/virtual-router/types.d.ts +11 -0
- package/dist/servertool/engine.js +189 -16
- package/dist/servertool/handlers/apply-patch-guard.js +269 -0
- package/dist/servertool/handlers/exec-command-guard.js +558 -0
- package/dist/servertool/handlers/followup-message-trimmer.d.ts +16 -0
- package/dist/servertool/handlers/followup-message-trimmer.js +198 -0
- package/dist/servertool/handlers/followup-request-builder.d.ts +17 -0
- package/dist/servertool/handlers/followup-request-builder.js +122 -0
- package/dist/servertool/handlers/gemini-empty-reply-continue.js +252 -51
- package/dist/servertool/handlers/iflow-model-error-retry.js +12 -22
- package/dist/servertool/handlers/stop-message-auto.js +237 -75
- package/dist/servertool/handlers/vision.js +15 -27
- package/dist/servertool/handlers/web-search.js +17 -43
- package/dist/servertool/server-side-tools.d.ts +3 -0
- package/dist/servertool/server-side-tools.js +3 -0
- package/dist/sse/json-to-sse/anthropic-json-to-sse-converter.d.ts +2 -1
- package/dist/sse/json-to-sse/chat-json-to-sse-converter.d.ts +80 -0
- package/dist/sse/json-to-sse/event-generators/chat.d.ts +55 -0
- package/dist/sse/json-to-sse/event-generators/responses.d.ts +99 -0
- package/dist/sse/json-to-sse/gemini-json-to-sse-converter.d.ts +2 -1
- package/dist/sse/json-to-sse/responses-json-to-sse-converter.d.ts +80 -0
- package/dist/sse/json-to-sse/sequencers/anthropic-sequencer.d.ts +1 -1
- package/dist/sse/json-to-sse/sequencers/chat-sequencer.d.ts +2 -2
- package/dist/sse/json-to-sse/sequencers/gemini-sequencer.d.ts +1 -1
- package/dist/sse/json-to-sse/sequencers/responses-sequencer.d.ts +40 -0
- package/dist/sse/shared/chat-serializer.d.ts +4 -0
- package/dist/sse/shared/constants.d.ts +272 -0
- package/dist/sse/shared/serializers/anthropic-event-serializer.d.ts +1 -1
- package/dist/sse/shared/serializers/base-serializer.d.ts +158 -0
- package/dist/sse/shared/serializers/chat-event-serializer.d.ts +82 -0
- package/dist/sse/shared/serializers/gemini-event-serializer.d.ts +1 -1
- package/dist/sse/shared/serializers/index.d.ts +2 -1
- package/dist/sse/shared/serializers/responses-event-serializer.d.ts +123 -0
- package/dist/sse/shared/serializers/types.d.ts +51 -0
- package/dist/sse/shared/utils.d.ts +254 -0
- package/dist/sse/shared/writer.d.ts +2 -2
- package/dist/sse/sse-to-json/anthropic-sse-to-json-converter.d.ts +1 -1
- package/dist/sse/sse-to-json/builders/anthropic-response-builder.d.ts +1 -1
- package/dist/sse/sse-to-json/builders/response-builder.d.ts +1 -1
- package/dist/sse/sse-to-json/chat-sse-to-json-converter.d.ts +2 -1
- package/dist/sse/sse-to-json/gemini-sse-to-json-converter.d.ts +2 -1
- package/dist/sse/sse-to-json/parsers/sse-parser.d.ts +73 -0
- package/dist/sse/sse-to-json/responses-sse-to-json-converter.d.ts +1 -1
- package/dist/sse/types/chat-types.d.ts +1 -1
- package/dist/sse/types/responses-types.d.ts +1 -1
- package/dist/tools/apply-patch/execution-capturer.d.ts +13 -0
- package/dist/tools/apply-patch/execution-capturer.js +158 -0
- package/dist/tools/apply-patch/regression-capturer.d.ts +1 -0
- package/dist/tools/apply-patch/regression-capturer.js +5 -4
- package/dist/tools/apply-patch/structured.js +109 -13
- package/dist/tools/apply-patch/validator.js +261 -17
- package/dist/tools/tool-registry.d.ts +8 -0
- package/dist/tools/tool-registry.js +2 -1
- package/package.json +4 -4
- package/dist/conversion/compat/actions/apply-patch-format-fixer.js +0 -233
- package/dist/conversion/config/compat-profiles.json +0 -38
- package/dist/conversion/hub/response/server-side-tools.d.ts +0 -26
- package/dist/conversion/hub/response/server-side-tools.js +0 -383
- package/dist/conversion/shared/bridge-conversation-store.d.ts +0 -41
- package/dist/conversion/shared/bridge-conversation-store.js +0 -279
- package/dist/conversion/shared/bridge-request-adapter.d.ts +0 -28
- package/dist/conversion/shared/bridge-request-adapter.js +0 -430
- package/dist/conversion/shared/responses-id-utils.js +0 -42
- package/dist/conversion/shared/responses-instructions.js +0 -113
- package/dist/conversion/shared/responses-message-utils.d.ts +0 -15
- package/dist/conversion/shared/responses-message-utils.js +0 -206
- package/dist/conversion/shared/responses-metadata.js +0 -1
- package/dist/conversion/shared/responses-output-utils.d.ts +0 -7
- package/dist/conversion/shared/responses-output-utils.js +0 -108
- package/dist/conversion/shared/responses-types.d.ts +0 -33
- package/dist/conversion/shared/tool-normalizers.d.ts +0 -4
- package/dist/conversion/shared/tool-normalizers.js +0 -84
- package/dist/filters/special/request-streaming-to-nonstreaming.d.ts +0 -13
- package/dist/filters/special/request-streaming-to-nonstreaming.js +0 -39
- package/dist/filters/special/response-apply-patch-toon-decode.d.ts +0 -23
- package/dist/filters/special/response-apply-patch-toon-decode.js +0 -460
- package/dist/filters/special/response-tool-arguments-toon-decode.d.ts +0 -10
- package/dist/filters/special/response-tool-arguments-toon-decode.js +0 -154
- package/dist/servertool/flow-types.d.ts +0 -40
- package/dist/servertool/flow-types.js +0 -1
- package/dist/servertool/orchestration-types.d.ts +0 -33
- package/dist/servertool/orchestration-types.js +0 -1
- package/dist/servertool/vision-tool.d.ts +0 -2
- package/dist/servertool/vision-tool.js +0 -185
- package/dist/tools/patch-args-normalizer.d.ts +0 -15
- package/dist/tools/patch-args-normalizer.js +0 -472
- package/dist/utils/toon.d.ts +0 -4
- package/dist/utils/toon.js +0 -75
- /package/dist/{conversion/compat/actions/apply-patch-format-fixer.d.ts → servertool/handlers/apply-patch-guard.d.ts} +0 -0
- /package/dist/{conversion/shared/responses-types.js → servertool/handlers/exec-command-guard.d.ts} +0 -0
|
@@ -29,12 +29,32 @@ export function bootstrapVirtualRouterConfig(input) {
|
|
|
29
29
|
}
|
|
30
30
|
const webSearch = normalizeWebSearch(section.webSearch, routingSource);
|
|
31
31
|
validateWebSearchRouting(webSearch, routingSource);
|
|
32
|
-
const
|
|
32
|
+
const execCommandGuard = normalizeExecCommandGuard(section.execCommandGuard);
|
|
33
|
+
const { runtimeEntries, aliasIndex, modelIndex } = buildProviderRuntimeEntries(providersSource);
|
|
33
34
|
const { routing, targetKeys } = expandRoutingTable(routingSource, aliasIndex);
|
|
34
35
|
if (!routing.default || routing.default.length === 0) {
|
|
35
36
|
throw new VirtualRouterError('Virtual Router default route must contain at least one provider target', VirtualRouterErrorCode.CONFIG_ERROR);
|
|
36
37
|
}
|
|
37
|
-
|
|
38
|
+
// Build provider profiles for:
|
|
39
|
+
// - routing targets (targetKeys), and
|
|
40
|
+
// - all declared provider models across all auth aliases (modelIndex × aliasIndex),
|
|
41
|
+
// so that "direct model" and "prefer model" routing can bypass route pools when needed.
|
|
42
|
+
const expandedTargetKeys = new Set(targetKeys);
|
|
43
|
+
for (const [providerId, aliases] of aliasIndex.entries()) {
|
|
44
|
+
const models = modelIndex.get(providerId) ?? [];
|
|
45
|
+
if (!models.length || !aliases.length) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
for (const alias of aliases) {
|
|
49
|
+
const runtimeKey = buildRuntimeKey(providerId, alias);
|
|
50
|
+
for (const modelId of models) {
|
|
51
|
+
if (modelId && typeof modelId === 'string') {
|
|
52
|
+
expandedTargetKeys.add(`${runtimeKey}.${modelId}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const { profiles: providerProfiles, targetRuntime } = buildProviderProfiles(expandedTargetKeys, runtimeEntries);
|
|
38
58
|
const classifier = normalizeClassifier(section.classifier);
|
|
39
59
|
const loadBalancing = section.loadBalancing ?? DEFAULT_LOAD_BALANCING;
|
|
40
60
|
const health = section.health ?? DEFAULT_HEALTH;
|
|
@@ -46,7 +66,8 @@ export function bootstrapVirtualRouterConfig(input) {
|
|
|
46
66
|
loadBalancing,
|
|
47
67
|
health,
|
|
48
68
|
contextRouting,
|
|
49
|
-
...(webSearch ? { webSearch } : {})
|
|
69
|
+
...(webSearch ? { webSearch } : {}),
|
|
70
|
+
...(execCommandGuard ? { execCommandGuard } : {})
|
|
50
71
|
};
|
|
51
72
|
return {
|
|
52
73
|
config,
|
|
@@ -68,13 +89,17 @@ function extractVirtualRouterSection(input) {
|
|
|
68
89
|
const health = normalizeHealth(section.health ?? root.health);
|
|
69
90
|
const contextRouting = normalizeContextRouting(section.contextRouting ?? root.contextRouting);
|
|
70
91
|
const webSearch = section.webSearch ?? root.webSearch;
|
|
71
|
-
|
|
92
|
+
const execCommandGuard = section.execCommandGuard ?? root.execCommandGuard;
|
|
93
|
+
return { providers, routing, classifier, loadBalancing, health, contextRouting, webSearch, execCommandGuard };
|
|
72
94
|
}
|
|
73
95
|
function buildProviderRuntimeEntries(providers) {
|
|
74
96
|
const runtimeEntries = {};
|
|
75
97
|
const aliasIndex = new Map();
|
|
98
|
+
const modelIndex = new Map();
|
|
76
99
|
for (const [providerId, providerRaw] of Object.entries(providers)) {
|
|
77
100
|
const normalizedProvider = normalizeProvider(providerId, providerRaw);
|
|
101
|
+
const modelsNode = asRecord(providerRaw?.models);
|
|
102
|
+
modelIndex.set(providerId, modelsNode ? Object.keys(modelsNode).filter(Boolean) : []);
|
|
78
103
|
const authEntries = extractProviderAuthEntries(providerId, providerRaw);
|
|
79
104
|
if (!authEntries.length) {
|
|
80
105
|
throw new VirtualRouterError(`Provider ${providerId} requires at least one auth entry`, VirtualRouterErrorCode.CONFIG_ERROR);
|
|
@@ -126,7 +151,7 @@ function buildProviderRuntimeEntries(providers) {
|
|
|
126
151
|
};
|
|
127
152
|
}
|
|
128
153
|
}
|
|
129
|
-
return { runtimeEntries, aliasIndex };
|
|
154
|
+
return { runtimeEntries, aliasIndex, modelIndex };
|
|
130
155
|
}
|
|
131
156
|
function expandRoutingTable(routingSource, aliasIndex) {
|
|
132
157
|
const routing = {};
|
|
@@ -690,6 +715,25 @@ function normalizeWebSearch(input, routingSource) {
|
|
|
690
715
|
...(force ? { force } : {})
|
|
691
716
|
};
|
|
692
717
|
}
|
|
718
|
+
function normalizeExecCommandGuard(input) {
|
|
719
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
720
|
+
return undefined;
|
|
721
|
+
}
|
|
722
|
+
const record = input;
|
|
723
|
+
const enabledRaw = record.enabled;
|
|
724
|
+
const enabled = enabledRaw === true ||
|
|
725
|
+
(typeof enabledRaw === 'string' && enabledRaw.trim().toLowerCase() === 'true') ||
|
|
726
|
+
(typeof enabledRaw === 'number' && enabledRaw === 1);
|
|
727
|
+
if (!enabled) {
|
|
728
|
+
return undefined;
|
|
729
|
+
}
|
|
730
|
+
const policyFileRaw = record.policyFile ?? record?.policy_file;
|
|
731
|
+
const policyFile = typeof policyFileRaw === 'string' && policyFileRaw.trim().length ? policyFileRaw.trim() : undefined;
|
|
732
|
+
return {
|
|
733
|
+
enabled: true,
|
|
734
|
+
...(policyFile ? { policyFile } : {})
|
|
735
|
+
};
|
|
736
|
+
}
|
|
693
737
|
function extractProviderAuthEntries(providerId, raw) {
|
|
694
738
|
const provider = asRecord(raw);
|
|
695
739
|
const auth = asRecord(provider.auth);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ClassificationResult, RoutingFeatures, VirtualRouterClassifierConfig } from './types.js';
|
|
2
|
+
export declare class RoutingClassifier {
|
|
3
|
+
private readonly config;
|
|
4
|
+
constructor(config: VirtualRouterClassifierConfig);
|
|
5
|
+
classify(features: RoutingFeatures): ClassificationResult;
|
|
6
|
+
private buildResult;
|
|
7
|
+
private ensureDefaultCandidate;
|
|
8
|
+
private orderRoutes;
|
|
9
|
+
private routeWeight;
|
|
10
|
+
}
|
|
@@ -76,13 +76,41 @@ export function selectProviderImpl(requestedRoute, metadata, classification, fea
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
if (forcedKeySet.size > 0) {
|
|
79
|
-
const candidates = buildRouteCandidates(requestedRoute, classification.candidates, features, deps.routing, deps.providerRegistry);
|
|
79
|
+
const candidates = extendRouteCandidatesForState(buildRouteCandidates(requestedRoute, classification.candidates, features, deps.routing, deps.providerRegistry), state, deps.routing);
|
|
80
80
|
const filteredCandidates = filterCandidatesByRoutingState(candidates, state, deps.routing, deps.providerRegistry);
|
|
81
81
|
if (filteredCandidates.length === 0) {
|
|
82
|
-
|
|
82
|
+
const allowedProviders = Array.from(state.allowedProviders);
|
|
83
|
+
const disabledProviders = Array.from(state.disabledProviders);
|
|
84
|
+
const providersInRouting = new Set();
|
|
85
|
+
for (const pools of Object.values(deps.routing)) {
|
|
86
|
+
if (!Array.isArray(pools))
|
|
87
|
+
continue;
|
|
88
|
+
for (const pool of pools) {
|
|
89
|
+
if (!pool || !Array.isArray(pool.targets))
|
|
90
|
+
continue;
|
|
91
|
+
for (const key of pool.targets) {
|
|
92
|
+
if (typeof key !== 'string' || !key)
|
|
93
|
+
continue;
|
|
94
|
+
const providerId = extractProviderId(key);
|
|
95
|
+
if (providerId) {
|
|
96
|
+
providersInRouting.add(providerId);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const missingAllowedProviders = allowedProviders.length > 0 ? allowedProviders.filter((provider) => !providersInRouting.has(provider)) : [];
|
|
102
|
+
const hint = (() => {
|
|
103
|
+
if (missingAllowedProviders.length > 0) {
|
|
104
|
+
return `Allowed providers not present in routing pools: ${missingAllowedProviders.join(', ')}`;
|
|
105
|
+
}
|
|
106
|
+
return 'Routing instructions excluded all route candidates';
|
|
107
|
+
})();
|
|
108
|
+
throw new VirtualRouterError(`No available providers after applying routing instructions (${hint}). ` +
|
|
109
|
+
`Tip: remove/adjust <**...**> routing instructions (or use <**clear**>), or add providers/models to routing.`, VirtualRouterErrorCode.PROVIDER_NOT_AVAILABLE, {
|
|
83
110
|
requestedRoute,
|
|
84
|
-
allowedProviders
|
|
85
|
-
disabledProviders
|
|
111
|
+
allowedProviders,
|
|
112
|
+
disabledProviders,
|
|
113
|
+
missingAllowedProviders
|
|
86
114
|
});
|
|
87
115
|
}
|
|
88
116
|
return selectFromCandidates(filteredCandidates, metadata, classification, features, state, deps, {
|
|
@@ -98,18 +126,65 @@ export function selectProviderImpl(requestedRoute, metadata, classification, fea
|
|
|
98
126
|
}
|
|
99
127
|
}
|
|
100
128
|
const candidates = buildRouteCandidates(requestedRoute, classification.candidates, features, deps.routing, deps.providerRegistry);
|
|
101
|
-
const
|
|
129
|
+
const expandedCandidates = extendRouteCandidatesForState(candidates, state, deps.routing);
|
|
130
|
+
const filteredCandidates = filterCandidatesByRoutingState(expandedCandidates, state, deps.routing, deps.providerRegistry);
|
|
102
131
|
if (filteredCandidates.length === 0) {
|
|
103
|
-
|
|
132
|
+
const allowedProviders = Array.from(state.allowedProviders);
|
|
133
|
+
const disabledProviders = Array.from(state.disabledProviders);
|
|
134
|
+
const providersInRouting = new Set();
|
|
135
|
+
for (const pools of Object.values(deps.routing)) {
|
|
136
|
+
if (!Array.isArray(pools))
|
|
137
|
+
continue;
|
|
138
|
+
for (const pool of pools) {
|
|
139
|
+
if (!pool || !Array.isArray(pool.targets))
|
|
140
|
+
continue;
|
|
141
|
+
for (const key of pool.targets) {
|
|
142
|
+
if (typeof key !== 'string' || !key)
|
|
143
|
+
continue;
|
|
144
|
+
const providerId = extractProviderId(key);
|
|
145
|
+
if (providerId) {
|
|
146
|
+
providersInRouting.add(providerId);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const missingAllowedProviders = allowedProviders.length > 0 ? allowedProviders.filter((provider) => !providersInRouting.has(provider)) : [];
|
|
152
|
+
const hint = (() => {
|
|
153
|
+
if (missingAllowedProviders.length > 0) {
|
|
154
|
+
return `Allowed providers not present in routing pools: ${missingAllowedProviders.join(', ')}`;
|
|
155
|
+
}
|
|
156
|
+
return 'Routing instructions excluded all route candidates';
|
|
157
|
+
})();
|
|
158
|
+
throw new VirtualRouterError(`No available providers after applying routing instructions (${hint}). ` +
|
|
159
|
+
`Tip: remove/adjust <**...**> routing instructions (or use <**clear**>), or add providers/models to routing.`, VirtualRouterErrorCode.PROVIDER_NOT_AVAILABLE, {
|
|
104
160
|
requestedRoute,
|
|
105
|
-
allowedProviders
|
|
106
|
-
disabledProviders
|
|
161
|
+
allowedProviders,
|
|
162
|
+
disabledProviders,
|
|
163
|
+
missingAllowedProviders
|
|
107
164
|
});
|
|
108
165
|
}
|
|
109
166
|
return selectFromCandidates(filteredCandidates, metadata, classification, features, state, deps, {
|
|
110
167
|
allowAliasRotation
|
|
111
168
|
});
|
|
112
169
|
}
|
|
170
|
+
function extendRouteCandidatesForState(candidates, state, routing) {
|
|
171
|
+
// When provider allowlists are active (e.g. "<**!glm**>"), routing should not be bounded by
|
|
172
|
+
// classifier candidates only. Otherwise, a perfectly valid provider that exists in config
|
|
173
|
+
// (e.g. in a backup/default pool) can become unreachable and cause PROVIDER_NOT_AVAILABLE.
|
|
174
|
+
//
|
|
175
|
+
// We keep original ordering, then append all known routes (by priority) as a fallback search space.
|
|
176
|
+
if (!state.allowedProviders || state.allowedProviders.size === 0) {
|
|
177
|
+
return candidates;
|
|
178
|
+
}
|
|
179
|
+
const allRoutes = sortByPriority(Object.keys(routing).filter((routeName) => routeName && routeHasTargets(routing[routeName])));
|
|
180
|
+
const expanded = Array.isArray(candidates) ? [...candidates] : [];
|
|
181
|
+
for (const routeName of allRoutes) {
|
|
182
|
+
if (!expanded.includes(routeName)) {
|
|
183
|
+
expanded.push(routeName);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return expanded;
|
|
187
|
+
}
|
|
113
188
|
export function selectDirectProviderModel(providerId, modelId, metadata, features, activeState, deps) {
|
|
114
189
|
const normalizedProvider = typeof providerId === 'string' ? providerId.trim() : '';
|
|
115
190
|
const normalizedModel = typeof modelId === 'string' ? modelId.trim() : '';
|
|
@@ -220,7 +295,11 @@ function trySelectFromTier(routeName, tier, stickyKey, estimatedTokens, features
|
|
|
220
295
|
targets = targets.filter((key) => !excludedKeys.has(key));
|
|
221
296
|
}
|
|
222
297
|
if (targets.length > 0) {
|
|
223
|
-
|
|
298
|
+
const cooled = targets.filter((key) => !deps.isProviderCoolingDown(key));
|
|
299
|
+
// 单 provider 兜底:当一个 tier 只有一个候选 key 时,不因 cooldown 造成路由池为空。
|
|
300
|
+
if (cooled.length > 0 || targets.length !== 1) {
|
|
301
|
+
targets = cooled;
|
|
302
|
+
}
|
|
224
303
|
}
|
|
225
304
|
if (allowedProviders && allowedProviders.size > 0) {
|
|
226
305
|
targets = targets.filter((key) => {
|
|
@@ -331,14 +410,22 @@ function trySelectFromTier(routeName, tier, stickyKey, estimatedTokens, features
|
|
|
331
410
|
const selectWithQuota = (candidates) => {
|
|
332
411
|
if (!quotaView) {
|
|
333
412
|
if (tier.mode === 'priority') {
|
|
334
|
-
|
|
413
|
+
const selected = selectFirstAvailable(candidates);
|
|
414
|
+
if (!selected && candidates.length === 1) {
|
|
415
|
+
return candidates[0];
|
|
416
|
+
}
|
|
417
|
+
return selected;
|
|
335
418
|
}
|
|
336
|
-
|
|
419
|
+
const selected = deps.loadBalancer.select({
|
|
337
420
|
routeName: `${routeName}:${tier.id}`,
|
|
338
421
|
candidates,
|
|
339
422
|
stickyKey: options.allowAliasRotation ? undefined : stickyKey,
|
|
340
423
|
availabilityCheck: (key) => deps.healthManager.isAvailable(key)
|
|
341
424
|
}, tier.mode === 'round-robin' ? 'round-robin' : undefined);
|
|
425
|
+
if (!selected && candidates.length === 1) {
|
|
426
|
+
return candidates[0];
|
|
427
|
+
}
|
|
428
|
+
return selected;
|
|
342
429
|
}
|
|
343
430
|
const buckets = new Map();
|
|
344
431
|
for (const key of candidates) {
|
|
@@ -389,6 +476,33 @@ function trySelectFromTier(routeName, tier, stickyKey, estimatedTokens, features
|
|
|
389
476
|
}
|
|
390
477
|
}
|
|
391
478
|
}
|
|
479
|
+
// default 路由永不因 quota gating 而“空池”:
|
|
480
|
+
// 当 quotaView 过滤后没有任何可用候选时,默认路由允许忽略 quotaView,
|
|
481
|
+
// 继续按健康/负载均衡选择一个 providerKey(但不覆盖 forced/required 约束)。
|
|
482
|
+
const quotaBypassAllowed = routeName === DEFAULT_ROUTE && (!requiredProviderKeys || requiredProviderKeys.size === 0);
|
|
483
|
+
if (quotaBypassAllowed) {
|
|
484
|
+
if (tier.mode === 'priority') {
|
|
485
|
+
const selected = selectFirstAvailable(candidates);
|
|
486
|
+
if (selected) {
|
|
487
|
+
return selected;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
const selected = deps.loadBalancer.select({
|
|
492
|
+
routeName: `${routeName}:${tier.id}:quota-bypass`,
|
|
493
|
+
candidates,
|
|
494
|
+
stickyKey: options.allowAliasRotation ? undefined : stickyKey,
|
|
495
|
+
availabilityCheck: (key) => deps.healthManager.isAvailable(key)
|
|
496
|
+
}, tier.mode === 'round-robin' ? 'round-robin' : undefined);
|
|
497
|
+
if (selected) {
|
|
498
|
+
return selected;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
// 单 provider 兜底:当只剩一个候选 key 时,不因 quota/blacklist/cooldown 或健康状态过滤导致无 provider。
|
|
503
|
+
if (candidates.length === 1) {
|
|
504
|
+
return candidates[0];
|
|
505
|
+
}
|
|
392
506
|
return null;
|
|
393
507
|
};
|
|
394
508
|
for (const candidatePool of prioritizedPools) {
|
|
@@ -416,10 +530,13 @@ export function selectFromStickyPool(stickyKeySet, metadata, features, state, de
|
|
|
416
530
|
]));
|
|
417
531
|
const disabledModels = new Map(Array.from(state.disabledModels.entries()).map(([provider, models]) => [provider, new Set(models)]));
|
|
418
532
|
let candidates = Array.from(stickyKeySet).filter((key) => !deps.isProviderCoolingDown(key));
|
|
533
|
+
if (!candidates.length && stickyKeySet.size === 1) {
|
|
534
|
+
candidates = Array.from(stickyKeySet);
|
|
535
|
+
}
|
|
419
536
|
const quotaView = deps.quotaView;
|
|
420
537
|
const now = quotaView ? Date.now() : 0;
|
|
421
538
|
if (quotaView) {
|
|
422
|
-
|
|
539
|
+
const filtered = candidates.filter((key) => {
|
|
423
540
|
const entry = quotaView(key);
|
|
424
541
|
if (!entry) {
|
|
425
542
|
return true;
|
|
@@ -435,6 +552,9 @@ export function selectFromStickyPool(stickyKeySet, metadata, features, state, de
|
|
|
435
552
|
}
|
|
436
553
|
return true;
|
|
437
554
|
});
|
|
555
|
+
if (filtered.length > 0 || candidates.length !== 1) {
|
|
556
|
+
candidates = filtered;
|
|
557
|
+
}
|
|
438
558
|
}
|
|
439
559
|
if (allowedProviders.size > 0) {
|
|
440
560
|
candidates = candidates.filter((key) => {
|
|
@@ -777,9 +897,21 @@ function resolveInstructionTarget(target, providerRegistry) {
|
|
|
777
897
|
const alias = typeof target.keyAlias === 'string' ? target.keyAlias.trim() : '';
|
|
778
898
|
const aliasExplicit = alias.length > 0 && target.pathLength === 3;
|
|
779
899
|
if (aliasExplicit) {
|
|
780
|
-
const
|
|
781
|
-
|
|
782
|
-
|
|
900
|
+
const prefix = `${providerId}.${alias}.`;
|
|
901
|
+
const aliasKeys = providerKeys.filter((key) => key.startsWith(prefix));
|
|
902
|
+
if (aliasKeys.length > 0) {
|
|
903
|
+
if (target.model && target.model.trim()) {
|
|
904
|
+
const normalizedModel = target.model.trim();
|
|
905
|
+
const matching = aliasKeys.filter((key) => getProviderModelId(key, providerRegistry) === normalizedModel);
|
|
906
|
+
if (matching.length > 0) {
|
|
907
|
+
// Prefer exact to keep sticky pool deterministic when only one key matches.
|
|
908
|
+
if (matching.length === 1) {
|
|
909
|
+
return { mode: 'exact', keys: [matching[0]] };
|
|
910
|
+
}
|
|
911
|
+
return { mode: 'filter', keys: matching };
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return { mode: 'filter', keys: aliasKeys };
|
|
783
915
|
}
|
|
784
916
|
}
|
|
785
917
|
if (typeof target.keyIndex === 'number' && target.keyIndex > 0) {
|
|
@@ -9,7 +9,7 @@ import { getStatsCenter } from '../../telemetry/stats-center.js';
|
|
|
9
9
|
import { parseRoutingInstructions, applyRoutingInstructions, cleanMessagesFromRoutingInstructions } from './routing-instructions.js';
|
|
10
10
|
import { loadRoutingInstructionStateSync, saveRoutingInstructionStateAsync } from './sticky-session-store.js';
|
|
11
11
|
import { buildHitReason, formatVirtualRouterHit } from './engine-logging.js';
|
|
12
|
-
import { selectDirectProviderModel, selectProviderImpl } from './engine-selection.js';
|
|
12
|
+
import { selectDirectProviderModel, selectFromStickyPool, selectProviderImpl } from './engine-selection.js';
|
|
13
13
|
import { applyQuotaDepletedImpl, applyQuotaRecoveryImpl, applySeriesCooldownImpl, handleProviderFailureImpl, mapProviderErrorImpl } from './engine-health.js';
|
|
14
14
|
export class VirtualRouterEngine {
|
|
15
15
|
routing = {};
|
|
@@ -105,7 +105,11 @@ export class VirtualRouterEngine {
|
|
|
105
105
|
}
|
|
106
106
|
route(request, metadata) {
|
|
107
107
|
const stickyKey = this.resolveStickyKey(metadata);
|
|
108
|
-
const
|
|
108
|
+
const sessionScope = this.resolveSessionScope(metadata);
|
|
109
|
+
// Routing instructions should be session/conversation-scoped when available (including /v1/responses),
|
|
110
|
+
// while auto-sticky for Responses remains request-chain scoped via resolveStickyKey().
|
|
111
|
+
const stateKey = sessionScope || stickyKey || 'default';
|
|
112
|
+
const baseState = this.getRoutingInstructionState(stateKey);
|
|
109
113
|
let routingState = baseState;
|
|
110
114
|
const metadataInstructions = this.buildMetadataInstructions(metadata);
|
|
111
115
|
if (metadataInstructions.length > 0) {
|
|
@@ -114,13 +118,13 @@ export class VirtualRouterEngine {
|
|
|
114
118
|
const disableStickyRoutes = metadata &&
|
|
115
119
|
typeof metadata === 'object' &&
|
|
116
120
|
metadata.disableStickyRoutes === true;
|
|
117
|
-
if (disableStickyRoutes && routingState.stickyTarget) {
|
|
121
|
+
if (disableStickyRoutes && (routingState.stickyTarget || routingState.preferTarget)) {
|
|
118
122
|
routingState = {
|
|
119
123
|
...routingState,
|
|
120
|
-
stickyTarget: undefined
|
|
124
|
+
stickyTarget: undefined,
|
|
125
|
+
preferTarget: undefined
|
|
121
126
|
};
|
|
122
127
|
}
|
|
123
|
-
const sessionScope = this.resolveSessionScope(metadata);
|
|
124
128
|
if (sessionScope) {
|
|
125
129
|
const sessionState = this.getRoutingInstructionState(sessionScope);
|
|
126
130
|
if (typeof sessionState.stopMessageText === 'string' ||
|
|
@@ -163,9 +167,8 @@ export class VirtualRouterEngine {
|
|
|
163
167
|
}
|
|
164
168
|
if (instructions.length > 0) {
|
|
165
169
|
routingState = applyRoutingInstructions(instructions, routingState);
|
|
166
|
-
|
|
167
|
-
this.
|
|
168
|
-
this.persistRoutingInstructionState(effectiveKey, routingState);
|
|
170
|
+
this.routingInstructionState.set(stateKey, routingState);
|
|
171
|
+
this.persistRoutingInstructionState(stateKey, routingState);
|
|
169
172
|
// 对 stopMessage 指令补充一份基于 session/conversation 的持久化状态,
|
|
170
173
|
// 便于 server-side 工具通过 session:*/conversation:* scope 读取到相同配置。
|
|
171
174
|
if (sessionScope) {
|
|
@@ -236,12 +239,52 @@ export class VirtualRouterEngine {
|
|
|
236
239
|
routingState.stopMessageLastUsedAt = sessionState.stopMessageLastUsedAt;
|
|
237
240
|
}
|
|
238
241
|
}
|
|
239
|
-
|
|
242
|
+
// Guardrail: if a session is restricted to providers that do not exist in any routing pools,
|
|
243
|
+
// we must not hard-fail the request loop. Auto-clear the allowlist and fall back to normal routing.
|
|
244
|
+
if (routingState.allowedProviders.size > 0) {
|
|
245
|
+
const providersInRouting = new Set();
|
|
246
|
+
for (const pools of Object.values(this.routing)) {
|
|
247
|
+
if (!Array.isArray(pools))
|
|
248
|
+
continue;
|
|
249
|
+
for (const pool of pools) {
|
|
250
|
+
if (!pool || !Array.isArray(pool.targets))
|
|
251
|
+
continue;
|
|
252
|
+
for (const key of pool.targets) {
|
|
253
|
+
if (typeof key !== 'string' || !key)
|
|
254
|
+
continue;
|
|
255
|
+
const providerId = this.extractProviderId(key);
|
|
256
|
+
if (providerId) {
|
|
257
|
+
providersInRouting.add(providerId);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const allowed = Array.from(routingState.allowedProviders);
|
|
263
|
+
const hasIntersection = allowed.some((provider) => providersInRouting.has(provider));
|
|
264
|
+
if (!hasIntersection) {
|
|
265
|
+
routingState = {
|
|
266
|
+
...routingState,
|
|
267
|
+
allowedProviders: new Set()
|
|
268
|
+
};
|
|
269
|
+
this.routingInstructionState.set(stateKey, routingState);
|
|
270
|
+
this.persistRoutingInstructionState(stateKey, routingState);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
240
273
|
const features = buildRoutingFeatures(request, metadata);
|
|
241
274
|
const directProviderModel = this.parseDirectProviderModel(request?.model);
|
|
242
275
|
let classification;
|
|
243
276
|
let requestedRoute;
|
|
244
277
|
let selection;
|
|
278
|
+
const selectionDeps = {
|
|
279
|
+
routing: this.routing,
|
|
280
|
+
providerRegistry: this.providerRegistry,
|
|
281
|
+
healthManager: this.healthManager,
|
|
282
|
+
contextAdvisor: this.contextAdvisor,
|
|
283
|
+
loadBalancer: this.loadBalancer,
|
|
284
|
+
isProviderCoolingDown: (key) => this.isProviderCoolingDown(key),
|
|
285
|
+
resolveStickyKey: (m) => this.resolveStickyKey(m),
|
|
286
|
+
quotaView: this.quotaView
|
|
287
|
+
};
|
|
245
288
|
if (directProviderModel) {
|
|
246
289
|
const providerKeys = this.providerRegistry.listProviderKeys(directProviderModel.providerId);
|
|
247
290
|
let hasModel = false;
|
|
@@ -268,33 +311,129 @@ export class VirtualRouterEngine {
|
|
|
268
311
|
candidates: ['direct']
|
|
269
312
|
};
|
|
270
313
|
requestedRoute = 'direct';
|
|
271
|
-
const directSelection = selectDirectProviderModel(directProviderModel.providerId, directProviderModel.modelId, metadata, features, routingState,
|
|
272
|
-
routing: this.routing,
|
|
273
|
-
providerRegistry: this.providerRegistry,
|
|
274
|
-
healthManager: this.healthManager,
|
|
275
|
-
contextAdvisor: this.contextAdvisor,
|
|
276
|
-
loadBalancer: this.loadBalancer,
|
|
277
|
-
isProviderCoolingDown: (key) => this.isProviderCoolingDown(key),
|
|
278
|
-
resolveStickyKey: (m) => this.resolveStickyKey(m),
|
|
279
|
-
quotaView: this.quotaView
|
|
280
|
-
});
|
|
314
|
+
const directSelection = selectDirectProviderModel(directProviderModel.providerId, directProviderModel.modelId, metadata, features, routingState, selectionDeps);
|
|
281
315
|
if (!directSelection) {
|
|
282
316
|
throw new VirtualRouterError(`All providers unavailable for model ${directProviderModel.providerId}.${directProviderModel.modelId}`, VirtualRouterErrorCode.PROVIDER_NOT_AVAILABLE, { providerId: directProviderModel.providerId, modelId: directProviderModel.modelId });
|
|
283
317
|
}
|
|
284
318
|
selection = directSelection;
|
|
285
319
|
}
|
|
286
320
|
else {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
321
|
+
// Prefer target (from "<**!provider.model**>") is evaluated before routing classification.
|
|
322
|
+
const preferTarget = routingState.preferTarget;
|
|
323
|
+
if (preferTarget && typeof preferTarget.provider === 'string' && preferTarget.provider.trim()) {
|
|
324
|
+
const providerId = preferTarget.provider.trim();
|
|
325
|
+
const keyAlias = typeof preferTarget.keyAlias === 'string' ? preferTarget.keyAlias.trim() : '';
|
|
326
|
+
const modelId = typeof preferTarget.model === 'string' ? preferTarget.model.trim() : '';
|
|
327
|
+
const keyIndex = typeof preferTarget.keyIndex === 'number' && Number.isFinite(preferTarget.keyIndex)
|
|
328
|
+
? Math.floor(preferTarget.keyIndex)
|
|
329
|
+
: undefined;
|
|
330
|
+
const candidateKeys = [];
|
|
331
|
+
if (keyIndex !== undefined && keyIndex > 0) {
|
|
332
|
+
const runtimeKey = this.providerRegistry.resolveRuntimeKeyByIndex(providerId, keyIndex);
|
|
333
|
+
if (runtimeKey) {
|
|
334
|
+
candidateKeys.push(runtimeKey);
|
|
335
|
+
}
|
|
294
336
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
337
|
+
else if (modelId) {
|
|
338
|
+
const allKeys = this.providerRegistry.listProviderKeys(providerId);
|
|
339
|
+
for (const key of allKeys) {
|
|
340
|
+
if (keyAlias) {
|
|
341
|
+
const prefix = `${providerId}.${keyAlias}.`;
|
|
342
|
+
if (!key.startsWith(prefix)) {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
const profile = this.providerRegistry.get(key);
|
|
348
|
+
if (profile?.modelId === modelId) {
|
|
349
|
+
candidateKeys.push(key);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const allowAliasRotation = !keyAlias && keyIndex === undefined;
|
|
358
|
+
const eligibleKeys = (() => {
|
|
359
|
+
if (candidateKeys.length === 0) {
|
|
360
|
+
return [];
|
|
361
|
+
}
|
|
362
|
+
const quotaView = selectionDeps.quotaView;
|
|
363
|
+
const now = quotaView ? Date.now() : 0;
|
|
364
|
+
return candidateKeys.filter((key) => {
|
|
365
|
+
if (this.isProviderCoolingDown(key)) {
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
if (!this.healthManager.isAvailable(key)) {
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
if (!quotaView) {
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
const entry = quotaView(key);
|
|
375
|
+
if (!entry) {
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
378
|
+
if (!entry.inPool) {
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
if (entry.cooldownUntil && entry.cooldownUntil > now) {
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
if (entry.blacklistUntil && entry.blacklistUntil > now) {
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
return true;
|
|
388
|
+
});
|
|
389
|
+
})();
|
|
390
|
+
const preferSelection = eligibleKeys.length > 0
|
|
391
|
+
? selectFromStickyPool(new Set(eligibleKeys), metadata, features, routingState, selectionDeps, {
|
|
392
|
+
allowAliasRotation
|
|
393
|
+
})
|
|
394
|
+
: null;
|
|
395
|
+
if (preferSelection) {
|
|
396
|
+
classification = {
|
|
397
|
+
routeName: 'prefer',
|
|
398
|
+
confidence: 1,
|
|
399
|
+
reasoning: keyIndex !== undefined ? `prefer_key:${providerId}.${keyIndex}` : `prefer_model:${providerId}.${modelId}`,
|
|
400
|
+
fallback: false,
|
|
401
|
+
candidates: ['prefer']
|
|
402
|
+
};
|
|
403
|
+
requestedRoute = 'prefer';
|
|
404
|
+
selection = {
|
|
405
|
+
...preferSelection,
|
|
406
|
+
routeUsed: 'prefer',
|
|
407
|
+
poolId: 'prefer-primary'
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
else if (routingState.preferTarget) {
|
|
411
|
+
// Auto-clear only when the target becomes invalid or blocked by explicit routing instructions.
|
|
412
|
+
// Do NOT clear for temporary unavailability (e.g. 429 cooldown, quota cooldown, transient health).
|
|
413
|
+
const shouldAutoClear = candidateKeys.length === 0 || eligibleKeys.length > 0;
|
|
414
|
+
if (shouldAutoClear) {
|
|
415
|
+
routingState = {
|
|
416
|
+
...routingState,
|
|
417
|
+
preferTarget: undefined
|
|
418
|
+
};
|
|
419
|
+
this.routingInstructionState.set(stateKey, routingState);
|
|
420
|
+
this.persistRoutingInstructionState(stateKey, routingState);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (!selection) {
|
|
425
|
+
classification = metadata.routeHint && metadata.routeHint.trim()
|
|
426
|
+
? {
|
|
427
|
+
routeName: metadata.routeHint.trim(),
|
|
428
|
+
confidence: 1,
|
|
429
|
+
reasoning: `route_hint:${metadata.routeHint.trim()}`,
|
|
430
|
+
fallback: false,
|
|
431
|
+
candidates: [metadata.routeHint.trim()]
|
|
432
|
+
}
|
|
433
|
+
: this.classifier.classify(features);
|
|
434
|
+
requestedRoute = this.normalizeRouteAlias(classification.routeName || DEFAULT_ROUTE);
|
|
435
|
+
selection = this.selectProvider(requestedRoute, metadata, classification, features, routingState);
|
|
436
|
+
}
|
|
298
437
|
}
|
|
299
438
|
const baseTarget = this.providerRegistry.buildTarget(selection.providerKey);
|
|
300
439
|
const forceVision = this.routeHasForceFlag('vision');
|
|
@@ -304,6 +443,7 @@ export class VirtualRouterEngine {
|
|
|
304
443
|
...(forceVision ? { forceVision: true } : {})
|
|
305
444
|
};
|
|
306
445
|
this.incrementRouteStat(selection.routeUsed, selection.providerKey);
|
|
446
|
+
const routingMode = this.resolveRoutingMode([...metadataInstructions, ...instructions], routingState);
|
|
307
447
|
try {
|
|
308
448
|
this.statsCenter.recordVirtualRouterHit({
|
|
309
449
|
requestId: metadata.requestId,
|
|
@@ -577,7 +717,7 @@ export class VirtualRouterEngine {
|
|
|
577
717
|
// 能在 VirtualRouter 日志中实时反映出来。
|
|
578
718
|
if (existing && (key.startsWith('session:') || key.startsWith('conversation:'))) {
|
|
579
719
|
try {
|
|
580
|
-
const persisted =
|
|
720
|
+
const persisted = this.routingStateStore.loadSync(key);
|
|
581
721
|
if (persisted) {
|
|
582
722
|
// 以持久化状态为准(包括清空后的 undefined),避免 stopMessage 状态“卡死”在内存中。
|
|
583
723
|
existing.stopMessageText = persisted.stopMessageText;
|
|
@@ -603,7 +743,7 @@ export class VirtualRouterEngine {
|
|
|
603
743
|
let initial = null;
|
|
604
744
|
// 仅对 session:/conversation: 作用域的 key 尝试从磁盘恢复持久化状态
|
|
605
745
|
if (key.startsWith('session:') || key.startsWith('conversation:')) {
|
|
606
|
-
initial =
|
|
746
|
+
initial = this.routingStateStore.loadSync(key);
|
|
607
747
|
}
|
|
608
748
|
if (!initial) {
|
|
609
749
|
initial = {
|
|
@@ -661,6 +801,7 @@ export class VirtualRouterEngine {
|
|
|
661
801
|
const hasForce = instructions.some((inst) => inst.type === 'force');
|
|
662
802
|
const hasAllow = instructions.some((inst) => inst.type === 'allow');
|
|
663
803
|
const hasClear = instructions.some((inst) => inst.type === 'clear');
|
|
804
|
+
const hasPrefer = instructions.some((inst) => inst.type === 'prefer');
|
|
664
805
|
if (hasClear) {
|
|
665
806
|
return 'none';
|
|
666
807
|
}
|
|
@@ -670,6 +811,9 @@ export class VirtualRouterEngine {
|
|
|
670
811
|
if (hasForce || state.forcedTarget) {
|
|
671
812
|
return 'force';
|
|
672
813
|
}
|
|
814
|
+
if (hasPrefer || state.preferTarget) {
|
|
815
|
+
return 'sticky';
|
|
816
|
+
}
|
|
673
817
|
if (state.stickyTarget) {
|
|
674
818
|
return 'sticky';
|
|
675
819
|
}
|
|
@@ -1114,6 +1258,7 @@ export class VirtualRouterEngine {
|
|
|
1114
1258
|
}
|
|
1115
1259
|
const noForced = !state.forcedTarget;
|
|
1116
1260
|
const noSticky = !state.stickyTarget;
|
|
1261
|
+
const noPrefer = !state.preferTarget;
|
|
1117
1262
|
const noAllowed = state.allowedProviders.size === 0;
|
|
1118
1263
|
const noDisabledProviders = state.disabledProviders.size === 0;
|
|
1119
1264
|
const noDisabledKeys = state.disabledKeys.size === 0;
|
|
@@ -1125,6 +1270,7 @@ export class VirtualRouterEngine {
|
|
|
1125
1270
|
(typeof state.stopMessageLastUsedAt !== 'number' || !Number.isFinite(state.stopMessageLastUsedAt));
|
|
1126
1271
|
return (noForced &&
|
|
1127
1272
|
noSticky &&
|
|
1273
|
+
noPrefer &&
|
|
1128
1274
|
noAllowed &&
|
|
1129
1275
|
noDisabledProviders &&
|
|
1130
1276
|
noDisabledKeys &&
|