@jsonstudio/llms 0.6.6 → 0.6.34

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.
@@ -262,6 +262,7 @@ export function buildOpenAIChatFromGeminiResponse(payload) {
262
262
  catch {
263
263
  // best-effort policy execution
264
264
  }
265
+ const normalizedFinishReason = toolCalls.length ? 'tool_calls' : finish_reason;
265
266
  const chatResp = {
266
267
  id: payload?.id || `chatcmpl_${Date.now()}`,
267
268
  object: 'chat.completion',
@@ -269,7 +270,7 @@ export function buildOpenAIChatFromGeminiResponse(payload) {
269
270
  choices: [
270
271
  {
271
272
  index: 0,
272
- finish_reason,
273
+ finish_reason: normalizedFinishReason,
273
274
  message: chatMsg
274
275
  }
275
276
  ]
package/dist/index.d.ts CHANGED
@@ -7,4 +7,5 @@
7
7
  export * from './conversion/index.js';
8
8
  export * from './router/virtual-router/bootstrap.js';
9
9
  export * from './router/virtual-router/types.js';
10
+ export { DEFAULT_THINKING_KEYWORDS } from './router/virtual-router/default-thinking-keywords.js';
10
11
  export declare const VERSION = "0.4.0";
package/dist/index.js CHANGED
@@ -7,4 +7,5 @@
7
7
  export * from './conversion/index.js';
8
8
  export * from './router/virtual-router/bootstrap.js';
9
9
  export * from './router/virtual-router/types.js';
10
+ export { DEFAULT_THINKING_KEYWORDS } from './router/virtual-router/default-thinking-keywords.js';
10
11
  export const VERSION = '0.4.0';
@@ -1,10 +1,13 @@
1
1
  import { VirtualRouterError, VirtualRouterErrorCode } from './types.js';
2
+ import { DEFAULT_THINKING_KEYWORDS } from './default-thinking-keywords.js';
3
+ const KEYWORD_INJECTION_KEYS = ['thinking', 'background', 'vision', 'coding'];
2
4
  const DEFAULT_CLASSIFIER = {
3
- longContextThresholdTokens: 60000,
4
- thinkingKeywords: ['think step', 'analysis', 'reasoning', '仔细分析', '深度思考'],
5
+ longContextThresholdTokens: 180000,
6
+ thinkingKeywords: DEFAULT_THINKING_KEYWORDS,
5
7
  codingKeywords: ['apply_patch', 'write_file', 'create_file', 'shell', '修改文件', '写入文件'],
6
8
  backgroundKeywords: ['background', 'context dump', '上下文'],
7
- visionKeywords: ['vision', 'image', 'picture', 'photo']
9
+ visionKeywords: ['vision', 'image', 'picture', 'photo'],
10
+ keywordInjections: {}
8
11
  };
9
12
  const DEFAULT_LOAD_BALANCING = { strategy: 'round-robin' };
10
13
  const DEFAULT_HEALTH = { failureThreshold: 3, cooldownMs: 30_000, fatalCooldownMs: 300_000 };
@@ -186,7 +189,8 @@ function normalizeClassifier(input) {
186
189
  thinkingKeywords: normalizeStringArray(normalized.thinkingKeywords, DEFAULT_CLASSIFIER.thinkingKeywords),
187
190
  codingKeywords: normalizeStringArray(normalized.codingKeywords, DEFAULT_CLASSIFIER.codingKeywords),
188
191
  backgroundKeywords: normalizeStringArray(normalized.backgroundKeywords, DEFAULT_CLASSIFIER.backgroundKeywords),
189
- visionKeywords: normalizeStringArray(normalized.visionKeywords, DEFAULT_CLASSIFIER.visionKeywords)
192
+ visionKeywords: normalizeStringArray(normalized.visionKeywords, DEFAULT_CLASSIFIER.visionKeywords),
193
+ keywordInjections: normalizeKeywordInjectionMap(normalized.keywordInjections)
190
194
  };
191
195
  return result;
192
196
  }
@@ -197,6 +201,26 @@ function normalizeStringArray(value, fallback) {
197
201
  const normalized = value.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean);
198
202
  return normalized.length ? normalized : [...fallback];
199
203
  }
204
+ function normalizeKeywordInjectionMap(value) {
205
+ const map = {};
206
+ const record = asRecord(value);
207
+ if (!record) {
208
+ return map;
209
+ }
210
+ for (const key of KEYWORD_INJECTION_KEYS) {
211
+ const rawList = record[key];
212
+ if (!Array.isArray(rawList)) {
213
+ continue;
214
+ }
215
+ const normalized = rawList
216
+ .map((entry) => (typeof entry === 'string' ? entry.trim() : ''))
217
+ .filter(Boolean);
218
+ if (normalized.length) {
219
+ map[key] = normalized;
220
+ }
221
+ }
222
+ return map;
223
+ }
200
224
  function normalizeProvider(providerId, raw) {
201
225
  const provider = asRecord(raw);
202
226
  const providerType = detectProviderType(provider);
@@ -234,9 +258,9 @@ function normalizeResponsesConfig(provider) {
234
258
  return undefined;
235
259
  }
236
260
  function resolveCompatibilityProfile(provider) {
237
- const compat = provider.compat;
238
- if (typeof compat === 'string' && compat.trim().length > 0) {
239
- return compat.trim();
261
+ const profile = readOptionalString(provider.compatibilityProfile);
262
+ if (profile) {
263
+ return profile;
240
264
  }
241
265
  return 'default';
242
266
  }
@@ -1,19 +1,42 @@
1
1
  import { DEFAULT_ROUTE, ROUTE_PRIORITY } from './types.js';
2
- const DEFAULT_LONG_CONTEXT_THRESHOLD = 60000;
2
+ import { DEFAULT_THINKING_KEYWORDS } from './default-thinking-keywords.js';
3
+ const DEFAULT_LONG_CONTEXT_THRESHOLD = 180000;
3
4
  export class RoutingClassifier {
4
5
  config;
5
6
  constructor(config) {
7
+ const keywordConfig = normalizeKeywordConfig(config);
6
8
  this.config = {
7
9
  longContextThresholdTokens: config.longContextThresholdTokens ?? DEFAULT_LONG_CONTEXT_THRESHOLD,
8
- thinkingKeywords: normalizeList(config.thinkingKeywords, ['think step', 'analysis', 'reasoning']),
9
- backgroundKeywords: normalizeList(config.backgroundKeywords, ['background', 'context dump'])
10
+ thinkingKeywords: keywordConfig.thinking,
11
+ backgroundKeywords: keywordConfig.background,
12
+ visionKeywords: keywordConfig.vision,
13
+ codingKeywords: keywordConfig.coding
10
14
  };
11
15
  }
12
16
  classify(features) {
13
17
  const evaluations = [
18
+ {
19
+ route: 'longcontext',
20
+ triggered: features.estimatedTokens >= (this.config.longContextThresholdTokens ?? DEFAULT_LONG_CONTEXT_THRESHOLD),
21
+ reason: 'longcontext:token-threshold'
22
+ },
23
+ {
24
+ route: 'thinking',
25
+ triggered: features.hasThinkingKeyword ||
26
+ containsKeywords(features.userTextSample, this.config.thinkingKeywords ?? []) ||
27
+ features.previousToolCategory === 'context_read' ||
28
+ features.previousToolCategory === 'plan',
29
+ reason: 'thinking:keywords'
30
+ },
31
+ {
32
+ route: 'background',
33
+ triggered: containsKeywords(features.userTextSample, this.config.backgroundKeywords ?? []),
34
+ reason: 'background:keywords'
35
+ },
14
36
  {
15
37
  route: 'vision',
16
- triggered: features.hasVisionTool && features.hasImageAttachment,
38
+ triggered: (features.hasVisionTool && features.hasImageAttachment) ||
39
+ features.previousToolCategory === 'vision',
17
40
  reason: 'vision:requires-tool+image'
18
41
  },
19
42
  {
@@ -23,29 +46,15 @@ export class RoutingClassifier {
23
46
  },
24
47
  {
25
48
  route: 'coding',
26
- triggered: features.hasCodingTool,
49
+ triggered: features.hasCodingTool ||
50
+ containsKeywords(features.userTextSample, this.config.codingKeywords ?? []) ||
51
+ features.previousToolCategory === 'coding',
27
52
  reason: 'coding:coding-tools-detected'
28
53
  },
29
54
  {
30
55
  route: 'tools',
31
56
  triggered: features.hasTools || features.hasToolCallResponses,
32
57
  reason: 'tools:tool-request-detected'
33
- },
34
- {
35
- route: 'longcontext',
36
- triggered: features.estimatedTokens >= (this.config.longContextThresholdTokens ?? DEFAULT_LONG_CONTEXT_THRESHOLD),
37
- reason: 'longcontext:token-threshold'
38
- },
39
- {
40
- route: 'thinking',
41
- triggered: features.hasThinkingKeyword ||
42
- containsKeywords(features.userTextSample, this.config.thinkingKeywords ?? []),
43
- reason: 'thinking:keywords'
44
- },
45
- {
46
- route: 'background',
47
- triggered: containsKeywords(features.userTextSample, this.config.backgroundKeywords ?? []),
48
- reason: 'background:keywords'
49
58
  }
50
59
  ];
51
60
  const triggeredEvaluations = evaluations.filter((evaluation) => evaluation.triggered);
@@ -84,12 +93,6 @@ export class RoutingClassifier {
84
93
  return index >= 0 ? index : ROUTE_PRIORITY.length;
85
94
  }
86
95
  }
87
- function normalizeList(source, fallback) {
88
- if (!source || source.length === 0) {
89
- return fallback;
90
- }
91
- return source.map((item) => item.toLowerCase());
92
- }
93
96
  function containsKeywords(text, keywords) {
94
97
  if (!text || !keywords.length) {
95
98
  return false;
@@ -97,3 +100,29 @@ function containsKeywords(text, keywords) {
97
100
  const normalized = text.toLowerCase();
98
101
  return keywords.some((keyword) => normalized.includes(keyword));
99
102
  }
103
+ function normalizeKeywordConfig(config) {
104
+ const injections = config.keywordInjections ?? {};
105
+ return {
106
+ thinking: mergeKeywordLists(DEFAULT_THINKING_KEYWORDS, config.thinkingKeywords, injections.thinking),
107
+ background: mergeKeywordLists(['background', 'context dump', '上下文'], config.backgroundKeywords, injections.background),
108
+ vision: mergeKeywordLists(['vision', 'image', 'picture', 'photo'], config.visionKeywords, injections.vision),
109
+ coding: mergeKeywordLists(config.codingKeywords, injections.coding)
110
+ };
111
+ }
112
+ function mergeKeywordLists(...lists) {
113
+ const set = new Set();
114
+ for (const list of lists) {
115
+ if (!Array.isArray(list)) {
116
+ continue;
117
+ }
118
+ for (const item of list) {
119
+ if (!item)
120
+ continue;
121
+ const normalized = String(item).toLowerCase();
122
+ if (normalized) {
123
+ set.add(normalized);
124
+ }
125
+ }
126
+ }
127
+ return Array.from(set);
128
+ }
@@ -0,0 +1 @@
1
+ export declare const DEFAULT_THINKING_KEYWORDS: string[];
@@ -0,0 +1,13 @@
1
+ export const DEFAULT_THINKING_KEYWORDS = [
2
+ '思考',
3
+ '深度思考',
4
+ '分析',
5
+ '推理',
6
+ '思路',
7
+ '一步一步',
8
+ '慢慢',
9
+ 'think',
10
+ 'thinking',
11
+ 'step by step',
12
+ 'reason'
13
+ ];
@@ -1,3 +1,4 @@
1
+ import { countRequestTokens } from './token-counter.js';
1
2
  const THINKING_KEYWORDS = ['let me think', 'chain of thought', 'cot', 'reason step', 'deliberate'];
2
3
  const WEB_TOOL_KEYWORDS = ['websearch', 'web_search', 'web-search', 'webfetch', 'web_fetch', 'web_request', 'search_web', 'internet_search'];
3
4
  export function buildRoutingFeatures(request, metadata) {
@@ -7,10 +8,11 @@ export function buildRoutingFeatures(request, metadata) {
7
8
  const normalizedUserText = latestUserText.toLowerCase();
8
9
  const hasTools = Array.isArray(request.tools) && request.tools.length > 0;
9
10
  const hasToolCallResponses = assistantMessages.some((msg) => Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0);
10
- const estimatedTokens = estimateTokens(latestUserText, latestUserText ? 1 : 0);
11
+ const estimatedTokens = countRequestTokens(request);
11
12
  const hasThinking = detectKeyword(normalizedUserText, THINKING_KEYWORDS);
12
- const hasVisionTool = detectVisionTool(request);
13
- const hasImageAttachment = hasVisionTool && detectImageAttachment(latestUserMessage);
13
+ const previousToolCategory = detectPreviousToolCategory(request.messages);
14
+ const hasVisionTool = detectVisionTool(request) || previousToolCategory === 'vision';
15
+ const hasImageAttachment = previousToolCategory === 'vision' || (hasVisionTool && detectImageAttachment(latestUserMessage));
14
16
  const hasCodingTool = detectCodingTool(request);
15
17
  const hasWebTool = detectWebTool(request);
16
18
  const hasThinkingKeyword = hasThinking || detectExtendedThinkingKeyword(normalizedUserText);
@@ -28,6 +30,7 @@ export function buildRoutingFeatures(request, metadata) {
28
30
  hasCodingTool,
29
31
  hasThinkingKeyword,
30
32
  estimatedTokens,
33
+ previousToolCategory,
31
34
  metadata: {
32
35
  ...metadata
33
36
  }
@@ -125,12 +128,111 @@ function detectExtendedThinkingKeyword(text) {
125
128
  const keywords = ['仔细分析', '思考', '超级思考', '深度思考', 'careful analysis', 'deep thinking', 'deliberate'];
126
129
  return keywords.some((keyword) => text.includes(keyword));
127
130
  }
128
- function estimateTokens(text, messageCount) {
129
- if (!text) {
130
- return Math.max(32, messageCount * 16);
131
+ function detectPreviousToolCategory(messages) {
132
+ if (!Array.isArray(messages) || !messages.length) {
133
+ return null;
134
+ }
135
+ let lastUserIndex = messages.length;
136
+ for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
137
+ if (messages[idx]?.role === 'user') {
138
+ lastUserIndex = idx;
139
+ break;
140
+ }
141
+ }
142
+ for (let idx = lastUserIndex - 1; idx >= 0; idx -= 1) {
143
+ const candidate = messages[idx];
144
+ if (!candidate || candidate.role !== 'assistant') {
145
+ continue;
146
+ }
147
+ const messageRecord = candidate;
148
+ const rawCalls = Array.isArray(messageRecord.tool_calls)
149
+ ? messageRecord.tool_calls
150
+ : [];
151
+ if (!rawCalls.length) {
152
+ continue;
153
+ }
154
+ for (const call of rawCalls) {
155
+ const categorized = categorizeToolCall(call);
156
+ if (categorized) {
157
+ return categorized;
158
+ }
159
+ }
160
+ }
161
+ return null;
162
+ }
163
+ function categorizeToolCall(call) {
164
+ if (!call) {
165
+ return null;
166
+ }
167
+ const fn = (call.function ?? call);
168
+ const name = typeof (fn?.name ?? call.name) === 'string' ? String(fn?.name ?? call.name) : '';
169
+ if (!name) {
170
+ return null;
171
+ }
172
+ const args = parseFunctionArguments(typeof fn?.arguments === 'string' ? fn.arguments : undefined);
173
+ if (name === 'update_plan') {
174
+ return 'plan';
175
+ }
176
+ if (name === 'view_image') {
177
+ return hasImageLink(args) ? 'vision' : null;
178
+ }
179
+ if (name === 'apply_patch') {
180
+ return 'coding';
131
181
  }
132
- const rough = Math.ceil(text.length / 4);
133
- return Math.max(rough, messageCount * 32);
182
+ if (name === 'shell_command') {
183
+ const command = typeof args?.command === 'string' ? args.command : '';
184
+ return classifyShellCommand(command);
185
+ }
186
+ return null;
187
+ }
188
+ function parseFunctionArguments(raw) {
189
+ if (!raw) {
190
+ return null;
191
+ }
192
+ try {
193
+ return JSON.parse(raw);
194
+ }
195
+ catch {
196
+ return null;
197
+ }
198
+ }
199
+ const CONTEXT_READ_PATTERNS = [
200
+ /\brg\b/i,
201
+ /\bsed\b/i,
202
+ /\bcat\b/i,
203
+ /\btail\b/i,
204
+ /\bhead\b/i,
205
+ /\bls\b/i,
206
+ /\bwc\b/i,
207
+ /\bgrep\b/i,
208
+ /node\s+-\s*<<['"]/i,
209
+ /python\s+-\s*<<['"]/i
210
+ ];
211
+ function classifyShellCommand(command) {
212
+ if (!command) {
213
+ return null;
214
+ }
215
+ const normalized = command.trim();
216
+ if (!normalized) {
217
+ return null;
218
+ }
219
+ if (CONTEXT_READ_PATTERNS.some((pattern) => pattern.test(normalized))) {
220
+ return 'context_read';
221
+ }
222
+ return null;
223
+ }
224
+ const IMAGE_LINK_KEYS = ['imagePath', 'path', 'filepath', 'file', 'url', 'image', 'src'];
225
+ function hasImageLink(args) {
226
+ if (!args || typeof args !== 'object') {
227
+ return false;
228
+ }
229
+ for (const key of IMAGE_LINK_KEYS) {
230
+ const value = args[key];
231
+ if (typeof value === 'string' && value.trim()) {
232
+ return true;
233
+ }
234
+ }
235
+ return false;
134
236
  }
135
237
  function extractToolName(tool) {
136
238
  if (!tool || typeof tool !== 'object') {
@@ -0,0 +1,2 @@
1
+ import type { StandardizedRequest } from '../../conversion/hub/types/standardized.js';
2
+ export declare function countRequestTokens(request: StandardizedRequest): number;
@@ -0,0 +1,105 @@
1
+ import { encoding_for_model, get_encoding } from 'tiktoken';
2
+ const DEFAULT_ENCODING = 'cl100k_base';
3
+ const encoderCache = new Map();
4
+ let defaultEncoder = null;
5
+ function getEncoder(model) {
6
+ if (model) {
7
+ const normalized = model.trim();
8
+ if (encoderCache.has(normalized)) {
9
+ return encoderCache.get(normalized);
10
+ }
11
+ try {
12
+ const encoder = encoding_for_model(normalized);
13
+ encoderCache.set(normalized, encoder);
14
+ return encoder;
15
+ }
16
+ catch {
17
+ // fall back to default encoder
18
+ }
19
+ }
20
+ if (!defaultEncoder) {
21
+ defaultEncoder = get_encoding(DEFAULT_ENCODING);
22
+ }
23
+ return defaultEncoder;
24
+ }
25
+ export function countRequestTokens(request) {
26
+ const encoder = getEncoder(request.model);
27
+ let total = 0;
28
+ for (const message of request.messages || []) {
29
+ total += countMessageTokens(message, encoder);
30
+ }
31
+ if (Array.isArray(request.tools)) {
32
+ for (const tool of request.tools) {
33
+ total += encodeText(JSON.stringify(tool ?? {}), encoder);
34
+ }
35
+ }
36
+ if (request.parameters) {
37
+ total += encodeText(JSON.stringify(request.parameters), encoder);
38
+ }
39
+ if (request.metadata) {
40
+ total += encodeText(JSON.stringify(request.metadata), encoder);
41
+ }
42
+ return total;
43
+ }
44
+ function countMessageTokens(message, encoder) {
45
+ let total = 0;
46
+ total += encodeText(message.role, encoder);
47
+ total += encodeContent(message.content, encoder);
48
+ if (Array.isArray(message.tool_calls)) {
49
+ for (const call of message.tool_calls) {
50
+ total += encodeText(JSON.stringify(call ?? {}), encoder);
51
+ }
52
+ }
53
+ if (Array.isArray(message.metadata?.toolRuns)) {
54
+ total += encodeText(JSON.stringify(message.metadata?.toolRuns), encoder);
55
+ }
56
+ if (message.name) {
57
+ total += encodeText(message.name, encoder);
58
+ }
59
+ if (message.metadata) {
60
+ total += encodeText(JSON.stringify(message.metadata), encoder);
61
+ }
62
+ if (message.tool_call_id) {
63
+ total += encodeText(message.tool_call_id, encoder);
64
+ }
65
+ return total;
66
+ }
67
+ function encodeContent(content, encoder) {
68
+ if (content === null || content === undefined) {
69
+ return 0;
70
+ }
71
+ if (typeof content === 'string') {
72
+ return encodeText(content, encoder);
73
+ }
74
+ if (Array.isArray(content)) {
75
+ let total = 0;
76
+ for (const part of content) {
77
+ if (typeof part === 'string') {
78
+ total += encodeText(part, encoder);
79
+ }
80
+ else if (part && typeof part === 'object') {
81
+ if (typeof part.text === 'string') {
82
+ total += encodeText(part.text, encoder);
83
+ }
84
+ else {
85
+ total += encodeText(JSON.stringify(part), encoder);
86
+ }
87
+ }
88
+ }
89
+ return total;
90
+ }
91
+ if (typeof content === 'object') {
92
+ return encodeText(JSON.stringify(content), encoder);
93
+ }
94
+ return encodeText(String(content), encoder);
95
+ }
96
+ function encodeText(value, encoder) {
97
+ if (value === null || value === undefined) {
98
+ return 0;
99
+ }
100
+ const text = typeof value === 'string' ? value : String(value);
101
+ if (!text.trim()) {
102
+ return 0;
103
+ }
104
+ return encoder.encode(text).length;
105
+ }
@@ -5,6 +5,7 @@ import type { StandardizedRequest } from '../../conversion/hub/types/standardize
5
5
  export declare const DEFAULT_ROUTE = "default";
6
6
  export declare const ROUTE_PRIORITY: string[];
7
7
  export type RoutingPools = Record<string, string[]>;
8
+ export type PreviousToolCategory = 'context_read' | 'plan' | 'vision' | 'coding';
8
9
  export interface ProviderAuthConfig {
9
10
  type: 'apiKey' | 'oauth';
10
11
  secretRef?: string;
@@ -47,12 +48,14 @@ export interface ProviderRuntimeProfile {
47
48
  processMode?: 'chat' | 'passthrough';
48
49
  responsesConfig?: ResponsesProviderConfig;
49
50
  }
51
+ export type RouteKeywordCategory = 'thinking' | 'background' | 'vision' | 'coding';
50
52
  export interface VirtualRouterClassifierConfig {
51
53
  longContextThresholdTokens?: number;
52
54
  thinkingKeywords?: string[];
53
55
  codingKeywords?: string[];
54
56
  backgroundKeywords?: string[];
55
57
  visionKeywords?: string[];
58
+ keywordInjections?: Partial<Record<RouteKeywordCategory, string[]>>;
56
59
  }
57
60
  export interface LoadBalancingPolicy {
58
61
  strategy: 'round-robin' | 'weighted' | 'sticky';
@@ -111,6 +114,7 @@ export interface RoutingFeatures {
111
114
  hasCodingTool: boolean;
112
115
  hasThinkingKeyword: boolean;
113
116
  estimatedTokens: number;
117
+ previousToolCategory?: PreviousToolCategory | null;
114
118
  metadata: RouterMetadataInput;
115
119
  }
116
120
  export interface ClassificationResult {
@@ -3,12 +3,12 @@
3
3
  */
4
4
  export const DEFAULT_ROUTE = 'default';
5
5
  export const ROUTE_PRIORITY = [
6
+ 'longcontext',
7
+ 'thinking',
6
8
  'vision',
7
9
  'websearch',
8
10
  'coding',
9
11
  'tools',
10
- 'longcontext',
11
- 'thinking',
12
12
  'background',
13
13
  DEFAULT_ROUTE
14
14
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsonstudio/llms",
3
- "version": "0.6.006",
3
+ "version": "0.6.034",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",