@jsonstudio/llms 0.6.1399 → 0.6.1402
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/conversion/codecs/gemini-openai-codec.d.ts +1 -3
- package/dist/conversion/codecs/gemini-openai-codec.js +4 -10
- package/dist/conversion/compat/actions/gemini-cli-request.d.ts +2 -0
- package/dist/conversion/compat/actions/gemini-cli-request.js +490 -0
- package/dist/conversion/compat/profiles/chat-gemini-cli.json +27 -0
- package/dist/conversion/hub/operation-table/semantic-mappers/gemini-mapper.js +76 -348
- package/dist/conversion/hub/pipeline/compat/compat-pipeline-executor.js +6 -0
- package/dist/conversion/hub/pipeline/compat/compat-types.d.ts +2 -0
- package/dist/conversion/hub/pipeline/hub-pipeline.d.ts +95 -3
- package/dist/conversion/hub/pipeline/hub-pipeline.js +1365 -19
- package/dist/conversion/hub/pipeline/stages/req_inbound/req_inbound_stage3_context_capture/index.js +22 -0
- package/dist/conversion/hub/policy/policy-engine.js +50 -3
- package/dist/conversion/hub/process/chat-process.js +5 -146
- package/dist/conversion/hub/response/provider-response.js +11 -10
- package/dist/conversion/hub/response/response-mappers.d.ts +1 -3
- package/dist/conversion/hub/response/response-mappers.js +2 -20
- package/dist/conversion/hub/tool-surface/tool-surface-engine.js +2 -1
- package/dist/conversion/responses/responses-openai-bridge.js +4 -3
- package/dist/conversion/shared/gemini-tool-utils.d.ts +1 -6
- package/dist/conversion/shared/gemini-tool-utils.js +164 -542
- package/dist/conversion/shared/mcp-injection.js +29 -0
- package/dist/conversion/shared/openai-message-normalize.js +3 -17
- package/dist/filters/special/request-tool-list-filter.js +21 -13
- package/dist/filters/special/tool-filter-hooks.js +60 -3
- package/dist/router/virtual-router/bootstrap.js +8 -6
- package/dist/router/virtual-router/engine-health.d.ts +1 -1
- package/dist/router/virtual-router/engine-health.js +110 -11
- package/dist/router/virtual-router/engine-selection/alias-selection.d.ts +0 -15
- package/dist/router/virtual-router/engine-selection/alias-selection.js +4 -85
- package/dist/router/virtual-router/engine-selection/route-utils.js +6 -12
- package/dist/router/virtual-router/engine-selection/tier-selection-select.js +17 -40
- package/dist/router/virtual-router/engine-selection/tier-selection.js +2 -5
- package/dist/router/virtual-router/engine.js +6 -21
- package/dist/router/virtual-router/types.d.ts +1 -14
- package/dist/servertool/clock/config.d.ts +1 -1
- package/dist/servertool/clock/config.js +5 -9
- package/dist/servertool/engine.js +11 -88
- package/dist/servertool/handlers/gemini-empty-reply-continue.js +1 -2
- package/dist/sse/sse-to-json/builders/response-builder.js +0 -16
- package/package.json +1 -1
- package/dist/conversion/hub/pipeline/hub-pipeline/adapter-context.d.ts +0 -10
- package/dist/conversion/hub/pipeline/hub-pipeline/adapter-context.js +0 -142
- package/dist/conversion/hub/pipeline/hub-pipeline/anthropic-alias-map.d.ts +0 -6
- package/dist/conversion/hub/pipeline/hub-pipeline/anthropic-alias-map.js +0 -79
- package/dist/conversion/hub/pipeline/hub-pipeline/apply-patch-tool-mode.d.ts +0 -3
- package/dist/conversion/hub/pipeline/hub-pipeline/apply-patch-tool-mode.js +0 -46
- package/dist/conversion/hub/pipeline/hub-pipeline/execute-chat-process-entry.d.ts +0 -8
- package/dist/conversion/hub/pipeline/hub-pipeline/execute-chat-process-entry.js +0 -366
- package/dist/conversion/hub/pipeline/hub-pipeline/execute-request-stage.d.ts +0 -9
- package/dist/conversion/hub/pipeline/hub-pipeline/execute-request-stage.js +0 -390
- package/dist/conversion/hub/pipeline/hub-pipeline/node-results.d.ts +0 -3
- package/dist/conversion/hub/pipeline/hub-pipeline/node-results.js +0 -14
- package/dist/conversion/hub/pipeline/hub-pipeline/payload-normalize.d.ts +0 -2
- package/dist/conversion/hub/pipeline/hub-pipeline/payload-normalize.js +0 -144
- package/dist/conversion/hub/pipeline/hub-pipeline/policy.d.ts +0 -4
- package/dist/conversion/hub/pipeline/hub-pipeline/policy.js +0 -32
- package/dist/conversion/hub/pipeline/hub-pipeline/protocol.d.ts +0 -8
- package/dist/conversion/hub/pipeline/hub-pipeline/protocol.js +0 -63
- package/dist/conversion/hub/pipeline/hub-pipeline/resolve-protocol-hooks.d.ts +0 -2
- package/dist/conversion/hub/pipeline/hub-pipeline/resolve-protocol-hooks.js +0 -43
- package/dist/conversion/hub/pipeline/hub-pipeline/semantic-gate.d.ts +0 -1
- package/dist/conversion/hub/pipeline/hub-pipeline/semantic-gate.js +0 -29
- package/dist/conversion/hub/pipeline/hub-pipeline/servertool-runtime-config.d.ts +0 -2
- package/dist/conversion/hub/pipeline/hub-pipeline/servertool-runtime-config.js +0 -16
- package/dist/conversion/hub/pipeline/hub-pipeline/types.d.ts +0 -116
- package/dist/conversion/hub/pipeline/hub-pipeline/types.js +0 -1
- package/dist/conversion/hub/pipeline/stages/req_outbound/req_outbound_stage2_thought_signature_inject/index.d.ts +0 -10
- package/dist/conversion/hub/pipeline/stages/req_outbound/req_outbound_stage2_thought_signature_inject/index.js +0 -172
- package/dist/conversion/hub/pipeline/stages/resp_inbound/resp_inbound_stage3_thought_signature_capture/index.d.ts +0 -10
- package/dist/conversion/hub/pipeline/stages/resp_inbound/resp_inbound_stage3_thought_signature_capture/index.js +0 -71
- package/dist/conversion/hub/pipeline/thought-signature/thought-signature-center.d.ts +0 -14
- package/dist/conversion/hub/pipeline/thought-signature/thought-signature-center.js +0 -289
|
@@ -98,6 +98,21 @@ export function injectMcpToolsForChat(tools, discoveredServers) {
|
|
|
98
98
|
out.push(t);
|
|
99
99
|
keep.add(lower);
|
|
100
100
|
}
|
|
101
|
+
// Ensure list exists when user did not provide it
|
|
102
|
+
if (!keep.has('list_mcp_resources')) {
|
|
103
|
+
out.push({ type: 'function', function: { name: 'list_mcp_resources', description: buildListResourcesDescription(discoveredServers), parameters: obj({ server: listServers, filter: { type: 'string' }, root: { type: 'string' } }) } });
|
|
104
|
+
}
|
|
105
|
+
// Ensure templates exists (safe to call after list_mcp_resources)
|
|
106
|
+
if (!keep.has('list_mcp_resource_templates')) {
|
|
107
|
+
out.push({
|
|
108
|
+
type: 'function',
|
|
109
|
+
function: {
|
|
110
|
+
name: 'list_mcp_resource_templates',
|
|
111
|
+
description: buildListTemplatesDescription(discoveredServers),
|
|
112
|
+
parameters: obj({ server: templateServer, cursor: { type: 'string' } })
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
101
116
|
return out;
|
|
102
117
|
}
|
|
103
118
|
export function injectMcpToolsForResponses(tools, discoveredServers) {
|
|
@@ -140,5 +155,19 @@ export function injectMcpToolsForResponses(tools, discoveredServers) {
|
|
|
140
155
|
out.push(t);
|
|
141
156
|
keep.add(lower);
|
|
142
157
|
}
|
|
158
|
+
if (!keep.has('list_mcp_resources')) {
|
|
159
|
+
out.push({ type: 'function', function: { name: 'list_mcp_resources', description: buildListResourcesDescription(discoveredServers), parameters: obj({ server: listServers, filter: { type: 'string' }, root: { type: 'string' } }) } });
|
|
160
|
+
keep.add('list_mcp_resources');
|
|
161
|
+
}
|
|
162
|
+
if (!keep.has('list_mcp_resource_templates')) {
|
|
163
|
+
out.push({
|
|
164
|
+
type: 'function',
|
|
165
|
+
function: {
|
|
166
|
+
name: 'list_mcp_resource_templates',
|
|
167
|
+
description: buildListTemplatesDescription(discoveredServers),
|
|
168
|
+
parameters: obj({ server: templateServer, cursor: { type: 'string' } })
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
|
143
172
|
return out;
|
|
144
173
|
}
|
|
@@ -3,14 +3,6 @@ import { injectMcpToolsForChat } from './mcp-injection.js';
|
|
|
3
3
|
// with the deprecated "openai-normalizer" module entry). This file contains the
|
|
4
4
|
// previously-implemented logic from openai-normalize.ts.
|
|
5
5
|
// Legacy tooling stage removed for Chat; tool canonicalization lives in codecs
|
|
6
|
-
function isMcpToolName(name) {
|
|
7
|
-
if (typeof name !== 'string')
|
|
8
|
-
return false;
|
|
9
|
-
const lowered = name.trim().toLowerCase();
|
|
10
|
-
return (lowered === 'list_mcp_resources' ||
|
|
11
|
-
lowered === 'list_mcp_resource_templates' ||
|
|
12
|
-
lowered === 'read_mcp_resource');
|
|
13
|
-
}
|
|
14
6
|
export function normalizeChatRequest(request) {
|
|
15
7
|
if (!request || typeof request !== 'object')
|
|
16
8
|
return request;
|
|
@@ -132,15 +124,9 @@ export function normalizeChatRequest(request) {
|
|
|
132
124
|
}
|
|
133
125
|
}
|
|
134
126
|
catch { /* ignore */ }
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
// IMPORTANT: Do not force-inject MCP tools. If the inbound request doesn't include MCP tools,
|
|
139
|
-
// keep tools untouched (and do not create a tools=[] placeholder).
|
|
140
|
-
if (currentTools && currentTools.some((t) => isMcpToolName(t?.function?.name))) {
|
|
141
|
-
const discovered = Array.from(known);
|
|
142
|
-
normalized.tools = injectMcpToolsForChat(currentTools, discovered);
|
|
143
|
-
}
|
|
127
|
+
const discovered = Array.from(known);
|
|
128
|
+
const currentTools = Array.isArray(normalized.tools) ? normalized.tools : [];
|
|
129
|
+
normalized.tools = injectMcpToolsForChat(currentTools, discovered);
|
|
144
130
|
}
|
|
145
131
|
}
|
|
146
132
|
catch { /* ignore MCP injection */ }
|
|
@@ -184,6 +184,9 @@ function ensureFunctionTool(tools, name, description, parameters) {
|
|
|
184
184
|
fn.description = description;
|
|
185
185
|
fn.parameters = parameters;
|
|
186
186
|
}
|
|
187
|
+
else {
|
|
188
|
+
tools.push({ type: 'function', function: { name, description, parameters } });
|
|
189
|
+
}
|
|
187
190
|
}
|
|
188
191
|
function removeToolByName(tools, name) {
|
|
189
192
|
for (let i = tools.length - 1; i >= 0; i--) {
|
|
@@ -205,22 +208,15 @@ export class RequestToolListFilter {
|
|
|
205
208
|
try {
|
|
206
209
|
const out = JSON.parse(JSON.stringify(input || {}));
|
|
207
210
|
const hadIncomingTools = Array.isArray(out.tools);
|
|
208
|
-
|
|
209
|
-
// keep it absent.
|
|
211
|
+
const tools = hadIncomingTools ? out.tools : [];
|
|
210
212
|
if (!hadIncomingTools) {
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
const tools = out.tools;
|
|
214
|
-
const hasMcpTool = Array.isArray(tools) &&
|
|
215
|
-
tools.some((t) => t && typeof t === 'object' && t.type === 'function' && t.function && typeof t.function.name === 'string' && (t.function.name === 'list_mcp_resources' ||
|
|
216
|
-
t.function.name === 'list_mcp_resource_templates' ||
|
|
217
|
-
t.function.name === 'read_mcp_resource'));
|
|
218
|
-
if (!hasMcpTool) {
|
|
219
|
-
return { ok: true, data: out };
|
|
213
|
+
out.tools = tools;
|
|
220
214
|
}
|
|
221
215
|
const mode = envMode();
|
|
222
216
|
if (mode === 'off') {
|
|
223
|
-
|
|
217
|
+
if (!hadIncomingTools && tools.length === 0) {
|
|
218
|
+
delete out.tools;
|
|
219
|
+
}
|
|
224
220
|
return { ok: true, data: out };
|
|
225
221
|
}
|
|
226
222
|
const messages = Array.isArray(out.messages) ? out.messages : [];
|
|
@@ -239,7 +235,15 @@ export class RequestToolListFilter {
|
|
|
239
235
|
return `Known MCP servers: ${shown.join(', ')}${suffix}.`;
|
|
240
236
|
};
|
|
241
237
|
const mcpServerReminder = 'Note: arguments.server is an MCP server label (NOT a tool name like shell/exec_command/apply_patch).';
|
|
242
|
-
//
|
|
238
|
+
// If the session already attempted list_mcp_resources and got an empty/unsupported response,
|
|
239
|
+
// stop exposing MCP *resource* tools to avoid repeated "server" probing loops.
|
|
240
|
+
if (mcpListEmpty) {
|
|
241
|
+
removeToolByName(tools, 'list_mcp_resources');
|
|
242
|
+
removeToolByName(tools, 'list_mcp_resource_templates');
|
|
243
|
+
removeToolByName(tools, 'read_mcp_resource');
|
|
244
|
+
out.tools = tools;
|
|
245
|
+
return { ok: true, data: out };
|
|
246
|
+
}
|
|
243
247
|
// MCP tool schemas
|
|
244
248
|
const listResParams = {
|
|
245
249
|
type: 'object',
|
|
@@ -303,6 +307,10 @@ export class RequestToolListFilter {
|
|
|
303
307
|
withEnum.properties.server = { type: 'string', enum: knownServers };
|
|
304
308
|
ensureFunctionTool(tools, 'read_mcp_resource', readDescription, withEnum);
|
|
305
309
|
}
|
|
310
|
+
else {
|
|
311
|
+
// remove any existing read tool to prevent premature exposure
|
|
312
|
+
removeToolByName(tools, 'read_mcp_resource');
|
|
313
|
+
}
|
|
306
314
|
}
|
|
307
315
|
out.tools = tools;
|
|
308
316
|
return { ok: true, data: out };
|
|
@@ -261,9 +261,66 @@ const mcpToolHook = ctx => {
|
|
|
261
261
|
}
|
|
262
262
|
return;
|
|
263
263
|
}
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
264
|
+
// 会话内阶段性策略:
|
|
265
|
+
// 1) 如果 list_mcp_resources 从未在本会话中被请求过 → 仅暴露 list_mcp_resources,过滤 read/templates。
|
|
266
|
+
// 2) 如果 list_mcp_resources 已请求且结果为空 → 完全过滤所有 MCP 工具。
|
|
267
|
+
const { listRequested, listEmpty } = deriveMcpSessionState(messages);
|
|
268
|
+
const next = [];
|
|
269
|
+
for (const t of tools) {
|
|
270
|
+
let name = '';
|
|
271
|
+
try {
|
|
272
|
+
name =
|
|
273
|
+
t &&
|
|
274
|
+
typeof t === 'object' &&
|
|
275
|
+
t.function &&
|
|
276
|
+
typeof t.function.name === 'string'
|
|
277
|
+
? String(t.function.name)
|
|
278
|
+
: '';
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
name = '';
|
|
282
|
+
}
|
|
283
|
+
const lower = name.toLowerCase();
|
|
284
|
+
const isMcp = isMcpToolName(name);
|
|
285
|
+
if (!isMcp) {
|
|
286
|
+
next.push(t);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (!listRequested) {
|
|
290
|
+
// list 未被请求过:保留 list_mcp_resources,其它 MCP 工具全部过滤
|
|
291
|
+
if (lower === 'list_mcp_resources') {
|
|
292
|
+
next.push(t);
|
|
293
|
+
recordDecision({
|
|
294
|
+
name,
|
|
295
|
+
action: 'allow',
|
|
296
|
+
category: 'mcp',
|
|
297
|
+
reason: 'mcp_list_exposed_before_first_use',
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
recordDecision({
|
|
302
|
+
name,
|
|
303
|
+
action: 'block',
|
|
304
|
+
category: 'mcp',
|
|
305
|
+
reason: 'mcp_non_list_blocked_until_list_called',
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (listEmpty) {
|
|
311
|
+
// list 已请求且结果为空:完全屏蔽所有 MCP 工具
|
|
312
|
+
recordDecision({
|
|
313
|
+
name,
|
|
314
|
+
action: 'block',
|
|
315
|
+
category: 'mcp',
|
|
316
|
+
reason: 'mcp_disabled_for_session_after_empty_list',
|
|
317
|
+
});
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
// list 已请求且非空:保留现有行为(交由其它层/配置控制)
|
|
321
|
+
next.push(t);
|
|
322
|
+
}
|
|
323
|
+
ctx.tools = next;
|
|
267
324
|
};
|
|
268
325
|
const requestHooks = [visionToolHook, mcpToolHook];
|
|
269
326
|
export class ToolFilterHookFilter {
|
|
@@ -612,6 +612,14 @@ function resolveCompatibilityProfile(providerId, provider) {
|
|
|
612
612
|
if (legacyFields.length > 0) {
|
|
613
613
|
throw new VirtualRouterError(`Provider "${providerId}" uses legacy compatibility field(s): ${legacyFields.join(', ')}. Rename to "compatibilityProfile".`, VirtualRouterErrorCode.CONFIG_ERROR);
|
|
614
614
|
}
|
|
615
|
+
const normalizedId = providerId.trim().toLowerCase();
|
|
616
|
+
const providerType = String(provider.providerType ?? provider.type ?? provider.protocol ?? '').toLowerCase();
|
|
617
|
+
if (normalizedId === 'antigravity' ||
|
|
618
|
+
normalizedId === 'gemini-cli' ||
|
|
619
|
+
providerType.includes('antigravity') ||
|
|
620
|
+
providerType.includes('gemini-cli')) {
|
|
621
|
+
return 'chat:gemini-cli';
|
|
622
|
+
}
|
|
615
623
|
return 'compat:passthrough';
|
|
616
624
|
}
|
|
617
625
|
function normalizeProcessMode(value) {
|
|
@@ -1241,12 +1249,6 @@ function coerceAliasSelectionStrategy(value) {
|
|
|
1241
1249
|
if (normalized === 'sticky-queue' || normalized === 'sticky_queue' || normalized === 'stickyqueue') {
|
|
1242
1250
|
return 'sticky-queue';
|
|
1243
1251
|
}
|
|
1244
|
-
if (normalized === 'best-quota' ||
|
|
1245
|
-
normalized === 'best_quota' ||
|
|
1246
|
-
normalized === 'quota-best' ||
|
|
1247
|
-
normalized === 'quota_best') {
|
|
1248
|
-
return 'best-quota';
|
|
1249
|
-
}
|
|
1250
1252
|
return undefined;
|
|
1251
1253
|
}
|
|
1252
1254
|
function coerceRatio(value) {
|
|
@@ -7,6 +7,7 @@ type DebugLike = {
|
|
|
7
7
|
export declare function resetRateLimitBackoffForProvider(providerKey: string): void;
|
|
8
8
|
export declare function handleProviderFailureImpl(event: ProviderFailureEvent, healthManager: ProviderHealthManager, healthConfig: Required<ProviderHealthConfig>, markProviderCooldown: (providerKey: string, cooldownMs: number | undefined) => void): void;
|
|
9
9
|
export declare function mapProviderErrorImpl(event: ProviderErrorEvent, healthConfig: Required<ProviderHealthConfig>): ProviderFailureEvent | null;
|
|
10
|
+
export declare function applySeriesCooldownImpl(event: ProviderErrorEvent, providerRegistry: ProviderRegistry, healthManager: ProviderHealthManager, markProviderCooldown: (providerKey: string, cooldownMs: number | undefined) => void, debug?: DebugLike): void;
|
|
10
11
|
/**
|
|
11
12
|
* 处理来自 Host 侧的配额恢复事件:
|
|
12
13
|
* - 清除指定 providerKey 在健康管理器中的熔断/冷却状态;
|
|
@@ -17,6 +18,5 @@ export declare function mapProviderErrorImpl(event: ProviderErrorEvent, healthCo
|
|
|
17
18
|
*/
|
|
18
19
|
export declare function applyQuotaRecoveryImpl(event: ProviderErrorEvent, healthManager: ProviderHealthManager, clearProviderCooldown: (providerKey: string) => void, debug?: DebugLike): boolean;
|
|
19
20
|
export declare function applyQuotaDepletedImpl(event: ProviderErrorEvent, healthManager: ProviderHealthManager, markProviderCooldown: (providerKey: string, cooldownMs: number | undefined) => void, debug?: DebugLike): boolean;
|
|
20
|
-
export declare function applySeriesCooldownImpl(event: ProviderErrorEvent, _providerRegistry: ProviderRegistry, _healthManager: ProviderHealthManager, _markProviderCooldown: (providerKey: string, cooldownMs: number | undefined) => void, debug?: DebugLike): boolean;
|
|
21
21
|
export declare function deriveReason(code: string, stage: string, statusCode?: number): string;
|
|
22
22
|
export {};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const SERIES_COOLDOWN_DETAIL_KEY = 'virtualRouterSeriesCooldown';
|
|
1
2
|
const QUOTA_RECOVERY_DETAIL_KEY = 'virtualRouterQuotaRecovery';
|
|
2
3
|
const QUOTA_DEPLETED_DETAIL_KEY = 'virtualRouterQuotaDepleted';
|
|
3
4
|
function parseDurationToMs(value) {
|
|
@@ -195,6 +196,46 @@ export function mapProviderErrorImpl(event, healthConfig) {
|
|
|
195
196
|
}
|
|
196
197
|
};
|
|
197
198
|
}
|
|
199
|
+
export function applySeriesCooldownImpl(event, providerRegistry, healthManager, markProviderCooldown, debug) {
|
|
200
|
+
const seriesDetail = extractSeriesCooldownDetail(event);
|
|
201
|
+
if (!seriesDetail) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const targetKeys = resolveSeriesCooldownTargets(seriesDetail, event, providerRegistry);
|
|
205
|
+
if (targetKeys.length === 0) {
|
|
206
|
+
debug?.log?.('[virtual-router] series cooldown skipped: no targets', {
|
|
207
|
+
providerId: seriesDetail.providerId,
|
|
208
|
+
providerKey: seriesDetail.providerKey,
|
|
209
|
+
series: seriesDetail.series
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const affected = [];
|
|
214
|
+
for (const providerKey of targetKeys) {
|
|
215
|
+
try {
|
|
216
|
+
const profile = providerRegistry.get(providerKey);
|
|
217
|
+
const modelSeries = resolveModelSeries(profile.modelId);
|
|
218
|
+
if (modelSeries !== seriesDetail.series) {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
healthManager.tripProvider(providerKey, 'rate_limit', seriesDetail.cooldownMs);
|
|
222
|
+
markProviderCooldown(providerKey, seriesDetail.cooldownMs);
|
|
223
|
+
affected.push(providerKey);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// ignore lookup failures; invalid keys may show up if config drifted
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (affected.length) {
|
|
230
|
+
debug?.log?.('[virtual-router] series cooldown', {
|
|
231
|
+
providerId: seriesDetail.providerId,
|
|
232
|
+
providerKey: seriesDetail.providerKey,
|
|
233
|
+
series: seriesDetail.series,
|
|
234
|
+
cooldownMs: seriesDetail.cooldownMs,
|
|
235
|
+
affected
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
198
239
|
function extractQuotaRecoveryDetail(event) {
|
|
199
240
|
if (!event || !event.details || typeof event.details !== 'object') {
|
|
200
241
|
return null;
|
|
@@ -285,21 +326,63 @@ export function applyQuotaDepletedImpl(event, healthManager, markProviderCooldow
|
|
|
285
326
|
}
|
|
286
327
|
return true;
|
|
287
328
|
}
|
|
288
|
-
|
|
329
|
+
function resolveSeriesCooldownTargets(detail, event, providerRegistry) {
|
|
330
|
+
const candidates = new Set();
|
|
331
|
+
const push = (key) => {
|
|
332
|
+
if (typeof key !== 'string') {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const trimmed = key.trim();
|
|
336
|
+
if (!trimmed) {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (providerRegistry.has(trimmed)) {
|
|
340
|
+
candidates.add(trimmed);
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
push(detail.providerKey);
|
|
344
|
+
const runtimeKey = (event.runtime?.target && typeof event.runtime.target === 'object'
|
|
345
|
+
? event.runtime.target.providerKey
|
|
346
|
+
: undefined) || event.runtime?.providerKey;
|
|
347
|
+
push(runtimeKey);
|
|
348
|
+
return Array.from(candidates);
|
|
349
|
+
}
|
|
350
|
+
function extractSeriesCooldownDetail(event) {
|
|
289
351
|
if (!event || !event.details || typeof event.details !== 'object') {
|
|
290
|
-
return
|
|
352
|
+
return null;
|
|
291
353
|
}
|
|
292
|
-
const raw = event.details
|
|
354
|
+
const raw = event.details[SERIES_COOLDOWN_DETAIL_KEY];
|
|
293
355
|
if (!raw || typeof raw !== 'object') {
|
|
294
|
-
return
|
|
356
|
+
return null;
|
|
295
357
|
}
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
358
|
+
const record = raw;
|
|
359
|
+
const providerIdRaw = record.providerId;
|
|
360
|
+
const seriesRaw = record.series;
|
|
361
|
+
const providerKeyRaw = record.providerKey;
|
|
362
|
+
const cooldownRaw = record.cooldownMs;
|
|
363
|
+
if (typeof providerIdRaw !== 'string' || !providerIdRaw.trim()) {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
const normalizedSeries = typeof seriesRaw === 'string' ? seriesRaw.trim().toLowerCase() : '';
|
|
367
|
+
if (normalizedSeries !== 'gemini-pro' && normalizedSeries !== 'gemini-flash' && normalizedSeries !== 'claude') {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
const cooldownMs = typeof cooldownRaw === 'number'
|
|
371
|
+
? cooldownRaw
|
|
372
|
+
: typeof cooldownRaw === 'string'
|
|
373
|
+
? Number.parseFloat(cooldownRaw)
|
|
374
|
+
: Number.NaN;
|
|
375
|
+
if (!Number.isFinite(cooldownMs) || cooldownMs <= 0) {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
return {
|
|
379
|
+
providerId: providerIdRaw.trim(),
|
|
380
|
+
...(typeof providerKeyRaw === 'string' && providerKeyRaw.trim().length
|
|
381
|
+
? { providerKey: providerKeyRaw.trim() }
|
|
382
|
+
: {}),
|
|
383
|
+
series: normalizedSeries,
|
|
384
|
+
cooldownMs: Math.round(cooldownMs)
|
|
385
|
+
};
|
|
303
386
|
}
|
|
304
387
|
export function deriveReason(code, stage, statusCode) {
|
|
305
388
|
if (code.includes('RATE') || code.includes('429'))
|
|
@@ -318,3 +401,19 @@ export function deriveReason(code, stage, statusCode) {
|
|
|
318
401
|
return 'client_error';
|
|
319
402
|
return 'unknown';
|
|
320
403
|
}
|
|
404
|
+
function resolveModelSeries(modelId) {
|
|
405
|
+
if (!modelId) {
|
|
406
|
+
return 'default';
|
|
407
|
+
}
|
|
408
|
+
const lower = modelId.toLowerCase();
|
|
409
|
+
if (lower.includes('claude') || lower.includes('opus')) {
|
|
410
|
+
return 'claude';
|
|
411
|
+
}
|
|
412
|
+
if (lower.includes('flash')) {
|
|
413
|
+
return 'gemini-flash';
|
|
414
|
+
}
|
|
415
|
+
if (lower.includes('gemini') || lower.includes('pro')) {
|
|
416
|
+
return 'gemini-pro';
|
|
417
|
+
}
|
|
418
|
+
return 'default';
|
|
419
|
+
}
|
|
@@ -13,18 +13,3 @@ export declare function pinCandidatesByAliasQueue(opts: {
|
|
|
13
13
|
modelIdOfKey: (providerKey: string) => string | null;
|
|
14
14
|
availabilityCheck: (providerKey: string) => boolean;
|
|
15
15
|
}): string[] | null;
|
|
16
|
-
export declare function pinCandidatesByBestQuota(opts: {
|
|
17
|
-
providerId: string;
|
|
18
|
-
modelId: string;
|
|
19
|
-
candidates: string[];
|
|
20
|
-
orderedTargets: string[];
|
|
21
|
-
aliasOfKey: (providerKey: string) => string | null;
|
|
22
|
-
modelIdOfKey: (providerKey: string) => string | null;
|
|
23
|
-
quotaView: ((providerKey: string) => {
|
|
24
|
-
remainingFraction?: number | null;
|
|
25
|
-
inPool: boolean;
|
|
26
|
-
cooldownUntil?: number | null;
|
|
27
|
-
blacklistUntil?: number | null;
|
|
28
|
-
} | null) | undefined;
|
|
29
|
-
now: number;
|
|
30
|
-
}): string[] | null;
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
const COOLDOWN_EMPTY_THRESHOLD_MS = 30_000;
|
|
2
1
|
// Default provider-level strategy table.
|
|
3
2
|
// This is a data-only default; callers can override via `loadBalancing.aliasSelection.providers`.
|
|
4
3
|
export const DEFAULT_PROVIDER_ALIAS_SELECTION = {
|
|
5
|
-
// Antigravity:
|
|
6
|
-
antigravity: '
|
|
4
|
+
// Antigravity: upstream gateway may reject rapid cross-key switching; stick to one alias until error.
|
|
5
|
+
antigravity: 'sticky-queue'
|
|
7
6
|
};
|
|
8
7
|
export function resolveAliasSelectionStrategy(providerId, cfg) {
|
|
9
8
|
if (!providerId)
|
|
@@ -12,11 +11,11 @@ export function resolveAliasSelectionStrategy(providerId, cfg) {
|
|
|
12
11
|
return 'none';
|
|
13
12
|
const overrides = cfg?.providers ?? {};
|
|
14
13
|
const override = overrides[providerId];
|
|
15
|
-
if (override === 'none' || override === 'sticky-queue'
|
|
14
|
+
if (override === 'none' || override === 'sticky-queue') {
|
|
16
15
|
return override;
|
|
17
16
|
}
|
|
18
17
|
const def = cfg?.defaultStrategy;
|
|
19
|
-
if (def === 'none' || def === 'sticky-queue'
|
|
18
|
+
if (def === 'none' || def === 'sticky-queue') {
|
|
20
19
|
return def;
|
|
21
20
|
}
|
|
22
21
|
const table = DEFAULT_PROVIDER_ALIAS_SELECTION[providerId];
|
|
@@ -101,86 +100,6 @@ export function pinCandidatesByAliasQueue(opts) {
|
|
|
101
100
|
const selectedSet = new Set(selectedKeys);
|
|
102
101
|
return candidates.filter((key) => selectedSet.has(key));
|
|
103
102
|
}
|
|
104
|
-
export function pinCandidatesByBestQuota(opts) {
|
|
105
|
-
const { providerId, modelId, candidates, orderedTargets, aliasOfKey, modelIdOfKey, quotaView, now } = opts;
|
|
106
|
-
if (!quotaView)
|
|
107
|
-
return null;
|
|
108
|
-
if (!providerId || !modelId)
|
|
109
|
-
return null;
|
|
110
|
-
if (!Array.isArray(candidates) || candidates.length < 2)
|
|
111
|
-
return null;
|
|
112
|
-
const aliasBuckets = new Map();
|
|
113
|
-
const aliasOrder = new Map();
|
|
114
|
-
let order = 0;
|
|
115
|
-
for (const key of candidates) {
|
|
116
|
-
if (!key || typeof key !== 'string')
|
|
117
|
-
continue;
|
|
118
|
-
if (!key.startsWith(`${providerId}.`))
|
|
119
|
-
return null;
|
|
120
|
-
const m = modelIdOfKey(key);
|
|
121
|
-
if (!m || m !== modelId)
|
|
122
|
-
return null;
|
|
123
|
-
const alias = aliasOfKey(key);
|
|
124
|
-
if (!alias)
|
|
125
|
-
return null;
|
|
126
|
-
const list = aliasBuckets.get(alias) ?? [];
|
|
127
|
-
list.push(key);
|
|
128
|
-
aliasBuckets.set(alias, list);
|
|
129
|
-
if (!aliasOrder.has(alias)) {
|
|
130
|
-
aliasOrder.set(alias, order++);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
if (aliasBuckets.size <= 1)
|
|
134
|
-
return null;
|
|
135
|
-
const preferredOrder = resolveAliasOrderFromTargets({
|
|
136
|
-
orderedTargets,
|
|
137
|
-
providerId,
|
|
138
|
-
modelId,
|
|
139
|
-
aliasOfKey,
|
|
140
|
-
modelIdOfKey,
|
|
141
|
-
allowedAliases: new Set(aliasBuckets.keys())
|
|
142
|
-
});
|
|
143
|
-
for (const alias of preferredOrder) {
|
|
144
|
-
if (!aliasOrder.has(alias)) {
|
|
145
|
-
aliasOrder.set(alias, order++);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
const eligible = [];
|
|
149
|
-
for (const [alias, keys] of aliasBuckets.entries()) {
|
|
150
|
-
const entry = quotaView(keys[0] ?? '');
|
|
151
|
-
if (!entry || entry.inPool === false) {
|
|
152
|
-
continue;
|
|
153
|
-
}
|
|
154
|
-
if (entry.blacklistUntil && entry.blacklistUntil > now) {
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
if (entry.cooldownUntil && entry.cooldownUntil - now >= COOLDOWN_EMPTY_THRESHOLD_MS) {
|
|
158
|
-
continue;
|
|
159
|
-
}
|
|
160
|
-
const remainingRaw = entry.remainingFraction;
|
|
161
|
-
const remaining = typeof remainingRaw === 'number' && Number.isFinite(remainingRaw) ? remainingRaw : 0;
|
|
162
|
-
if (remaining <= 0) {
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
eligible.push({
|
|
166
|
-
alias,
|
|
167
|
-
score: remaining,
|
|
168
|
-
order: aliasOrder.get(alias) ?? Number.MAX_SAFE_INTEGER
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
if (!eligible.length) {
|
|
172
|
-
return null;
|
|
173
|
-
}
|
|
174
|
-
eligible.sort((a, b) => (b.score - a.score) || (a.order - b.order));
|
|
175
|
-
const selectedAlias = eligible[0]?.alias;
|
|
176
|
-
if (!selectedAlias)
|
|
177
|
-
return null;
|
|
178
|
-
const selectedKeys = aliasBuckets.get(selectedAlias) ?? [];
|
|
179
|
-
if (!selectedKeys.length)
|
|
180
|
-
return null;
|
|
181
|
-
const selectedSet = new Set(selectedKeys);
|
|
182
|
-
return candidates.filter((key) => selectedSet.has(key));
|
|
183
|
-
}
|
|
184
103
|
function resolveAliasOrderFromTargets(opts) {
|
|
185
104
|
const { orderedTargets, providerId, modelId, aliasOfKey, modelIdOfKey, allowedAliases } = opts;
|
|
186
105
|
if (!Array.isArray(orderedTargets) || orderedTargets.length === 0) {
|
|
@@ -81,22 +81,16 @@ export function buildRouteCandidates(requestedRoute, classificationCandidates, f
|
|
|
81
81
|
return filtered.length ? filtered : [DEFAULT_ROUTE];
|
|
82
82
|
}
|
|
83
83
|
export function extendRouteCandidatesForState(candidates, state, routing) {
|
|
84
|
-
// When provider allowlists are active (e.g. "<**!
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
// We keep original ordering, then append all known routes (by priority) as a fallback search space.
|
|
84
|
+
// When provider allowlists are active (e.g. "<**!antigravity**>"),
|
|
85
|
+
// only look at the default pool. This keeps sticky semantics scoped
|
|
86
|
+
// to default routing, as required by RouteCodex.
|
|
89
87
|
if (!state.allowedProviders || state.allowedProviders.size === 0) {
|
|
90
88
|
return candidates;
|
|
91
89
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
for (const routeName of allRoutes) {
|
|
95
|
-
if (!expanded.includes(routeName)) {
|
|
96
|
-
expanded.push(routeName);
|
|
97
|
-
}
|
|
90
|
+
if (routeHasTargets(routing[DEFAULT_ROUTE])) {
|
|
91
|
+
return [DEFAULT_ROUTE];
|
|
98
92
|
}
|
|
99
|
-
return
|
|
93
|
+
return candidates;
|
|
100
94
|
}
|
|
101
95
|
function routeWeight(routeName) {
|
|
102
96
|
const idx = ROUTE_PRIORITY.indexOf(routeName);
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { computeContextMultiplier } from '../context-weighted.js';
|
|
2
2
|
import { computeHealthWeight } from '../health-weighted.js';
|
|
3
|
-
import { pinCandidatesByAliasQueue,
|
|
3
|
+
import { pinCandidatesByAliasQueue, resolveAliasSelectionStrategy } from './alias-selection.js';
|
|
4
4
|
import { computeContextWeightMultipliers } from './context-weight-multipliers.js';
|
|
5
5
|
import { extractKeyAlias, extractProviderId, getProviderModelId } from './key-parsing.js';
|
|
6
6
|
import { pickPriorityGroup } from './tier-priority.js';
|
|
7
|
-
const ANTIGRAVITY_COOLDOWN_ALIAS_THRESHOLD_MS = 30_000;
|
|
8
7
|
function applyAliasStickyQueuePinning(opts) {
|
|
9
|
-
const { candidates, orderedTargets, deps, excludedKeys
|
|
8
|
+
const { candidates, orderedTargets, deps, excludedKeys } = opts;
|
|
10
9
|
if (!Array.isArray(candidates) || candidates.length < 2) {
|
|
11
10
|
return candidates;
|
|
12
11
|
}
|
|
@@ -27,7 +26,7 @@ function applyAliasStickyQueuePinning(opts) {
|
|
|
27
26
|
continue;
|
|
28
27
|
}
|
|
29
28
|
const strategy = resolveAliasSelectionStrategy(providerId, deps.loadBalancer.getPolicy().aliasSelection);
|
|
30
|
-
if (strategy !== 'sticky-queue'
|
|
29
|
+
if (strategy !== 'sticky-queue') {
|
|
31
30
|
continue;
|
|
32
31
|
}
|
|
33
32
|
const modelId = getProviderModelId(key, deps.providerRegistry) ?? '';
|
|
@@ -59,33 +58,17 @@ function applyAliasStickyQueuePinning(opts) {
|
|
|
59
58
|
if (aliases.size < 2) {
|
|
60
59
|
continue;
|
|
61
60
|
}
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
now
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
if (!pinned) {
|
|
77
|
-
pinned = pinCandidatesByAliasQueue({
|
|
78
|
-
queueStore: store,
|
|
79
|
-
providerId: group.providerId,
|
|
80
|
-
modelId: group.modelId,
|
|
81
|
-
candidates: group.keys,
|
|
82
|
-
orderedTargets,
|
|
83
|
-
excludedProviderKeys: excludedKeys,
|
|
84
|
-
aliasOfKey: extractKeyAlias,
|
|
85
|
-
modelIdOfKey: (key) => getProviderModelId(key, deps.providerRegistry),
|
|
86
|
-
availabilityCheck: (key) => deps.healthManager.isAvailable(key)
|
|
87
|
-
});
|
|
88
|
-
}
|
|
61
|
+
const pinned = pinCandidatesByAliasQueue({
|
|
62
|
+
queueStore: store,
|
|
63
|
+
providerId: group.providerId,
|
|
64
|
+
modelId: group.modelId,
|
|
65
|
+
candidates: group.keys,
|
|
66
|
+
orderedTargets,
|
|
67
|
+
excludedProviderKeys: excludedKeys,
|
|
68
|
+
aliasOfKey: extractKeyAlias,
|
|
69
|
+
modelIdOfKey: (key) => getProviderModelId(key, deps.providerRegistry),
|
|
70
|
+
availabilityCheck: (key) => deps.healthManager.isAvailable(key)
|
|
71
|
+
});
|
|
89
72
|
if (pinned && pinned.length) {
|
|
90
73
|
pinnedByGroup.set(groupId, new Set(pinned));
|
|
91
74
|
}
|
|
@@ -178,8 +161,7 @@ export function selectProviderKeyFromCandidatePool(opts) {
|
|
|
178
161
|
candidates: retryPreferredCandidates,
|
|
179
162
|
orderedTargets: tier.targets,
|
|
180
163
|
deps,
|
|
181
|
-
excludedKeys
|
|
182
|
-
now
|
|
164
|
+
excludedKeys
|
|
183
165
|
});
|
|
184
166
|
if (tier.mode === 'priority') {
|
|
185
167
|
if (isRecoveryAttempt) {
|
|
@@ -258,11 +240,7 @@ export function selectProviderKeyFromCandidatePool(opts) {
|
|
|
258
240
|
continue;
|
|
259
241
|
}
|
|
260
242
|
if (entry.cooldownUntil && entry.cooldownUntil > now) {
|
|
261
|
-
|
|
262
|
-
const cooldownMs = entry.cooldownUntil - now;
|
|
263
|
-
if (providerId !== 'antigravity' || cooldownMs >= ANTIGRAVITY_COOLDOWN_ALIAS_THRESHOLD_MS) {
|
|
264
|
-
continue;
|
|
265
|
-
}
|
|
243
|
+
continue;
|
|
266
244
|
}
|
|
267
245
|
if (entry.blacklistUntil && entry.blacklistUntil > now) {
|
|
268
246
|
continue;
|
|
@@ -295,8 +273,7 @@ export function selectProviderKeyFromCandidatePool(opts) {
|
|
|
295
273
|
candidates: bucketCandidates,
|
|
296
274
|
orderedTargets: tier.targets,
|
|
297
275
|
deps,
|
|
298
|
-
excludedKeys
|
|
299
|
-
now
|
|
276
|
+
excludedKeys
|
|
300
277
|
});
|
|
301
278
|
const bucketPenaltyMap = {};
|
|
302
279
|
for (const item of bucket) {
|