@jsonstudio/llms 0.6.633 → 0.6.743

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.
Files changed (61) hide show
  1. package/dist/conversion/codecs/anthropic-openai-codec.js +0 -5
  2. package/dist/conversion/codecs/openai-openai-codec.js +0 -6
  3. package/dist/conversion/codecs/responses-openai-codec.js +1 -7
  4. package/dist/conversion/hub/node-support.js +5 -4
  5. package/dist/conversion/hub/pipeline/hub-pipeline.d.ts +14 -1
  6. package/dist/conversion/hub/pipeline/hub-pipeline.js +82 -18
  7. package/dist/conversion/hub/pipeline/session-identifiers.js +132 -2
  8. package/dist/conversion/hub/pipeline/stages/req_inbound/req_inbound_stage3_context_capture/index.js +23 -19
  9. package/dist/conversion/hub/pipeline/stages/resp_inbound/resp_inbound_stage1_sse_decode/index.js +47 -0
  10. package/dist/conversion/hub/pipeline/stages/resp_process/resp_process_stage1_tool_governance/index.js +4 -2
  11. package/dist/conversion/hub/process/chat-process.js +2 -0
  12. package/dist/conversion/hub/response/provider-response.js +6 -1
  13. package/dist/conversion/hub/snapshot-recorder.js +8 -1
  14. package/dist/conversion/pipeline/codecs/v2/shared/openai-chat-helpers.js +0 -7
  15. package/dist/conversion/responses/responses-openai-bridge.js +47 -7
  16. package/dist/conversion/shared/compaction-detect.d.ts +2 -0
  17. package/dist/conversion/shared/compaction-detect.js +53 -0
  18. package/dist/conversion/shared/errors.d.ts +1 -1
  19. package/dist/conversion/shared/reasoning-tool-normalizer.js +7 -0
  20. package/dist/conversion/shared/snapshot-hooks.d.ts +2 -0
  21. package/dist/conversion/shared/snapshot-hooks.js +180 -4
  22. package/dist/conversion/shared/snapshot-utils.d.ts +4 -0
  23. package/dist/conversion/shared/snapshot-utils.js +4 -0
  24. package/dist/conversion/shared/tool-filter-pipeline.js +3 -9
  25. package/dist/conversion/shared/tool-governor.d.ts +2 -0
  26. package/dist/conversion/shared/tool-governor.js +101 -13
  27. package/dist/conversion/shared/tool-harvester.js +42 -2
  28. package/dist/filters/index.d.ts +0 -2
  29. package/dist/filters/index.js +0 -2
  30. package/dist/filters/special/request-tools-normalize.d.ts +11 -0
  31. package/dist/filters/special/request-tools-normalize.js +13 -50
  32. package/dist/filters/special/response-apply-patch-toon-decode.js +403 -82
  33. package/dist/filters/special/response-tool-arguments-toon-decode.js +6 -75
  34. package/dist/filters/utils/snapshot-writer.js +42 -4
  35. package/dist/guidance/index.js +8 -2
  36. package/dist/router/virtual-router/engine-health.js +0 -4
  37. package/dist/router/virtual-router/engine-selection.d.ts +2 -1
  38. package/dist/router/virtual-router/engine-selection.js +101 -9
  39. package/dist/router/virtual-router/engine.d.ts +5 -1
  40. package/dist/router/virtual-router/engine.js +188 -5
  41. package/dist/router/virtual-router/routing-instructions.d.ts +6 -0
  42. package/dist/router/virtual-router/routing-instructions.js +18 -3
  43. package/dist/router/virtual-router/sticky-session-store.d.ts +1 -0
  44. package/dist/router/virtual-router/sticky-session-store.js +36 -0
  45. package/dist/router/virtual-router/types.d.ts +22 -0
  46. package/dist/servertool/engine.js +335 -9
  47. package/dist/servertool/handlers/compaction-detect.d.ts +1 -0
  48. package/dist/servertool/handlers/compaction-detect.js +1 -0
  49. package/dist/servertool/handlers/gemini-empty-reply-continue.js +29 -5
  50. package/dist/servertool/handlers/iflow-model-error-retry.js +17 -0
  51. package/dist/servertool/handlers/stop-message-auto.js +199 -19
  52. package/dist/servertool/server-side-tools.d.ts +0 -1
  53. package/dist/servertool/server-side-tools.js +0 -1
  54. package/dist/servertool/types.d.ts +1 -0
  55. package/dist/tools/apply-patch-structured.js +52 -15
  56. package/dist/tools/tool-registry.js +537 -15
  57. package/dist/utils/toon.d.ts +4 -0
  58. package/dist/utils/toon.js +75 -0
  59. package/package.json +4 -2
  60. package/dist/test-output/virtual-router/results.json +0 -1
  61. package/dist/test-output/virtual-router/summary.json +0 -12
