@juspay/neurolink 9.79.2 → 9.80.0
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/CHANGELOG.md +12 -0
- package/dist/browser/neurolink.min.js +351 -347
- package/dist/cli/factories/commandFactory.js +61 -0
- package/dist/cli/utils/classifierRouterFlags.d.ts +19 -0
- package/dist/cli/utils/classifierRouterFlags.js +111 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/index.d.ts +1 -1
- package/dist/lib/index.js +1 -1
- package/dist/lib/neurolink.d.ts +14 -0
- package/dist/lib/neurolink.js +131 -0
- package/dist/lib/providers/googleNativeGemini3.d.ts +26 -0
- package/dist/lib/providers/googleNativeGemini3.js +48 -0
- package/dist/lib/providers/googleVertex.d.ts +16 -0
- package/dist/lib/providers/googleVertex.js +200 -24
- package/dist/lib/routing/classifierRouter.d.ts +52 -0
- package/dist/lib/routing/classifierRouter.js +269 -0
- package/dist/lib/routing/classifierStrategies.d.ts +23 -0
- package/dist/lib/routing/classifierStrategies.js +156 -0
- package/dist/lib/routing/index.d.ts +2 -0
- package/dist/lib/routing/index.js +2 -0
- package/dist/lib/session/globalSessionState.d.ts +10 -1
- package/dist/lib/session/globalSessionState.js +18 -0
- package/dist/lib/types/classifierRouter.d.ts +193 -0
- package/dist/lib/types/classifierRouter.js +17 -0
- package/dist/lib/types/cli.d.ts +27 -3
- package/dist/lib/types/config.d.ts +10 -0
- package/dist/lib/types/index.d.ts +1 -0
- package/dist/lib/types/index.js +2 -0
- package/dist/neurolink.d.ts +14 -0
- package/dist/neurolink.js +131 -0
- package/dist/providers/googleNativeGemini3.d.ts +26 -0
- package/dist/providers/googleNativeGemini3.js +48 -0
- package/dist/providers/googleVertex.d.ts +16 -0
- package/dist/providers/googleVertex.js +200 -24
- package/dist/routing/classifierRouter.d.ts +52 -0
- package/dist/routing/classifierRouter.js +268 -0
- package/dist/routing/classifierStrategies.d.ts +23 -0
- package/dist/routing/classifierStrategies.js +155 -0
- package/dist/routing/index.d.ts +2 -0
- package/dist/routing/index.js +2 -0
- package/dist/session/globalSessionState.d.ts +10 -1
- package/dist/session/globalSessionState.js +18 -0
- package/dist/types/classifierRouter.d.ts +193 -0
- package/dist/types/classifierRouter.js +16 -0
- package/dist/types/cli.d.ts +27 -3
- package/dist/types/config.d.ts +10 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +2 -0
- package/package.json +2 -1
|
@@ -22,7 +22,7 @@ import { convertZodToJsonSchema, inlineJsonSchema, ensureNestedSchemaTypes, } fr
|
|
|
22
22
|
import { createNativeThinkingConfig } from "../utils/thinkingConfig.js";
|
|
23
23
|
import { TimeoutError, withTimeout } from "../utils/async/index.js";
|
|
24
24
|
import { parseTimeout } from "../utils/timeout.js";
|
|
25
|
-
import { createTextChannel, extractThoughtSignature, prependConversationMessages, } from "./googleNativeGemini3.js";
|
|
25
|
+
import { appendStepText, createTextChannel, extractThoughtSignature, mapGeminiFinishReason, prependConversationMessages, } from "./googleNativeGemini3.js";
|
|
26
26
|
import { ATTR, LANGFUSE_ATTR, spanJsonAttribute, tracers, withClientSpan, withClientStreamSpan, withSpan, } from "../telemetry/index.js";
|
|
27
27
|
import { SpanKind, SpanStatusCode, context as otelContext, trace as otelTrace, } from "@opentelemetry/api";
|
|
28
28
|
import { calculateCost } from "../utils/pricing.js";
|
|
@@ -786,7 +786,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
786
786
|
// lifecycle callbacks. Pipeline A gets these via the AI SDK
|
|
787
787
|
// wrapStream middleware; the native path has to fire them here.
|
|
788
788
|
const wrappedResult = this.wrapStreamResultWithLifecycle(options, result, streamStartTime);
|
|
789
|
-
this.emitStreamEnd(modelName, streamStartTime, true);
|
|
789
|
+
this.emitStreamEnd(modelName, streamStartTime, true, undefined, result.finishReason);
|
|
790
790
|
return wrappedResult;
|
|
791
791
|
}
|
|
792
792
|
catch (error) {
|
|
@@ -801,7 +801,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
801
801
|
* `model.generation` span for native Vertex stream traffic. Mirrors
|
|
802
802
|
* `emitGenerationEnd` (used by `generate()`).
|
|
803
803
|
*/
|
|
804
|
-
emitStreamEnd(modelName, startTime, success, error) {
|
|
804
|
+
emitStreamEnd(modelName, startTime, success, error, resolvedFinishReason) {
|
|
805
805
|
const emitter = this.neurolink?.getEventEmitter();
|
|
806
806
|
if (!emitter) {
|
|
807
807
|
return;
|
|
@@ -815,7 +815,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
815
815
|
usage: { input: 0, output: 0, total: 0 },
|
|
816
816
|
model: modelName,
|
|
817
817
|
provider: this.providerName,
|
|
818
|
-
finishReason: success ? "stop" : "error",
|
|
818
|
+
finishReason: success ? (resolvedFinishReason ?? "stop") : "error",
|
|
819
819
|
},
|
|
820
820
|
success,
|
|
821
821
|
...(error
|
|
@@ -1156,7 +1156,10 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
1156
1156
|
: Math.min(DEFAULT_MAX_STEPS, 100);
|
|
1157
1157
|
const currentContents = [...contents];
|
|
1158
1158
|
let finalText = "";
|
|
1159
|
-
|
|
1159
|
+
// Last SDK finish reason seen across steps (Bug 2: previously never read,
|
|
1160
|
+
// so callers fell back to "unknown"). Last non-empty value wins — the
|
|
1161
|
+
// terminal chunk is authoritative.
|
|
1162
|
+
let lastFinishReason;
|
|
1160
1163
|
const allToolCalls = [];
|
|
1161
1164
|
// Mirrors the generate-path shape so StreamResult.toolExecutions can be
|
|
1162
1165
|
// populated (parity with AI-SDK-driven providers) and so the storage
|
|
@@ -1200,6 +1203,12 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
1200
1203
|
const chunkRecord = chunk;
|
|
1201
1204
|
const candidates = chunkRecord.candidates;
|
|
1202
1205
|
const firstCandidate = candidates?.[0];
|
|
1206
|
+
// Capture the SDK finish reason (Bug 2: previously dropped). Last
|
|
1207
|
+
// non-empty value across chunks wins.
|
|
1208
|
+
const chunkFinishReason = firstCandidate?.finishReason;
|
|
1209
|
+
if (typeof chunkFinishReason === "string" && chunkFinishReason) {
|
|
1210
|
+
lastFinishReason = chunkFinishReason;
|
|
1211
|
+
}
|
|
1203
1212
|
const chunkContent = firstCandidate?.content;
|
|
1204
1213
|
if (chunkContent && Array.isArray(chunkContent.parts)) {
|
|
1205
1214
|
for (const part of chunkContent.parts) {
|
|
@@ -1253,8 +1262,6 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
1253
1262
|
break;
|
|
1254
1263
|
}
|
|
1255
1264
|
}
|
|
1256
|
-
// Track the last step text for maxSteps termination
|
|
1257
|
-
lastStepText = stepText;
|
|
1258
1265
|
// Execute function calls
|
|
1259
1266
|
logger.debug(`[GoogleVertex] Executing ${stepFunctionCalls.length} function calls`);
|
|
1260
1267
|
// Add model response with ALL parts (including thoughtSignature) to history
|
|
@@ -1426,14 +1433,50 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
1426
1433
|
throw this.handleProviderError(error);
|
|
1427
1434
|
}
|
|
1428
1435
|
}
|
|
1429
|
-
// Handle maxSteps termination
|
|
1436
|
+
// Handle maxSteps termination — the loop exited because the step cap was
|
|
1437
|
+
// reached while the model was still calling tools. Surface a real answer
|
|
1438
|
+
// instead of the canned placeholder (Bug 1) and a meaningful finishReason
|
|
1439
|
+
// (Bug 2).
|
|
1440
|
+
let hitStepLimit = false;
|
|
1441
|
+
let synthesizedFinalAnswer = false;
|
|
1430
1442
|
if (step >= maxSteps && !finalText) {
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1443
|
+
hitStepLimit = true;
|
|
1444
|
+
// The consumer receives text via `incrementalTextChunks`; any text the
|
|
1445
|
+
// model emitted across steps is already preserved there. Only synthesize
|
|
1446
|
+
// when NOTHING was produced (the pure-functionCall case that otherwise
|
|
1447
|
+
// surfaces the placeholder) so we never waste a round-trip whose output
|
|
1448
|
+
// the stream would ignore.
|
|
1449
|
+
if (incrementalTextChunks.length === 0) {
|
|
1450
|
+
logger.warn(`[GoogleVertex] Tool call loop terminated after reaching maxSteps (${maxSteps}) ` +
|
|
1451
|
+
`with no text; synthesizing a final answer with tools disabled.`);
|
|
1452
|
+
const synth = await this.synthesizeFinalAnswerWithoutTools(client, modelName, config, currentContents, useFinalResultTool, parseTimeout(options.timeout) ?? 300_000);
|
|
1453
|
+
if (synth.text) {
|
|
1454
|
+
synthesizedFinalAnswer = true;
|
|
1455
|
+
finalText = synth.text;
|
|
1456
|
+
incrementalTextChunks.push(synth.text);
|
|
1457
|
+
totalInputTokens += synth.inputTokens;
|
|
1458
|
+
totalOutputTokens += synth.outputTokens;
|
|
1459
|
+
if (synth.finishReason) {
|
|
1460
|
+
lastFinishReason = synth.finishReason;
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
else {
|
|
1464
|
+
finalText = `[Tool execution limit reached after ${maxSteps} steps. The model continued requesting tool calls beyond the limit.]`;
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
else {
|
|
1468
|
+
logger.warn(`[GoogleVertex] Tool call loop terminated after reaching maxSteps (${maxSteps}); ` +
|
|
1469
|
+
`returning text already gathered from prior steps.`);
|
|
1470
|
+
}
|
|
1436
1471
|
}
|
|
1472
|
+
// Unified finish reason: a step-cap exhaustion that did NOT end in a clean
|
|
1473
|
+
// synthesized answer is reported as "tool-calls" (the model still wanted
|
|
1474
|
+
// tools) — NOT "length", which neurolink.ts treats as token truncation
|
|
1475
|
+
// (jsonTruncated + WARNING span). A clean completion maps from the SDK
|
|
1476
|
+
// finish reason.
|
|
1477
|
+
const resolvedFinishReason = hitStepLimit && !synthesizedFinalAnswer
|
|
1478
|
+
? "tool-calls"
|
|
1479
|
+
: mapGeminiFinishReason(lastFinishReason);
|
|
1437
1480
|
const responseTime = Date.now() - startTime;
|
|
1438
1481
|
// Yield each text part separately so the CLI receives multiple stream
|
|
1439
1482
|
// chunks instead of a single coalesced buffer. The SDK already gave us
|
|
@@ -1460,6 +1503,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
1460
1503
|
stream: createTextStream(),
|
|
1461
1504
|
provider: this.providerName,
|
|
1462
1505
|
model: modelName,
|
|
1506
|
+
finishReason: resolvedFinishReason,
|
|
1463
1507
|
usage: {
|
|
1464
1508
|
input: totalInputTokens,
|
|
1465
1509
|
output: totalOutputTokens,
|
|
@@ -1791,7 +1835,11 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
1791
1835
|
: Math.min(DEFAULT_MAX_STEPS, 100);
|
|
1792
1836
|
const currentContents = [...contents];
|
|
1793
1837
|
let finalText = "";
|
|
1794
|
-
|
|
1838
|
+
// Cross-step text accumulation + last SDK finish reason, so the
|
|
1839
|
+
// maxSteps-exhaustion exit can surface real gathered text (Bug 1) and a
|
|
1840
|
+
// meaningful finishReason (Bug 2) instead of a placeholder / "unknown".
|
|
1841
|
+
let accumulatedText = "";
|
|
1842
|
+
let lastFinishReason;
|
|
1795
1843
|
const allToolCalls = [];
|
|
1796
1844
|
const toolExecutions = [];
|
|
1797
1845
|
let step = 0;
|
|
@@ -1826,6 +1874,12 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
1826
1874
|
const chunkRecord = chunk;
|
|
1827
1875
|
const candidates = chunkRecord.candidates;
|
|
1828
1876
|
const firstCandidate = candidates?.[0];
|
|
1877
|
+
// Capture the SDK finish reason (Bug 2: previously dropped). Last
|
|
1878
|
+
// non-empty value across chunks wins.
|
|
1879
|
+
const chunkFinishReason = firstCandidate?.finishReason;
|
|
1880
|
+
if (typeof chunkFinishReason === "string" && chunkFinishReason) {
|
|
1881
|
+
lastFinishReason = chunkFinishReason;
|
|
1882
|
+
}
|
|
1829
1883
|
const chunkContent = firstCandidate?.content;
|
|
1830
1884
|
if (chunkContent && Array.isArray(chunkContent.parts)) {
|
|
1831
1885
|
rawResponseParts.push(...chunkContent.parts);
|
|
@@ -1874,8 +1928,11 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
1874
1928
|
break;
|
|
1875
1929
|
}
|
|
1876
1930
|
}
|
|
1877
|
-
//
|
|
1878
|
-
|
|
1931
|
+
// Accumulate non-empty step text across steps so the
|
|
1932
|
+
// maxSteps-exhaustion exit can surface the prose the model produced
|
|
1933
|
+
// instead of a canned placeholder (Bug 1). Mirrors the Vertex-Claude
|
|
1934
|
+
// loop's text accumulation.
|
|
1935
|
+
accumulatedText = appendStepText(accumulatedText, stepText);
|
|
1879
1936
|
// Execute function calls
|
|
1880
1937
|
logger.debug(`[GoogleVertex] Generate executing ${stepFunctionCalls.length} function calls`);
|
|
1881
1938
|
// Add model response with ALL parts (including thoughtSignature) to history
|
|
@@ -2036,14 +2093,49 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
2036
2093
|
throw this.handleProviderError(error);
|
|
2037
2094
|
}
|
|
2038
2095
|
}
|
|
2039
|
-
// Handle maxSteps termination
|
|
2096
|
+
// Handle maxSteps termination — the loop exited because the step cap was
|
|
2097
|
+
// reached while the model was still calling tools. Surface a real answer
|
|
2098
|
+
// instead of the canned placeholder (Bug 1) and a meaningful finishReason
|
|
2099
|
+
// (Bug 2).
|
|
2100
|
+
let hitStepLimit = false;
|
|
2101
|
+
let synthesizedFinalAnswer = false;
|
|
2040
2102
|
if (step >= maxSteps && !finalText) {
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
`
|
|
2103
|
+
hitStepLimit = true;
|
|
2104
|
+
if (accumulatedText) {
|
|
2105
|
+
// Prefer the prose the model already produced across steps.
|
|
2106
|
+
logger.warn(`[GoogleVertex] Generate tool call loop terminated after reaching maxSteps (${maxSteps}); ` +
|
|
2107
|
+
`returning text already gathered from prior steps.`);
|
|
2108
|
+
finalText = accumulatedText;
|
|
2109
|
+
}
|
|
2110
|
+
else {
|
|
2111
|
+
// Pure functionCall turns leave no text — make one tools-disabled call
|
|
2112
|
+
// so the model answers from the gathered tool results instead of the
|
|
2113
|
+
// canned placeholder.
|
|
2114
|
+
logger.warn(`[GoogleVertex] Generate tool call loop terminated after reaching maxSteps (${maxSteps}) ` +
|
|
2115
|
+
`with no text; synthesizing a final answer with tools disabled.`);
|
|
2116
|
+
const synth = await this.synthesizeFinalAnswerWithoutTools(client, modelName, config, currentContents, useFinalResultTool, parseTimeout(options.timeout) ?? 300_000);
|
|
2117
|
+
if (synth.text) {
|
|
2118
|
+
synthesizedFinalAnswer = true;
|
|
2119
|
+
finalText = synth.text;
|
|
2120
|
+
totalInputTokens += synth.inputTokens;
|
|
2121
|
+
totalOutputTokens += synth.outputTokens;
|
|
2122
|
+
if (synth.finishReason) {
|
|
2123
|
+
lastFinishReason = synth.finishReason;
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
else {
|
|
2127
|
+
finalText = `[Tool execution limit reached after ${maxSteps} steps. The model continued requesting tool calls beyond the limit.]`;
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2046
2130
|
}
|
|
2131
|
+
// Unified finish reason: a step-cap exhaustion that did NOT end in a clean
|
|
2132
|
+
// synthesized answer is reported as "tool-calls" (the model still wanted
|
|
2133
|
+
// tools) — NOT "length", which neurolink.ts treats as token truncation
|
|
2134
|
+
// (jsonTruncated + WARNING span). A clean completion maps from the SDK
|
|
2135
|
+
// finish reason.
|
|
2136
|
+
const resolvedFinishReason = hitStepLimit && !synthesizedFinalAnswer
|
|
2137
|
+
? "tool-calls"
|
|
2138
|
+
: mapGeminiFinishReason(lastFinishReason);
|
|
2047
2139
|
const responseTime = Date.now() - startTime;
|
|
2048
2140
|
// Filter out final_result from tool calls and executions as it's an internal pattern
|
|
2049
2141
|
const externalToolCalls = allToolCalls.filter((tc) => tc.toolName !== "final_result");
|
|
@@ -2053,6 +2145,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
2053
2145
|
content: finalText,
|
|
2054
2146
|
provider: this.providerName,
|
|
2055
2147
|
model: modelName,
|
|
2148
|
+
finishReason: resolvedFinishReason,
|
|
2056
2149
|
usage: {
|
|
2057
2150
|
input: totalInputTokens,
|
|
2058
2151
|
output: totalOutputTokens,
|
|
@@ -2073,6 +2166,89 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
2073
2166
|
// Vertex Gemini generate path.
|
|
2074
2167
|
return this.enhanceResult(result, options, startTime);
|
|
2075
2168
|
}
|
|
2169
|
+
/**
|
|
2170
|
+
* One-shot, tools-disabled model call used when a native Gemini agentic loop
|
|
2171
|
+
* is force-terminated by the step cap with no text produced. Lets the model
|
|
2172
|
+
* synthesize a final answer from the function results already in `contents`
|
|
2173
|
+
* instead of returning a canned placeholder (Bug 1, part b).
|
|
2174
|
+
*
|
|
2175
|
+
* Tools are disabled by OMITTING `config.tools` — the codebase's established
|
|
2176
|
+
* mechanism. `@google/genai`'s `FunctionCallingConfigMode.NONE` is documented
|
|
2177
|
+
* as equivalent to passing no function declarations, and `functionCallingConfig`
|
|
2178
|
+
* is not used anywhere in this codebase. When the structured-output
|
|
2179
|
+
* (`final_result`) pattern was active, a trailing instruction countermands the
|
|
2180
|
+
* earlier "you MUST call final_result" directive so the model answers in plain
|
|
2181
|
+
* text. Never throws — returns empty text so the caller falls back to the
|
|
2182
|
+
* placeholder, guaranteeing no new failure path.
|
|
2183
|
+
*/
|
|
2184
|
+
async synthesizeFinalAnswerWithoutTools(client, modelName, config, contents, useFinalResultTool, timeoutMs) {
|
|
2185
|
+
try {
|
|
2186
|
+
// Shallow clone so the loop's config is never mutated; dropping the
|
|
2187
|
+
// top-level `tools` key is sufficient (nested thinkingConfig /
|
|
2188
|
+
// systemInstruction are intentionally preserved).
|
|
2189
|
+
const synthConfig = { ...config };
|
|
2190
|
+
delete synthConfig.tools;
|
|
2191
|
+
if (useFinalResultTool) {
|
|
2192
|
+
const baseSystemInstruction = typeof synthConfig.systemInstruction === "string"
|
|
2193
|
+
? synthConfig.systemInstruction
|
|
2194
|
+
: "";
|
|
2195
|
+
synthConfig.systemInstruction =
|
|
2196
|
+
baseSystemInstruction +
|
|
2197
|
+
"\n\nThe final_result tool is no longer available. Provide your " +
|
|
2198
|
+
"final answer directly as plain text now, using the information " +
|
|
2199
|
+
"gathered so far.";
|
|
2200
|
+
}
|
|
2201
|
+
// Bound the whole connect + drain with a timeout. The surrounding
|
|
2202
|
+
// try/catch only catches throws, not hangs, so without this a stalled
|
|
2203
|
+
// Vertex endpoint would hang the maxSteps recovery path indefinitely.
|
|
2204
|
+
// On timeout withTimeout rejects (TimeoutError) and the catch below
|
|
2205
|
+
// falls back to the placeholder — no new failure path is introduced.
|
|
2206
|
+
return await withTimeout((async () => {
|
|
2207
|
+
const stream = await client.models.generateContentStream({
|
|
2208
|
+
model: modelName,
|
|
2209
|
+
contents,
|
|
2210
|
+
config: synthConfig,
|
|
2211
|
+
});
|
|
2212
|
+
const parts = [];
|
|
2213
|
+
let finishReason;
|
|
2214
|
+
let inputTokens = 0;
|
|
2215
|
+
let outputTokens = 0;
|
|
2216
|
+
for await (const chunk of stream) {
|
|
2217
|
+
const chunkRecord = chunk;
|
|
2218
|
+
const candidates = chunkRecord.candidates;
|
|
2219
|
+
const firstCandidate = candidates?.[0];
|
|
2220
|
+
const chunkFinishReason = firstCandidate?.finishReason;
|
|
2221
|
+
if (typeof chunkFinishReason === "string" && chunkFinishReason) {
|
|
2222
|
+
finishReason = chunkFinishReason;
|
|
2223
|
+
}
|
|
2224
|
+
const chunkContent = firstCandidate?.content;
|
|
2225
|
+
if (chunkContent && Array.isArray(chunkContent.parts)) {
|
|
2226
|
+
parts.push(...chunkContent.parts);
|
|
2227
|
+
}
|
|
2228
|
+
const usageMetadata = chunkRecord.usageMetadata;
|
|
2229
|
+
if (usageMetadata) {
|
|
2230
|
+
if (usageMetadata.promptTokenCount !== undefined &&
|
|
2231
|
+
usageMetadata.promptTokenCount > 0) {
|
|
2232
|
+
inputTokens = usageMetadata.promptTokenCount;
|
|
2233
|
+
}
|
|
2234
|
+
if (usageMetadata.candidatesTokenCount !== undefined &&
|
|
2235
|
+
usageMetadata.candidatesTokenCount > 0) {
|
|
2236
|
+
outputTokens = usageMetadata.candidatesTokenCount;
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
const text = parts
|
|
2241
|
+
.filter((part) => typeof part.text === "string")
|
|
2242
|
+
.map((part) => part.text)
|
|
2243
|
+
.join("");
|
|
2244
|
+
return { text, finishReason, inputTokens, outputTokens };
|
|
2245
|
+
})(), timeoutMs, "Gemini synthesis call timed out");
|
|
2246
|
+
}
|
|
2247
|
+
catch (error) {
|
|
2248
|
+
logger.warn("[GoogleVertex] Tools-disabled synthesis call failed; falling back to placeholder", { error: error instanceof Error ? error.message : String(error) });
|
|
2249
|
+
return { text: "", inputTokens: 0, outputTokens: 0 };
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2076
2252
|
/**
|
|
2077
2253
|
* Create native AnthropicVertex client for Claude models
|
|
2078
2254
|
*/
|
|
@@ -3685,7 +3861,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3685
3861
|
}
|
|
3686
3862
|
: undefined,
|
|
3687
3863
|
duration: Date.now() - startTime,
|
|
3688
|
-
finishReason: "stop",
|
|
3864
|
+
finishReason: result?.finishReason ?? "stop",
|
|
3689
3865
|
});
|
|
3690
3866
|
Promise.resolve(callbackResult).catch((err) => logger.warn(`[GoogleVertex] onFinish callback rejected: ${err instanceof Error ? err.message : String(err)}`));
|
|
3691
3867
|
}
|
|
@@ -3890,7 +4066,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3890
4066
|
usage,
|
|
3891
4067
|
model: modelName,
|
|
3892
4068
|
provider: this.providerName,
|
|
3893
|
-
finishReason: success ? "stop" : "error",
|
|
4069
|
+
finishReason: success ? (result?.finishReason ?? "stop") : "error",
|
|
3894
4070
|
},
|
|
3895
4071
|
success,
|
|
3896
4072
|
...(error
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClassifierRouter — the single "classify → pick model + tools → run" engine.
|
|
3
|
+
*
|
|
4
|
+
* Given a request snapshot it (1) classifies difficulty (heuristic or a cheap
|
|
5
|
+
* LLM), (2) selects the best provider/model from the host-declared base pool —
|
|
6
|
+
* cheaper/faster for easy tiers, more capable for hard tiers — enriching pool
|
|
7
|
+
* members with real cost/quality metadata from the model registry, and
|
|
8
|
+
* (3) narrows the tool set via per-difficulty directives or classifier hints.
|
|
9
|
+
*
|
|
10
|
+
* Pure of provider imports (mirrors ModelPool): the LLM caller is injected.
|
|
11
|
+
* The only data dependency is the read-only `ModelResolver` registry lookup,
|
|
12
|
+
* used purely for metadata enrichment and never for provider construction.
|
|
13
|
+
*/
|
|
14
|
+
import type { ClassifierRouterConfig, ClassifierRouterDecision, ClassifierRouterDeps, ClassifierRouterInput } from "../types/index.js";
|
|
15
|
+
export declare class ClassifierRouter {
|
|
16
|
+
private readonly config;
|
|
17
|
+
private readonly deps;
|
|
18
|
+
private readonly metaCache;
|
|
19
|
+
constructor(config: ClassifierRouterConfig, deps?: ClassifierRouterDeps);
|
|
20
|
+
/**
|
|
21
|
+
* Classify the request and produce a combined model + tool decision, or
|
|
22
|
+
* `null` when nothing should change. Never throws (fails open).
|
|
23
|
+
*/
|
|
24
|
+
route(input: ClassifierRouterInput): Promise<ClassifierRouterDecision | null>;
|
|
25
|
+
/** Run the configured strategy; LLM falls back to heuristic on failure. */
|
|
26
|
+
private classify;
|
|
27
|
+
/**
|
|
28
|
+
* Build LLM-facing model descriptors + an id→member map so the classifier can
|
|
29
|
+
* pick a model directly. Works for ANY pool, registry-backed or not.
|
|
30
|
+
*/
|
|
31
|
+
private buildCandidates;
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the ranked model list for a decision. Prefers the LLM's direct pick
|
|
34
|
+
* (`selectedModelId`); otherwise falls back to difficulty-based tierMap /
|
|
35
|
+
* metadata scoring.
|
|
36
|
+
*/
|
|
37
|
+
private selectModels;
|
|
38
|
+
/** Candidate members for a difficulty: explicit tierMap or eligible pool. */
|
|
39
|
+
private candidatesFor;
|
|
40
|
+
/** Drop members that cannot satisfy the required capabilities (lenient). */
|
|
41
|
+
private filterByCapabilities;
|
|
42
|
+
/** Rank candidates best-first for the difficulty's strategy. */
|
|
43
|
+
private rank;
|
|
44
|
+
/** Tool narrowing: per-difficulty directive, then classifier hints. */
|
|
45
|
+
private selectTools;
|
|
46
|
+
/**
|
|
47
|
+
* Resolve cost/quality/capabilities for a member. Declared values win;
|
|
48
|
+
* gaps are filled from the model registry (by model name/alias) when known.
|
|
49
|
+
* Results are cached per (provider/model) for the router's lifetime.
|
|
50
|
+
*/
|
|
51
|
+
private metaFor;
|
|
52
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ClassifierRouter — the single "classify → pick model + tools → run" engine.
|
|
3
|
+
*
|
|
4
|
+
* Given a request snapshot it (1) classifies difficulty (heuristic or a cheap
|
|
5
|
+
* LLM), (2) selects the best provider/model from the host-declared base pool —
|
|
6
|
+
* cheaper/faster for easy tiers, more capable for hard tiers — enriching pool
|
|
7
|
+
* members with real cost/quality metadata from the model registry, and
|
|
8
|
+
* (3) narrows the tool set via per-difficulty directives or classifier hints.
|
|
9
|
+
*
|
|
10
|
+
* Pure of provider imports (mirrors ModelPool): the LLM caller is injected.
|
|
11
|
+
* The only data dependency is the read-only `ModelResolver` registry lookup,
|
|
12
|
+
* used purely for metadata enrichment and never for provider construction.
|
|
13
|
+
*/
|
|
14
|
+
import { classifyHeuristic, classifyLlm } from "./classifierStrategies.js";
|
|
15
|
+
import { ModelResolver } from "../models/modelResolver.js";
|
|
16
|
+
/** Maps a registry quality enum to a comparable numeric score. */
|
|
17
|
+
const QUALITY_SCORE = { high: 3, medium: 2, low: 1 };
|
|
18
|
+
/** How each difficulty ranks candidate members. */
|
|
19
|
+
const DIFFICULTY_RANK_MODE = {
|
|
20
|
+
trivial: "cost-asc",
|
|
21
|
+
simple: "cost-asc",
|
|
22
|
+
moderate: "balanced",
|
|
23
|
+
hard: "quality-desc",
|
|
24
|
+
expert: "quality-desc",
|
|
25
|
+
};
|
|
26
|
+
/** Neutral default for an unknown numeric metric (keeps ordering stable). */
|
|
27
|
+
const NEUTRAL = 0.5;
|
|
28
|
+
/**
|
|
29
|
+
* Cap on the per-(provider,model) metadata cache so it can't grow unbounded in
|
|
30
|
+
* long-lived processes with large or dynamic pools. FIFO eviction.
|
|
31
|
+
*/
|
|
32
|
+
const MAX_META_CACHE_ENTRIES = 1000;
|
|
33
|
+
export class ClassifierRouter {
|
|
34
|
+
config;
|
|
35
|
+
deps;
|
|
36
|
+
metaCache = new Map();
|
|
37
|
+
constructor(config, deps = {}) {
|
|
38
|
+
this.config = config;
|
|
39
|
+
this.deps = deps;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Classify the request and produce a combined model + tool decision, or
|
|
43
|
+
* `null` when nothing should change. Never throws (fails open).
|
|
44
|
+
*/
|
|
45
|
+
async route(input) {
|
|
46
|
+
try {
|
|
47
|
+
// For the LLM strategy, hand the pool to the classifier so it can select
|
|
48
|
+
// a model directly — the generic path for custom/registry-less models.
|
|
49
|
+
const useLlm = this.config.classifier === "llm" && !!this.deps.generate;
|
|
50
|
+
const built = useLlm ? this.buildCandidates() : undefined;
|
|
51
|
+
const decision = await this.classify(input, built?.descriptors);
|
|
52
|
+
const ranked = this.selectModels(decision, built?.byId);
|
|
53
|
+
const primary = ranked[0];
|
|
54
|
+
const { toolFilter, excludeTools } = this.selectTools(decision);
|
|
55
|
+
if (!primary && !toolFilter && !excludeTools) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
provider: primary?.provider,
|
|
60
|
+
model: primary?.model,
|
|
61
|
+
region: primary?.region,
|
|
62
|
+
difficulty: decision.difficulty,
|
|
63
|
+
modelFallbacks: ranked.slice(1),
|
|
64
|
+
toolFilter,
|
|
65
|
+
excludeTools,
|
|
66
|
+
reason: decision.reason,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
this.deps.logger?.warn?.("[ClassifierRouter] route failed — no-op", {
|
|
71
|
+
error: err instanceof Error ? err.message : String(err),
|
|
72
|
+
});
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Run the configured strategy; LLM falls back to heuristic on failure. */
|
|
77
|
+
async classify(input, candidates) {
|
|
78
|
+
if (this.config.classifier === "llm" && this.deps.generate) {
|
|
79
|
+
try {
|
|
80
|
+
return await classifyLlm(input, this.deps.generate, this.config.classifierModel, this.config.timeoutMs, candidates);
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
this.deps.logger?.warn?.("[ClassifierRouter] LLM classify failed — using heuristic", { error: err instanceof Error ? err.message : String(err) });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return classifyHeuristic(input);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Build LLM-facing model descriptors + an id→member map so the classifier can
|
|
90
|
+
* pick a model directly. Works for ANY pool, registry-backed or not.
|
|
91
|
+
*/
|
|
92
|
+
buildCandidates() {
|
|
93
|
+
const pool = this.config.pool ?? [];
|
|
94
|
+
const byId = new Map();
|
|
95
|
+
const used = new Set();
|
|
96
|
+
const descriptors = [];
|
|
97
|
+
pool.forEach((m, i) => {
|
|
98
|
+
let id = m.id ?? (m.model ? `${m.provider}/${m.model}` : m.provider);
|
|
99
|
+
if (used.has(id)) {
|
|
100
|
+
id = `${id}#${i}`;
|
|
101
|
+
}
|
|
102
|
+
used.add(id);
|
|
103
|
+
byId.set(id, m);
|
|
104
|
+
descriptors.push({
|
|
105
|
+
id,
|
|
106
|
+
provider: m.provider,
|
|
107
|
+
model: m.model,
|
|
108
|
+
description: m.description,
|
|
109
|
+
tiers: m.tiers,
|
|
110
|
+
capabilities: this.metaFor(m).capabilities,
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
return { descriptors, byId };
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Resolve the ranked model list for a decision. Prefers the LLM's direct pick
|
|
117
|
+
* (`selectedModelId`); otherwise falls back to difficulty-based tierMap /
|
|
118
|
+
* metadata scoring.
|
|
119
|
+
*/
|
|
120
|
+
selectModels(decision, byId) {
|
|
121
|
+
if (decision.selectedModelId && byId) {
|
|
122
|
+
const picked = byId.get(decision.selectedModelId);
|
|
123
|
+
if (picked) {
|
|
124
|
+
const rest = (this.config.pool ?? []).filter((m) => m !== picked);
|
|
125
|
+
const rankedRest = this.rank(this.filterByCapabilities(rest, decision.requiredCapabilities), decision.difficulty);
|
|
126
|
+
return [picked, ...rankedRest];
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const eligible = this.candidatesFor(decision);
|
|
130
|
+
const capable = this.filterByCapabilities(eligible, decision.requiredCapabilities);
|
|
131
|
+
return this.rank(capable, decision.difficulty);
|
|
132
|
+
}
|
|
133
|
+
/** Candidate members for a difficulty: explicit tierMap or eligible pool. */
|
|
134
|
+
candidatesFor(decision) {
|
|
135
|
+
const tierMembers = this.config.tierMap?.[decision.difficulty];
|
|
136
|
+
if (tierMembers && tierMembers.length > 0) {
|
|
137
|
+
return tierMembers;
|
|
138
|
+
}
|
|
139
|
+
const pool = this.config.pool ?? [];
|
|
140
|
+
const eligible = pool.filter((m) => !m.tiers || m.tiers.includes(decision.difficulty));
|
|
141
|
+
return eligible.length > 0 ? eligible : pool;
|
|
142
|
+
}
|
|
143
|
+
/** Drop members that cannot satisfy the required capabilities (lenient). */
|
|
144
|
+
filterByCapabilities(members, required) {
|
|
145
|
+
if (!required || required.length === 0) {
|
|
146
|
+
return members;
|
|
147
|
+
}
|
|
148
|
+
const kept = members.filter((m) => {
|
|
149
|
+
const caps = this.metaFor(m).capabilities;
|
|
150
|
+
// Unknown capabilities → keep (don't starve the pool on missing metadata).
|
|
151
|
+
if (!caps || caps.length === 0) {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
return required.every((c) => caps.includes(c));
|
|
155
|
+
});
|
|
156
|
+
return kept.length > 0 ? kept : members;
|
|
157
|
+
}
|
|
158
|
+
/** Rank candidates best-first for the difficulty's strategy. */
|
|
159
|
+
rank(members, difficulty) {
|
|
160
|
+
const mode = DIFFICULTY_RANK_MODE[difficulty];
|
|
161
|
+
const originalIndex = new Map(members.map((m, i) => [m, i]));
|
|
162
|
+
const num = (v) => (typeof v === "number" ? v : NEUTRAL);
|
|
163
|
+
return [...members].sort((a, b) => {
|
|
164
|
+
const ma = this.metaFor(a);
|
|
165
|
+
const mb = this.metaFor(b);
|
|
166
|
+
let delta;
|
|
167
|
+
if (mode === "cost-asc") {
|
|
168
|
+
delta = num(ma.cost) - num(mb.cost);
|
|
169
|
+
}
|
|
170
|
+
else if (mode === "quality-desc") {
|
|
171
|
+
delta = num(mb.quality) - num(ma.quality);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
// balanced: maximize quality-minus-cost
|
|
175
|
+
delta =
|
|
176
|
+
num(mb.quality) - num(mb.cost) - (num(ma.quality) - num(ma.cost));
|
|
177
|
+
}
|
|
178
|
+
if (delta !== 0) {
|
|
179
|
+
return delta;
|
|
180
|
+
}
|
|
181
|
+
const weightDelta = (b.weight ?? 1) - (a.weight ?? 1);
|
|
182
|
+
if (weightDelta !== 0) {
|
|
183
|
+
return weightDelta;
|
|
184
|
+
}
|
|
185
|
+
// Stable: preserve declared pool order on a tie.
|
|
186
|
+
return (originalIndex.get(a) ?? 0) - (originalIndex.get(b) ?? 0);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
/** Tool narrowing: per-difficulty directive, then classifier hints. */
|
|
190
|
+
selectTools(decision) {
|
|
191
|
+
const directive = this.config.toolDirectives?.[decision.difficulty];
|
|
192
|
+
let toolFilter = directive?.toolFilter
|
|
193
|
+
? [...directive.toolFilter]
|
|
194
|
+
: undefined;
|
|
195
|
+
const excludeTools = directive?.excludeTools
|
|
196
|
+
? [...directive.excludeTools]
|
|
197
|
+
: undefined;
|
|
198
|
+
// When no explicit allowlist is configured, honor the classifier's hint.
|
|
199
|
+
if ((!toolFilter || toolFilter.length === 0) &&
|
|
200
|
+
decision.suggestedTools &&
|
|
201
|
+
decision.suggestedTools.length > 0) {
|
|
202
|
+
toolFilter = [...decision.suggestedTools];
|
|
203
|
+
}
|
|
204
|
+
return { toolFilter, excludeTools };
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Resolve cost/quality/capabilities for a member. Declared values win;
|
|
208
|
+
* gaps are filled from the model registry (by model name/alias) when known.
|
|
209
|
+
* Results are cached per (provider/model) for the router's lifetime.
|
|
210
|
+
*/
|
|
211
|
+
metaFor(member) {
|
|
212
|
+
const key = `${member.provider}::${member.model ?? ""}`;
|
|
213
|
+
const cached = this.metaCache.get(key);
|
|
214
|
+
if (cached) {
|
|
215
|
+
return cached;
|
|
216
|
+
}
|
|
217
|
+
let cost = member.cost;
|
|
218
|
+
let quality = member.quality;
|
|
219
|
+
let capabilities = member.capabilities
|
|
220
|
+
? [...member.capabilities]
|
|
221
|
+
: undefined;
|
|
222
|
+
const needsEnrichment = cost === undefined || quality === undefined || capabilities === undefined;
|
|
223
|
+
if (needsEnrichment && member.model) {
|
|
224
|
+
try {
|
|
225
|
+
const info = ModelResolver.resolveModel(member.model);
|
|
226
|
+
if (info) {
|
|
227
|
+
if (cost === undefined) {
|
|
228
|
+
cost = info.pricing.inputCostPer1K + info.pricing.outputCostPer1K;
|
|
229
|
+
}
|
|
230
|
+
if (quality === undefined) {
|
|
231
|
+
quality = QUALITY_SCORE[info.performance.quality] ?? NEUTRAL;
|
|
232
|
+
}
|
|
233
|
+
if (capabilities === undefined) {
|
|
234
|
+
const caps = [];
|
|
235
|
+
if (info.capabilities.vision) {
|
|
236
|
+
caps.push("vision");
|
|
237
|
+
}
|
|
238
|
+
if (info.capabilities.functionCalling) {
|
|
239
|
+
caps.push("tools");
|
|
240
|
+
}
|
|
241
|
+
if (info.capabilities.reasoning) {
|
|
242
|
+
caps.push("reasoning");
|
|
243
|
+
}
|
|
244
|
+
if (info.capabilities.codeGeneration) {
|
|
245
|
+
caps.push("code");
|
|
246
|
+
}
|
|
247
|
+
if (info.capabilities.multimodal) {
|
|
248
|
+
caps.push("multimodal");
|
|
249
|
+
}
|
|
250
|
+
capabilities = caps;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
// Enrichment is best-effort; ignore registry lookup failures.
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const meta = { cost, quality, capabilities };
|
|
259
|
+
if (this.metaCache.size >= MAX_META_CACHE_ENTRIES) {
|
|
260
|
+
const oldest = this.metaCache.keys().next().value;
|
|
261
|
+
if (oldest !== undefined) {
|
|
262
|
+
this.metaCache.delete(oldest);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
this.metaCache.set(key, meta);
|
|
266
|
+
return meta;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
//# sourceMappingURL=classifierRouter.js.map
|