@@ -1,5 +1,6 @@
1
1
  import { isShellToolName, normalizeToolName } from '../../tools/tool-description-utils.js';
2
2
  import { repairFindMeta } from '../../conversion/shared/tooling.js';
3
+ import { decodeToonToKeyValue, coerceToonValue } from '../../utils/toon.js';
3
4
  function envEnabled() {
4
5
  // Default ON. Allow disabling via env RCC_TOON_ENABLE/ROUTECODEX_TOON_ENABLE = 0|false|off
5
6
  const v = String(process?.env?.RCC_TOON_ENABLE || process?.env?.ROUTECODEX_TOON_ENABLE || '').toLowerCase();
@@ -8,71 +9,6 @@ function envEnabled() {
8
9
  return !(v === '0' || v === 'false' || v === 'off');
9
10
  }
10
11
  function isObject(v) { return !!v && typeof v === 'object' && !Array.isArray(v); }
11
- function decodeToonPairs(toon) {
12
- try {
13
- const out = {};
14
- const lines = String(toon).split(/\r?\n/);
15
- let currentKey = null;
16
- let currentVal = '';
17
- const flush = () => {
18
- if (currentKey) {
19
- out[currentKey] = currentVal;
20
- }
21
- currentKey = null;
22
- currentVal = '';
23
- };
24
- for (const raw of lines) {
25
- const line = raw.trim();
26
- if (!line)
27
- continue;
28
- const m = line.match(/^([A-Za-z0-9_\-]+)\s*:\s*(.*)$/);
29
- if (m) {
30
- // 新的 key: value 行,先提交上一段,再开始累积新 key 的值
31
- flush();
32
- currentKey = m[1];
33
- currentVal = m[2] ?? '';
34
- }
35
- else {
36
- // 非 key: value 行视为上一 key 的续行(例如多行脚本)
37
- if (!currentKey) {
38
- // 如果一开始就遇到无法识别的行,认为整个 TOON 不是我们支持的形态
39
- return null;
40
- }
41
- currentVal += (currentVal ? '\n' : '') + raw;
42
- }
43
- }
44
- flush();
45
- return Object.keys(out).length ? out : null;
46
- }
47
- catch {
48
- return null;
49
- }
50
- }
51
- function coerceToPrimitive(value) {
52
- const trimmed = value.trim();
53
- if (!trimmed)
54
- return '';
55
- const lower = trimmed.toLowerCase();
56
- if (lower === 'true')
57
- return true;
58
- if (lower === 'false')
59
- return false;
60
- if (/^[+-]?\d+(\.\d+)?$/.test(trimmed)) {
61
- const num = Number(trimmed);
62
- if (Number.isFinite(num))
63
- return num;
64
- }
65
- if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
66
- (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
67
- try {
68
- return JSON.parse(trimmed);
69
- }
70
- catch {
71
- // fall through
72
- }
73
- }
74
- return value;
75
- }
76
12
  /**
77
13
  * Decode arguments.toon to standard JSON ({command, workdir?}) and map tool name 'shell_toon' → 'shell'.
78
14
  * Stage: response_pre (before arguments stringify and invariants).
@@ -99,7 +35,6 @@ export class ResponseToolArgumentsToonDecodeFilter {
99
35
  const toolName = typeof rawName === 'string' ? rawName : '';
100
36
  const normalizedName = normalizeToolName(toolName);
101
37
  const isShellLike = isShellToolName(toolName);
102
- const isApplyPatch = normalizedName === 'apply_patch';
103
38
  const argIn = fn.arguments;
104
39
  let parsed = undefined;
105
40
  if (typeof argIn === 'string') {
@@ -118,7 +53,7 @@ export class ResponseToolArgumentsToonDecodeFilter {
118
53
  const toon = parsed.toon;
119
54
  if (typeof toon !== 'string' || !toon.trim())
120
55
  continue;
121
- const kv = decodeToonPairs(toon);
56
+ const kv = decodeToonToKeyValue(toon);
122
57
  if (!kv) {
123
58
  const preview = toon.split(/\r?\n/).slice(0, 5).join('\n');
124
59
  const warnMsg = `response_tool_arguments_toon_decode: failed to decode TOON arguments for tool "${fn.name ?? 'unknown'}"`;
@@ -137,10 +72,6 @@ export class ResponseToolArgumentsToonDecodeFilter {
137
72
  }
138
73
  continue; // keep original if decode fails
139
74
  }
140
- // apply_patch 的 toon 由专门的 ResponseApplyPatchToonDecodeFilter 处理,这里跳过,避免覆盖。
141
- if (isApplyPatch) {
142
- continue;
143
- }
144
75
  if (isShellLike) {
145
76
  const commandRaw = (typeof kv['command'] === 'string' && kv['command'].trim()
146
77
  ? kv['command']
@@ -191,16 +122,16 @@ export class ResponseToolArgumentsToonDecodeFilter {
191
122
  catch {
192
123
  /* keep original */
193
124
  }
194
- if (typeof fn.name === 'string' && fn.name === 'shell_toon') {
195
- fn.name = 'shell';
196
- }
125
+ }
126
+ if (typeof fn.name === 'string' && fn.name === 'shell_toon') {
127
+ fn.name = 'shell';
197
128
  }
198
129
  }
199
130
  else {
200
131
  // 通用 TOON → JSON 解码:除 shell / apply_patch 以外的工具,将 key: value 对映射为普通 JSON 字段。
201
132
  const merged = {};
202
133
  for (const [key, value] of Object.entries(kv)) {
203
- merged[key] = coerceToPrimitive(value);
134
+ merged[key] = coerceToonValue(value);
204
135
  }
205
136
  try {
206
137
  fn.arguments = JSON.stringify(merged);
@@ -13,22 +13,60 @@ function isSnapshotEnabled() {
13
13
  const v = String(process?.env?.RCC_FILTER_SNAPSHOT || process?.env?.RCC_HOOKS_VERBOSITY || '').toLowerCase();
14
14
  return v === '1' || v === 'true' || v === 'verbose';
15
15
  }
16
+ function sanitizeToken(value, fallback) {
17
+ if (typeof value !== 'string') {
18
+ return fallback;
19
+ }
20
+ const trimmed = value.trim();
21
+ if (!trimmed) {
22
+ return fallback;
23
+ }
24
+ return trimmed.replace(/[^A-Za-z0-9_.-]/g, '_') || fallback;
25
+ }
26
+ function toErrorCode(error) {
27
+ if (!error || typeof error !== 'object') {
28
+ return undefined;
29
+ }
30
+ const code = error.code;
31
+ return typeof code === 'string' && code.trim() ? code : undefined;
32
+ }
33
+ async function writeUniqueFile(dir, baseName, contents) {
34
+ const parsed = path.parse(baseName);
35
+ const ext = parsed.ext || '.json';
36
+ const stem = parsed.name || 'snapshot';
37
+ for (let i = 0; i < 64; i += 1) {
38
+ const name = i === 0 ? `${stem}${ext}` : `${stem}_${i}${ext}`;
39
+ try {
40
+ await fsp.writeFile(path.join(dir, name), contents, { encoding: 'utf-8', flag: 'wx' });
41
+ return;
42
+ }
43
+ catch (error) {
44
+ if (toErrorCode(error) === 'EEXIST') {
45
+ continue;
46
+ }
47
+ throw error;
48
+ }
49
+ }
50
+ const fallback = `${stem}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${ext}`;
51
+ await fsp.writeFile(path.join(dir, fallback), contents, 'utf-8');
52
+ }
16
53
  export async function writeFilterSnapshot(options) {
17
54
  try {
18
55
  if (!isSnapshotEnabled())
19
56
  return;
20
- const rid = options.requestId || `req_${Date.now()}`;
57
+ const rid = sanitizeToken(options.requestId || '', `req_${Date.now()}`);
21
58
  const baseOverride = process?.env?.RCC_SNAPSHOT_DIR;
22
59
  const base = baseOverride && baseOverride.trim()
23
60
  ? baseOverride.trim()
24
61
  : path.join(os.homedir(), '.routecodex', 'codex-samples');
25
62
  const folder = mapEndpointToFolder(options.endpoint);
26
- const dir = path.join(base, folder);
63
+ const provider = sanitizeToken(options.profile || '', '__pending__');
64
+ const dir = path.join(base, folder, provider, rid);
27
65
  await fsp.mkdir(dir, { recursive: true });
28
66
  const parts = ['filters', options.stage.replace(/\s+/g, ''), options.tag || (options.name ? `after_${options.name}` : 'after')]
29
67
  .filter(Boolean)
30
68
  .join('_');
31
- const file = path.join(dir, `${rid}_${parts}.json`);
69
+ const file = `${sanitizeToken(parts, 'filters')}.json`;
32
70
  const payload = {
33
71
  meta: {
34
72
  requestId: rid,
@@ -41,7 +79,7 @@ export async function writeFilterSnapshot(options) {
41
79
  },
42
80
  data: options.data
43
81
  };
44
- await fsp.writeFile(file, JSON.stringify(payload, null, 2), 'utf-8');
82
+ await writeUniqueFile(dir, file, JSON.stringify(payload, null, 2));
45
83
  }
46
84
  catch { /* ignore snapshot errors */ }
47
85
  }
@@ -66,7 +66,9 @@ function augmentApplyPatch(fn) {
66
66
  'Each change describes one operation, e.g. `{ "file": "src/foo.ts", "kind": "insert_after", "anchor": "const foo = 1;", "lines": ["const bar = 2;"] }`.',
67
67
  'Supported kinds: insert_after, insert_before, replace, delete, create_file, delete_file.',
68
68
  'Paths must stay relative to the workspace root (no leading "/" or drive letters).',
69
- 'Insert operations require `anchor` text; replace/delete require exact `target` snippets; `lines` omit "+/-" prefixes.'
69
+ 'Insert operations require `anchor` text; replace/delete require exact `target` snippets; `lines` omit "+/-" prefixes.',
70
+ 'If you must emit raw patch text, use "*** Begin Patch" / "*** End Patch" with "*** Update/Add/Delete File" headers (no "diff --git").',
71
+ 'Do not output "*** Create File:"; use "*** Add File:".'
70
72
  ].join('\n');
71
73
  const params = ensureObjectSchema(fn.parameters);
72
74
  const props = params.properties;
@@ -230,7 +232,10 @@ export function augmentAnthropicTools(tools) {
230
232
  'Before using apply_patch, always read the latest content of the target file (via shell or another tool) and base your changes on that content.',
231
233
  'Provide structured changes (insert_after / insert_before / replace / delete / create_file / delete_file) instead of raw patch text.',
232
234
  'Each change must include the target file (relative path) plus anchor/target snippets and the replacement lines.',
233
- '所有路径必须相对工作区根目录,禁止输出以 / 或盘符开头的绝对路径。'
235
+ '所有路径必须相对工作区根目录,禁止输出以 / 或盘符开头的绝对路径。',
236
+ 'Example: {\"changes\":[{\"file\":\"src/app.ts\",\"kind\":\"replace\",\"target\":\"const answer = 41;\",\"lines\":[\"const answer = 42;\"]}]}(修改同一文件时尽量只修改一段连续区域,多处不相邻修改请拆成多次 apply_patch 调用).',
237
+ 'Raw patch text must use "*** Begin Patch" / "*** End Patch" + "*** Update/Add/Delete File" headers(不要输出 "diff --git")。',
238
+ '不要输出 "*** Create File:";请使用 "*** Add File:".'
234
239
  ].join('\n');
235
240
  copy.description = appendOnce(desc, guidance, marker);
236
241
  }
@@ -267,6 +272,7 @@ export function buildSystemToolGuidance() {
267
272
  lines.push(bullet('File writes are FORBIDDEN via shell (no redirection, no here-doc, no sed -i, no ed -s, no tee). Use apply_patch ONLY. / 通过 shell 写文件一律禁止(不得使用重定向、heredoc、sed -i、ed -s、tee);必须使用 apply_patch。'));
268
273
  lines.push(bullet('apply_patch: Before writing, always read the target file first and compute changes against the latest content using appropriate tools. / apply_patch 在写入前必须先通过合适的工具读取目标文件最新内容,并基于该内容生成变更。'));
269
274
  lines.push(bullet('apply_patch: Provide structured JSON arguments with a `changes` array (insert_after / insert_before / replace / delete / create_file / delete_file); omit "+/-" prefixes in `lines`; file paths必须是相对路径。 / apply_patch 仅接受结构化 JSON。'));
275
+ lines.push(bullet('apply_patch: For a given file, prefer one contiguous change block per call; if you need to touch non-adjacent regions, split them into multiple apply_patch calls. / apply_patch 修改同一文件时尽量只提交一段连续补丁,多个不相邻位置请拆成多次调用。'));
270
276
  lines.push(bullet('update_plan: Keep exactly one step in_progress; others pending/completed. / 仅一个 in_progress 步骤。'));
271
277
  lines.push(bullet('view_image: Path must be an image file (.png .jpg .jpeg .gif .webp .bmp .svg). / 仅图片路径。'));
272
278
  lines.push(bullet('Do NOT use view_image for text files (.md/.ts/.js/.json). Use shell: {"command":["cat","<path>"]}. / 文本文件请用 shell: cat。'));
@@ -275,10 +275,6 @@ export function applyQuotaRecoveryImpl(event, healthManager, clearProviderCooldo
275
275
  healthManager.recordSuccess(providerKey);
276
276
  resetRateLimitBackoffForProvider(providerKey);
277
277
  clearProviderCooldown(providerKey);
278
- debug?.log?.('[virtual-router] quota recovery', {
279
- providerKey,
280
- reason: detail.reason
281
- });
282
278
  }
283
279
  catch {
284
280
  // 恢复失败不得影响主路由流程
@@ -1,4 +1,4 @@
1
- import type { ClassificationResult, RoutePoolTier, RouterMetadataInput, RoutingFeatures } from './types.js';
1
+ import type { ClassificationResult, RoutePoolTier, RouterMetadataInput, RoutingFeatures, ProviderQuotaView } from './types.js';
2
2
  import type { RoutingInstructionState } from './routing-instructions.js';
3
3
  import type { ContextAdvisor } from './context-advisor.js';
4
4
  import type { RouteLoadBalancer } from './load-balancer.js';
@@ -12,6 +12,7 @@ type SelectionDeps = {
12
12
  loadBalancer: RouteLoadBalancer;
13
13
  isProviderCoolingDown: (providerKey: string) => boolean;
14
14
  resolveStickyKey: (metadata: RouterMetadataInput) => string | undefined;
15
+ quotaView?: ProviderQuotaView;
15
16
  };
16
17
  export declare function selectProviderImpl(requestedRoute: string, metadata: RouterMetadataInput, classification: ClassificationResult, features: RoutingFeatures, activeState: RoutingInstructionState, deps: SelectionDeps, options?: {
17
18
  routingState?: RoutingInstructionState;
@@ -1,11 +1,32 @@
1
1
  import { DEFAULT_ROUTE, ROUTE_PRIORITY, VirtualRouterError, VirtualRouterErrorCode } from './types.js';
2
2
  export function selectProviderImpl(requestedRoute, metadata, classification, features, activeState, deps, options = {}) {
3
3
  const state = options.routingState ?? activeState;
4
+ const quotaView = deps.quotaView;
5
+ const quotaNow = quotaView ? Date.now() : 0;
6
+ const isAllowedByQuota = (key) => {
7
+ if (!quotaView) {
8
+ return true;
9
+ }
10
+ const entry = quotaView(key);
11
+ if (!entry) {
12
+ return true;
13
+ }
14
+ if (!entry.inPool) {
15
+ return false;
16
+ }
17
+ if (entry.cooldownUntil && entry.cooldownUntil > quotaNow) {
18
+ return false;
19
+ }
20
+ if (entry.blacklistUntil && entry.blacklistUntil > quotaNow) {
21
+ return false;
22
+ }
23
+ return true;
24
+ };
4
25
  const excludedProviderKeys = extractExcludedProviderKeySet(features.metadata);
5
26
  const forcedResolution = state.forcedTarget ? resolveInstructionTarget(state.forcedTarget, deps.providerRegistry) : null;
6
27
  if (forcedResolution && forcedResolution.mode === 'exact') {
7
28
  const forcedKey = forcedResolution.keys[0];
8
- if (!excludedProviderKeys.has(forcedKey) && !deps.isProviderCoolingDown(forcedKey)) {
29
+ if (!excludedProviderKeys.has(forcedKey) && !deps.isProviderCoolingDown(forcedKey) && isAllowedByQuota(forcedKey)) {
9
30
  return {
10
31
  providerKey: forcedKey,
11
32
  routeUsed: requestedRoute,
@@ -22,7 +43,8 @@ export function selectProviderImpl(requestedRoute, metadata, classification, fea
22
43
  const stickyKey = stickyResolution.keys[0];
23
44
  if (deps.healthManager.isAvailable(stickyKey) &&
24
45
  !excludedProviderKeys.has(stickyKey) &&
25
- !deps.isProviderCoolingDown(stickyKey)) {
46
+ !deps.isProviderCoolingDown(stickyKey) &&
47
+ isAllowedByQuota(stickyKey)) {
26
48
  return {
27
49
  providerKey: stickyKey,
28
50
  routeUsed: requestedRoute,
@@ -34,7 +56,8 @@ export function selectProviderImpl(requestedRoute, metadata, classification, fea
34
56
  if (stickyResolution && stickyResolution.mode === 'filter' && stickyResolution.keys.length > 0) {
35
57
  const liveKeys = stickyResolution.keys.filter((key) => deps.healthManager.isAvailable(key) &&
36
58
  !excludedProviderKeys.has(key) &&
37
- !deps.isProviderCoolingDown(key));
59
+ !deps.isProviderCoolingDown(key) &&
60
+ isAllowedByQuota(key));
38
61
  if (liveKeys.length > 0) {
39
62
  stickyKeySet = new Set(liveKeys);
40
63
  }
@@ -247,13 +270,62 @@ function trySelectFromTier(routeName, tier, stickyKey, estimatedTokens, features
247
270
  }
248
271
  const contextResult = deps.contextAdvisor.classify(targets, estimatedTokens, (key) => deps.providerRegistry.get(key));
249
272
  const prioritizedPools = buildContextCandidatePools(contextResult);
273
+ const quotaView = deps.quotaView;
274
+ const now = quotaView ? Date.now() : 0;
275
+ const selectWithQuota = (candidates) => {
276
+ if (!quotaView) {
277
+ return deps.loadBalancer.select({
278
+ routeName: `${routeName}:${tier.id}`,
279
+ candidates,
280
+ stickyKey: options.allowAliasRotation ? undefined : stickyKey,
281
+ availabilityCheck: (key) => deps.healthManager.isAvailable(key)
282
+ });
283
+ }
284
+ const buckets = new Map();
285
+ for (const key of candidates) {
286
+ const entry = quotaView(key);
287
+ if (!entry) {
288
+ const list = buckets.get(100) ?? [];
289
+ list.push(key);
290
+ buckets.set(100, list);
291
+ continue;
292
+ }
293
+ if (!entry.inPool) {
294
+ continue;
295
+ }
296
+ if (entry.cooldownUntil && entry.cooldownUntil > now) {
297
+ continue;
298
+ }
299
+ if (entry.blacklistUntil && entry.blacklistUntil > now) {
300
+ continue;
301
+ }
302
+ const tierPriority = typeof entry.priorityTier === 'number' && Number.isFinite(entry.priorityTier)
303
+ ? entry.priorityTier
304
+ : 100;
305
+ const list = buckets.get(tierPriority) ?? [];
306
+ list.push(key);
307
+ buckets.set(tierPriority, list);
308
+ }
309
+ const sortedPriorities = Array.from(buckets.keys()).sort((a, b) => a - b);
310
+ for (const priority of sortedPriorities) {
311
+ const bucketCandidates = buckets.get(priority) ?? [];
312
+ if (!bucketCandidates.length) {
313
+ continue;
314
+ }
315
+ const selected = deps.loadBalancer.select({
316
+ routeName: `${routeName}:${tier.id}`,
317
+ candidates: bucketCandidates,
318
+ stickyKey: options.allowAliasRotation ? undefined : stickyKey,
319
+ availabilityCheck: (key) => deps.healthManager.isAvailable(key)
320
+ });
321
+ if (selected) {
322
+ return selected;
323
+ }
324
+ }
325
+ return null;
326
+ };
250
327
  for (const candidatePool of prioritizedPools) {
251
- const providerKey = deps.loadBalancer.select({
252
- routeName: `${routeName}:${tier.id}`,
253
- candidates: candidatePool,
254
- stickyKey: options.allowAliasRotation ? undefined : stickyKey,
255
- availabilityCheck: (key) => deps.healthManager.isAvailable(key)
256
- });
328
+ const providerKey = selectWithQuota(candidatePool);
257
329
  if (providerKey) {
258
330
  return { providerKey, poolTargets: tier.targets, tierId: tier.id };
259
331
  }
@@ -277,6 +349,26 @@ export function selectFromStickyPool(stickyKeySet, metadata, features, state, de
277
349
  ]));
278
350
  const disabledModels = new Map(Array.from(state.disabledModels.entries()).map(([provider, models]) => [provider, new Set(models)]));
279
351
  let candidates = Array.from(stickyKeySet).filter((key) => !deps.isProviderCoolingDown(key));
352
+ const quotaView = deps.quotaView;
353
+ const now = quotaView ? Date.now() : 0;
354
+ if (quotaView) {
355
+ candidates = candidates.filter((key) => {
356
+ const entry = quotaView(key);
357
+ if (!entry) {
358
+ return true;
359
+ }
360
+ if (!entry.inPool) {
361
+ return false;
362
+ }
363
+ if (entry.cooldownUntil && entry.cooldownUntil > now) {
364
+ return false;
365
+ }
366
+ if (entry.blacklistUntil && entry.blacklistUntil > now) {
367
+ return false;
368
+ }
369
+ return true;
370
+ });
371
+ }
280
372
  if (allowedProviders.size > 0) {
281
373
  candidates = candidates.filter((key) => {
282
374
  const providerId = extractProviderId(key);
@@ -1,6 +1,7 @@
1
- import { type RoutingDecision, type RoutingDiagnostics, type RouterMetadataInput, type VirtualRouterConfig, type TargetMetadata, type ProviderFailureEvent, type ProviderErrorEvent, type VirtualRouterHealthStore } from './types.js';
1
+ import { type RoutingDecision, type RoutingDiagnostics, type StopMessageStateSnapshot, type RouterMetadataInput, type VirtualRouterConfig, type TargetMetadata, type ProviderFailureEvent, type ProviderErrorEvent, type VirtualRouterHealthStore } from './types.js';
2
2
  import type { ProcessedRequest, StandardizedRequest } from '../../conversion/hub/types/standardized.js';
3
3
  import { type RoutingInstructionState } from './routing-instructions.js';
4
+ import type { ProviderQuotaView } from './types.js';
4
5
  interface RoutingInstructionStateStore {
5
6
  loadSync(key: string): RoutingInstructionState | null;
6
7
  saveAsync(key: string, state: RoutingInstructionState | null): void;
@@ -22,9 +23,11 @@ export declare class VirtualRouterEngine {
22
23
  private healthStore?;
23
24
  private routingStateStore;
24
25
  private routingInstructionState;
26
+ private quotaView?;
25
27
  constructor(deps?: {
26
28
  healthStore?: VirtualRouterHealthStore;
27
29
  routingStateStore?: RoutingInstructionStateStore;
30
+ quotaView?: ProviderQuotaView;
28
31
  });
29
32
  initialize(config: VirtualRouterConfig): void;
30
33
  route(request: StandardizedRequest | ProcessedRequest, metadata: RouterMetadataInput): {
@@ -32,6 +35,7 @@ export declare class VirtualRouterEngine {
32
35
  decision: RoutingDecision;
33
36
  diagnostics: RoutingDiagnostics;
34
37
  };
38
+ getStopMessageState(metadata: RouterMetadataInput): StopMessageStateSnapshot | null;
35
39
  handleProviderFailure(event: ProviderFailureEvent): void;
36
40
  handleProviderError(event: ProviderErrorEvent): void;
37
41
  getStatus(): {