@juspay/neurolink 12.28.3 → 12.29.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 +9 -2
- package/dist/adapters/tts/googleTTSHandler.d.ts +71 -1
- package/dist/adapters/tts/googleTTSHandler.js +254 -0
- package/dist/browser/neurolink.min.js +399 -399
- package/dist/cli/commands/voices.d.ts +23 -0
- package/dist/cli/commands/voices.js +102 -0
- package/dist/cli/loop/optionsSchema.d.ts +1 -1
- package/dist/cli/parser.js +3 -0
- package/dist/constants/enums.d.ts +13 -0
- package/dist/constants/enums.js +15 -0
- package/dist/core/baseProvider.d.ts +4 -0
- package/dist/core/baseProvider.js +245 -148
- package/dist/core/modules/structuredOutputPolicy.d.ts +27 -0
- package/dist/core/modules/structuredOutputPolicy.js +36 -0
- package/dist/core/nativeGenerateLoop.js +7 -1
- package/dist/neurolink.d.ts +42 -0
- package/dist/neurolink.js +102 -2
- package/dist/providers/catalog/index.generated.d.ts +1 -1
- package/dist/providers/catalog/index.generated.js +6 -0
- package/dist/providers/catalog/morph.json +97 -0
- package/dist/providers/catalog/novita.json +114 -0
- package/dist/providers/openaiChatCompletionsBase.d.ts +22 -0
- package/dist/providers/openaiChatCompletionsBase.js +291 -8
- package/dist/server/utils/validation.d.ts +34 -0
- package/dist/server/utils/validation.js +27 -0
- package/dist/server/websocket/WebSocketHandler.d.ts +4 -6
- package/dist/server/websocket/WebSocketHandler.js +185 -18
- package/dist/types/cli.d.ts +11 -0
- package/dist/types/generate.d.ts +15 -0
- package/dist/types/isolatedAgent.d.ts +16 -7
- package/dist/types/openaiCompatible.d.ts +6 -0
- package/dist/types/providerCatalog.generated.d.ts +2 -2
- package/dist/types/providers.d.ts +50 -3
- package/dist/types/server.d.ts +8 -0
- package/dist/types/stream.d.ts +30 -0
- package/dist/types/tts.d.ts +10 -0
- package/dist/types/utilities.d.ts +16 -3
- package/dist/utils/logger.d.ts +58 -13
- package/dist/utils/logger.js +156 -11
- package/dist/utils/providerHealth.d.ts +65 -1
- package/dist/utils/providerHealth.js +216 -54
- package/dist/utils/ttsProcessor.d.ts +30 -1
- package/dist/utils/ttsProcessor.js +76 -0
- package/docs-site/static/search-index.json +4 -4
- package/package.json +6 -1
|
@@ -38,11 +38,11 @@ import { resolveRequestKind } from "../core/resolveRequestKind.js";
|
|
|
38
38
|
import { appendJsonSchemaInstruction, hasNativeDoGenerate, runNativeGenerateLoop, } from "../core/nativeGenerateLoop.js";
|
|
39
39
|
import { resolveToolExecutionRecords, toolCallsFromSummaries, } from "../core/toolExecutionRecorder.js";
|
|
40
40
|
import { convertZodToJsonSchema } from "../utils/schemaConversion.js";
|
|
41
|
-
import { coerceJsonToSchema, schemaAccepts } from "../utils/json/coerce.js";
|
|
41
|
+
import { coerceJsonToSchema, recoverScalarRoot, schemaAccepts, } from "../utils/json/coerce.js";
|
|
42
42
|
import { resolveToolChoice } from "../utils/toolChoice.js";
|
|
43
43
|
import { transformToolExecutions } from "../utils/transformationUtils.js";
|
|
44
44
|
import { withProviderRetry } from "../utils/providerRetry.js";
|
|
45
|
-
import { isSchemaComplexityError, isToolsSchemaConflictError, } from "../core/modules/structuredOutputPolicy.js";
|
|
45
|
+
import { isResponseFormatUnsupportedError, isSchemaComplexityError, isToolsSchemaConflictError, } from "../core/modules/structuredOutputPolicy.js";
|
|
46
46
|
import { resolveDeferredTool } from "../tools/toolDiscovery.js";
|
|
47
47
|
import { buildAPIError, buildBody, buildToolsForOpenAI, buildWireToolNameMaps, createDeferredAnalytics, ensureJsonWordInBody, estimateWireTokens, mapNeuroLinkToolChoice, mergeUsage, messageBuilderToOpenAI, parseSSEStream, stringifyToolOutput, stripTrailingSlash, v3ResponseFormatToOpenAI, v3ToolChoiceToOpenAI, v3ToolsToOpenAI, } from "./openaiChatCompletionsClient.js";
|
|
48
48
|
import { createStreamChannel } from "../core/streamChannel.js";
|
|
@@ -75,6 +75,30 @@ const yieldsSchemaValidObject = (text, schema) => {
|
|
|
75
75
|
const coerced = coerceJsonToSchema(text, schema);
|
|
76
76
|
return coerced !== null && schemaAccepts(schema, coerced.structuredData);
|
|
77
77
|
};
|
|
78
|
+
/**
|
|
79
|
+
* Resolve a model answer against the requested schema, accepting the roots a
|
|
80
|
+
* ValidationSchema actually allows.
|
|
81
|
+
*
|
|
82
|
+
* `coerceJsonToSchema` scans for a balanced object or array span, so it
|
|
83
|
+
* recovers object and array roots but never a scalar one: a schema of
|
|
84
|
+
* `z.string()` answered with `"sunny"` finds no span and yields null. That is
|
|
85
|
+
* why `recoverScalarRoot` exists — it was already the second half of this
|
|
86
|
+
* decision on the neurolink.ts generate path (`recoverStructuredData`), and
|
|
87
|
+
* was simply never wired into the provider path. Without it a scalar-root
|
|
88
|
+
* schema silently produces no structured output no matter how correctly the
|
|
89
|
+
* model answers.
|
|
90
|
+
*
|
|
91
|
+
* Returns `undefined` when neither route accepts, which callers treat as "the
|
|
92
|
+
* model did not produce the value".
|
|
93
|
+
*/
|
|
94
|
+
const resolveAgainstSchema = (text, schema) => {
|
|
95
|
+
const coerced = coerceJsonToSchema(text, schema);
|
|
96
|
+
if (coerced !== null && schemaAccepts(schema, coerced.structuredData)) {
|
|
97
|
+
return coerced.structuredData;
|
|
98
|
+
}
|
|
99
|
+
const scalar = recoverScalarRoot(text, schema);
|
|
100
|
+
return scalar.kind === "accepted" ? scalar.value : undefined;
|
|
101
|
+
};
|
|
78
102
|
// Pull one native chunk at a time and forward cancellation to its iterator.
|
|
79
103
|
const chunksToV3Stream = (source, completion, cancel) => {
|
|
80
104
|
const iterator = source[Symbol.asyncIterator]();
|
|
@@ -976,7 +1000,9 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
|
|
|
976
1000
|
}
|
|
977
1001
|
catch (error) {
|
|
978
1002
|
const recoverable = responseFormat !== undefined &&
|
|
979
|
-
(isToolsSchemaConflictError(error) ||
|
|
1003
|
+
(isToolsSchemaConflictError(error) ||
|
|
1004
|
+
isSchemaComplexityError(error) ||
|
|
1005
|
+
isResponseFormatUnsupportedError(error));
|
|
980
1006
|
if (!recoverable) {
|
|
981
1007
|
throw error;
|
|
982
1008
|
}
|
|
@@ -1330,6 +1356,176 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
|
|
|
1330
1356
|
}
|
|
1331
1357
|
return loop;
|
|
1332
1358
|
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Resolve the parsed object for a `stream({ schema })` turn, after the
|
|
1361
|
+
* stream has been drained.
|
|
1362
|
+
*
|
|
1363
|
+
* Before this, streaming never sent `response_format` under any condition —
|
|
1364
|
+
* unlike the generate path, where the gap is only tools-dependent (see
|
|
1365
|
+
* suppressResponseFormatWithTools), so `structuredData` was never available
|
|
1366
|
+
* to a streaming caller at all. `executeStream` now sends `response_format`
|
|
1367
|
+
* on the wire whenever a schema is present and tools aren't suppressing it;
|
|
1368
|
+
* this method covers what's left: the common case where the streamed text
|
|
1369
|
+
* already parses against the schema, and the tools-suppressed case, which is
|
|
1370
|
+
* re-asked once without tools — the same recovery `reformatWithoutTools`
|
|
1371
|
+
* performs for generate(), reusing its acceptance rule
|
|
1372
|
+
* (`yieldsSchemaValidObject`) and its two-strategy fallback (native
|
|
1373
|
+
* `response_format` first, the schema spelled into the prompt if a vendor
|
|
1374
|
+
* rejects that).
|
|
1375
|
+
*
|
|
1376
|
+
* Returns `undefined` on every failure path rather than throwing: a turn
|
|
1377
|
+
* whose text the caller has already consumed and streamed to their UI must
|
|
1378
|
+
* not be turned into an error by a follow-up they never asked for.
|
|
1379
|
+
*/
|
|
1380
|
+
async resolveStreamStructuredData(args) {
|
|
1381
|
+
const { schema, schemaJson, text, conversation, modelId, abortSignal, options, } = args;
|
|
1382
|
+
// A function, not a captured boolean: the signal is read once before the
|
|
1383
|
+
// re-ask and again after it, and it can legitimately flip in between — a
|
|
1384
|
+
// const read of `abortSignal.aborted` would let the compiler narrow the
|
|
1385
|
+
// second read away (it cannot know the underlying signal mutates between
|
|
1386
|
+
// the two checks).
|
|
1387
|
+
const isCancelled = () => abortSignal?.aborted === true;
|
|
1388
|
+
// Nothing streamed — a middleware short-circuit, or a turn that only
|
|
1389
|
+
// called tools. Nothing to parse and nothing to restate.
|
|
1390
|
+
if (text.trim().length === 0) {
|
|
1391
|
+
return undefined;
|
|
1392
|
+
}
|
|
1393
|
+
// The happy path: the streamed answer already satisfies the schema (this
|
|
1394
|
+
// is what sending `response_format` on the wire was for), so no second
|
|
1395
|
+
// request.
|
|
1396
|
+
const streamedValue = resolveAgainstSchema(text, schema);
|
|
1397
|
+
if (streamedValue !== undefined) {
|
|
1398
|
+
return {
|
|
1399
|
+
data: streamedValue,
|
|
1400
|
+
usage: { inputTokens: 0, outputTokens: 0 },
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
// No conversation means `doStream` never ran (a middleware short
|
|
1404
|
+
// -circuited and returned its own stream), so there is no turn for a
|
|
1405
|
+
// re-ask to build on.
|
|
1406
|
+
if (!conversation || isCancelled()) {
|
|
1407
|
+
return undefined;
|
|
1408
|
+
}
|
|
1409
|
+
try {
|
|
1410
|
+
const model = await this.getAISDKModelWithMiddleware(options);
|
|
1411
|
+
if (!hasNativeDoGenerate(model)) {
|
|
1412
|
+
return undefined;
|
|
1413
|
+
}
|
|
1414
|
+
logger.warn(`[${this.providerName}] streamed answer is not schema-valid — re-asking once without tools`, { provider: this.providerName, model: modelId });
|
|
1415
|
+
const doGenerate = model.doGenerate.bind(model);
|
|
1416
|
+
// Every request here is billed, including one whose ANSWER is
|
|
1417
|
+
// discarded (a native-format attempt the vendor rejected, or a re-ask
|
|
1418
|
+
// that came back off-schema). Accumulate as each response lands rather
|
|
1419
|
+
// than reading the survivor, for the same reason the generate path
|
|
1420
|
+
// does: otherwise a two-request recovery reports as one.
|
|
1421
|
+
const billed = { inputTokens: 0, outputTokens: 0 };
|
|
1422
|
+
const runLoop = (conv, format) => runNativeGenerateLoop({
|
|
1423
|
+
doGenerate,
|
|
1424
|
+
conversation: conv,
|
|
1425
|
+
toolsRecord: {},
|
|
1426
|
+
...(format ? { responseFormat: format } : {}),
|
|
1427
|
+
maxSteps: 1,
|
|
1428
|
+
...(options.maxTokens
|
|
1429
|
+
? { maxOutputTokens: options.maxTokens }
|
|
1430
|
+
: {}),
|
|
1431
|
+
...(abortSignal ? { abortSignal } : {}),
|
|
1432
|
+
runStep: (call) => call(),
|
|
1433
|
+
}, []).then((loopResult) => {
|
|
1434
|
+
billed.inputTokens += loopResult.inputTokens;
|
|
1435
|
+
billed.outputTokens += loopResult.outputTokens;
|
|
1436
|
+
return loopResult;
|
|
1437
|
+
});
|
|
1438
|
+
// `conversation` here is the STREAMING loop's own array (see
|
|
1439
|
+
// executeToolBatch), which is already wire-shaped: an assistant turn
|
|
1440
|
+
// that called a tool carries a real `tool_calls` field and the matching
|
|
1441
|
+
// reply is a `{role: "tool", tool_call_id, content}` message — not the
|
|
1442
|
+
// ai-sdk-parts shape `messageBuilderToOpenAI` expects from `doGenerate`'s
|
|
1443
|
+
// `options.prompt`. Re-running `runNativeGenerateLoop` on those turns
|
|
1444
|
+
// unchanged would send them through `messageBuilderToOpenAI` a second
|
|
1445
|
+
// time, which only ever reads the ai-sdk-parts fields — it silently
|
|
1446
|
+
// drops `tool_calls` (it inspects `content` for `{type:"tool-call",
|
|
1447
|
+
// toolCallId}` parts, never a wire `tool_calls` property) and blanks
|
|
1448
|
+
// `tool_call_id` to `""` (it reads `toolCallId`, the pre-wire field,
|
|
1449
|
+
// never the wire `tool_call_id`). The result is a `tool` message with no
|
|
1450
|
+
// matching preceding tool call, which a real vendor rejects outright.
|
|
1451
|
+
// None of that history is actually needed for this re-ask: the
|
|
1452
|
+
// assistant turn appended below already restates whatever the tool call
|
|
1453
|
+
// produced, in the model's own words, so the tool exchange itself is
|
|
1454
|
+
// dropped rather than repaired.
|
|
1455
|
+
const conversationWithoutToolExchange = conversation.filter((message) => {
|
|
1456
|
+
if (message.role === "tool") {
|
|
1457
|
+
return false;
|
|
1458
|
+
}
|
|
1459
|
+
const toolCalls = message.tool_calls;
|
|
1460
|
+
return !(message.role === "assistant" &&
|
|
1461
|
+
Array.isArray(toolCalls) &&
|
|
1462
|
+
toolCalls.length > 0);
|
|
1463
|
+
});
|
|
1464
|
+
const reaskConversation = [
|
|
1465
|
+
...conversationWithoutToolExchange,
|
|
1466
|
+
{ role: "assistant", content: text },
|
|
1467
|
+
{
|
|
1468
|
+
role: "user",
|
|
1469
|
+
content:
|
|
1470
|
+
// "value", not "object": ValidationSchema also accepts array and
|
|
1471
|
+
// scalar roots (z.array(...), z.string()).
|
|
1472
|
+
"Return that same answer as a single JSON value conforming to " +
|
|
1473
|
+
"the required schema. No prose before or after it, and no " +
|
|
1474
|
+
"markdown code fence.",
|
|
1475
|
+
},
|
|
1476
|
+
];
|
|
1477
|
+
let reformatted;
|
|
1478
|
+
try {
|
|
1479
|
+
reformatted = await runLoop(reaskConversation, {
|
|
1480
|
+
type: "json",
|
|
1481
|
+
schema: schemaJson,
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
catch (nativeFormatError) {
|
|
1485
|
+
if (isCancelled()) {
|
|
1486
|
+
throw nativeFormatError;
|
|
1487
|
+
}
|
|
1488
|
+
logger.warn(`[${this.providerName}] the stream re-ask's response_format was rejected — retrying with the schema in the prompt`, {
|
|
1489
|
+
provider: this.providerName,
|
|
1490
|
+
model: modelId,
|
|
1491
|
+
error: nativeFormatError instanceof Error
|
|
1492
|
+
? nativeFormatError.message
|
|
1493
|
+
: String(nativeFormatError),
|
|
1494
|
+
});
|
|
1495
|
+
reformatted = await runLoop(appendJsonSchemaInstruction(reaskConversation, schemaJson), undefined);
|
|
1496
|
+
}
|
|
1497
|
+
// Sending response_format is not the same as being obeyed: accept the
|
|
1498
|
+
// reformat only if it actually produced the object.
|
|
1499
|
+
const reaskedValue = resolveAgainstSchema(reformatted.text, schema);
|
|
1500
|
+
if (reaskedValue === undefined) {
|
|
1501
|
+
logger.warn(`[${this.providerName}] the tool-free stream re-ask did not yield a schema-valid value either; leaving structuredData unset`, { provider: this.providerName, model: modelId });
|
|
1502
|
+
return { data: undefined, usage: billed };
|
|
1503
|
+
}
|
|
1504
|
+
return { data: reaskedValue, usage: billed };
|
|
1505
|
+
}
|
|
1506
|
+
catch (error) {
|
|
1507
|
+
// A caller who cancelled deserves their cancellation. Swallowing here
|
|
1508
|
+
// resolved a cancelled turn as though the abort never happened — the
|
|
1509
|
+
// inner handler already rethrows for exactly that case, and this outer
|
|
1510
|
+
// one was catching its rethrow and turning it back into `undefined`.
|
|
1511
|
+
//
|
|
1512
|
+
// Our OWN turn deadline is the one thing that must NOT propagate: the
|
|
1513
|
+
// caller has already consumed the streamed text, and a deadline reached
|
|
1514
|
+
// during a follow-up they never asked for must not retroactively turn
|
|
1515
|
+
// their answer into an error. The two arrive on the same composed
|
|
1516
|
+
// signal but are distinguishable by reason, since the turn timer aborts
|
|
1517
|
+
// with a TimeoutError. Same rule as the generate path's reformat guard.
|
|
1518
|
+
if (isCancelled() && !(abortSignal?.reason instanceof TimeoutError)) {
|
|
1519
|
+
throw error;
|
|
1520
|
+
}
|
|
1521
|
+
logger.warn(`[${this.providerName}] the tool-free structured-output re-ask failed for a stream; leaving structuredData unset`, {
|
|
1522
|
+
provider: this.providerName,
|
|
1523
|
+
model: modelId,
|
|
1524
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1525
|
+
});
|
|
1526
|
+
return undefined;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1333
1529
|
async executeStream(options, _analysisSchema) {
|
|
1334
1530
|
this.validateStreamOptions(options);
|
|
1335
1531
|
const startTime = Date.now();
|
|
@@ -1374,6 +1570,23 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
|
|
|
1374
1570
|
timeoutController?.cleanup();
|
|
1375
1571
|
throw setupErr;
|
|
1376
1572
|
}
|
|
1573
|
+
// Structured output rides `response_format`, mirroring the generate
|
|
1574
|
+
// path's policy (see suppressResponseFormatWithTools) — the streaming
|
|
1575
|
+
// path previously never sent it AT ALL, regardless of tools, so
|
|
1576
|
+
// `structuredData` was never available to a streaming caller in any
|
|
1577
|
+
// case. Computed once, up front, exactly like the generate path: mid-turn
|
|
1578
|
+
// tool hydration (tools.discovery) does not retroactively change whether
|
|
1579
|
+
// this turn asked for JSON.
|
|
1580
|
+
const schemaJson = options.schema
|
|
1581
|
+
? convertZodToJsonSchema(options.schema)
|
|
1582
|
+
: undefined;
|
|
1583
|
+
const hasToolsForStream = !!openAITools && openAITools.length > 0;
|
|
1584
|
+
const responseFormatSuppressed = schemaJson !== undefined &&
|
|
1585
|
+
hasToolsForStream &&
|
|
1586
|
+
this.suppressResponseFormatWithTools();
|
|
1587
|
+
const responseFormat = schemaJson && !responseFormatSuppressed
|
|
1588
|
+
? this.adjustResponseFormat(v3ResponseFormatToOpenAI({ type: "json", schema: schemaJson }), modelId)
|
|
1589
|
+
: undefined;
|
|
1377
1590
|
const url = this.getChatCompletionsURL(modelId);
|
|
1378
1591
|
const fetchImpl = createProxyFetch();
|
|
1379
1592
|
const maxSteps = options.maxSteps || DEFAULT_MAX_STEPS;
|
|
@@ -1416,6 +1629,14 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
|
|
|
1416
1629
|
...(options.topP !== undefined ? { topP: options.topP } : {}),
|
|
1417
1630
|
};
|
|
1418
1631
|
let loopPromise;
|
|
1632
|
+
// The wire conversation only exists once `doStream` actually runs, and it
|
|
1633
|
+
// is built from the MIDDLEWARE-TRANSFORMED prompt, so it cannot be
|
|
1634
|
+
// reconstructed from `promptMessages` afterwards. Captured here so the
|
|
1635
|
+
// post-drain structured-output re-ask (resolveStreamStructuredData) can
|
|
1636
|
+
// build on the same turn the model actually saw. Stays undefined when a
|
|
1637
|
+
// middleware short-circuits the request — the case where there is no
|
|
1638
|
+
// turn to build on at all.
|
|
1639
|
+
let loopConversation;
|
|
1419
1640
|
const providerNameForLoop = this.providerName;
|
|
1420
1641
|
const streamBaseModel = {
|
|
1421
1642
|
specificationVersion: "v3",
|
|
@@ -1437,6 +1658,7 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
|
|
|
1437
1658
|
? params.prompt
|
|
1438
1659
|
: promptMessages;
|
|
1439
1660
|
const conversation = messageBuilderToOpenAI(transformedPrompt, wireNameMaps?.toWire);
|
|
1661
|
+
loopConversation = conversation;
|
|
1440
1662
|
const sampled = {
|
|
1441
1663
|
...options,
|
|
1442
1664
|
...(typeof params?.maxOutputTokens === "number"
|
|
@@ -1466,6 +1688,7 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
|
|
|
1466
1688
|
closeChannel: channel.close,
|
|
1467
1689
|
resolveUsage,
|
|
1468
1690
|
resolveFinish,
|
|
1691
|
+
responseFormat,
|
|
1469
1692
|
});
|
|
1470
1693
|
const completion = loopPromise.then(() => Promise.all([usagePromise, finishPromise]).then(([usage, reason]) => ({
|
|
1471
1694
|
type: "finish",
|
|
@@ -1545,14 +1768,32 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
|
|
|
1545
1768
|
});
|
|
1546
1769
|
}
|
|
1547
1770
|
const providerName = this.providerName;
|
|
1771
|
+
// Not an arrow function — `transformedStream` doesn't have `this` bound,
|
|
1772
|
+
// so it needs its own capture to reach resolveStreamStructuredData.
|
|
1773
|
+
const self = this;
|
|
1774
|
+
// Built here rather than inline in `result` so the generator below can
|
|
1775
|
+
// fill in `structuredData` after the stream drains. Same mutable
|
|
1776
|
+
// -reference contract `finishReason`/`stopReason` on this object already
|
|
1777
|
+
// rely on: a wrapper that spreads the result cannot snapshot a value that
|
|
1778
|
+
// does not exist yet.
|
|
1779
|
+
const streamMetadata = {
|
|
1780
|
+
startTime,
|
|
1781
|
+
streamId: `${this.providerName}-${Date.now()}`,
|
|
1782
|
+
};
|
|
1548
1783
|
const transformedStream = async function* () {
|
|
1549
1784
|
let contentYielded = 0;
|
|
1785
|
+
// Only accumulated when a schema was actually requested — every other
|
|
1786
|
+
// turn keeps paying nothing for this.
|
|
1787
|
+
let schemaText = "";
|
|
1550
1788
|
try {
|
|
1551
1789
|
for await (const chunk of chunkSource) {
|
|
1552
1790
|
if ("content" in chunk &&
|
|
1553
1791
|
typeof chunk.content === "string" &&
|
|
1554
1792
|
chunk.content.length > 0) {
|
|
1555
1793
|
contentYielded++;
|
|
1794
|
+
if (options.schema) {
|
|
1795
|
+
schemaText += chunk.content;
|
|
1796
|
+
}
|
|
1556
1797
|
}
|
|
1557
1798
|
yield chunk;
|
|
1558
1799
|
}
|
|
@@ -1560,6 +1801,47 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
|
|
|
1560
1801
|
// `loopPromise` is undefined when a middleware blocked the request
|
|
1561
1802
|
// before `doStream` ran, in which case there is no loop to surface.
|
|
1562
1803
|
await loopPromise;
|
|
1804
|
+
// Structured output for a `stream({ schema })` turn. Runs HERE —
|
|
1805
|
+
// after the stream is fully drained — so a tool-free re-ask (when the
|
|
1806
|
+
// streamed answer isn't already schema-valid) never reaches
|
|
1807
|
+
// `wrapStream` and a caller's guardrail middleware never sees a
|
|
1808
|
+
// second stream it did not ask for.
|
|
1809
|
+
//
|
|
1810
|
+
// When a middleware short-circuited the request, `loopPromise` is
|
|
1811
|
+
// undefined and `schemaText` is empty, so this resolves to nothing
|
|
1812
|
+
// and the field stays absent rather than hanging a reader.
|
|
1813
|
+
if (options.schema && schemaJson) {
|
|
1814
|
+
const resolved = await self.resolveStreamStructuredData({
|
|
1815
|
+
schema: options.schema,
|
|
1816
|
+
schemaJson,
|
|
1817
|
+
text: schemaText,
|
|
1818
|
+
conversation: loopConversation,
|
|
1819
|
+
modelId,
|
|
1820
|
+
abortSignal,
|
|
1821
|
+
options,
|
|
1822
|
+
});
|
|
1823
|
+
// Assign only when there is something to assign. A bare
|
|
1824
|
+
// `= resolved?.data` creates the own property even when the value
|
|
1825
|
+
// is undefined, so `"structuredData" in metadata` reports true for
|
|
1826
|
+
// a turn that produced no object — which is precisely the check the
|
|
1827
|
+
// field's own contract tells readers to use to detect absence.
|
|
1828
|
+
if (resolved?.data !== undefined) {
|
|
1829
|
+
streamMetadata.structuredData = resolved.data;
|
|
1830
|
+
}
|
|
1831
|
+
// Only when a re-ask actually spent something. The common case is
|
|
1832
|
+
// the streamed answer already parsing, which costs nothing extra
|
|
1833
|
+
// and leaves the field absent rather than reporting a zero.
|
|
1834
|
+
if (resolved &&
|
|
1835
|
+
(resolved.usage.inputTokens > 0 || resolved.usage.outputTokens > 0)) {
|
|
1836
|
+
streamMetadata.structuredDataUsage = resolved.usage;
|
|
1837
|
+
logger.info(`[${providerName}] structured-output re-ask billed separately from the streamed turn`, {
|
|
1838
|
+
provider: providerName,
|
|
1839
|
+
model: modelId,
|
|
1840
|
+
inputTokens: resolved.usage.inputTokens,
|
|
1841
|
+
outputTokens: resolved.usage.outputTokens,
|
|
1842
|
+
});
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1563
1845
|
// No-output path: stream completed normally but yielded zero text.
|
|
1564
1846
|
// Build an enriched sentinel + stamp the active OTel span so
|
|
1565
1847
|
// Pipeline B (ContextEnricher) surfaces a WARNING-level Langfuse
|
|
@@ -1615,10 +1897,7 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
|
|
|
1615
1897
|
streamingMode: true,
|
|
1616
1898
|
}),
|
|
1617
1899
|
toolsUsed,
|
|
1618
|
-
metadata:
|
|
1619
|
-
startTime,
|
|
1620
|
-
streamId: `${this.providerName}-${Date.now()}`,
|
|
1621
|
-
},
|
|
1900
|
+
metadata: streamMetadata,
|
|
1622
1901
|
};
|
|
1623
1902
|
// Lazy getter: every read transforms the live `toolExecutionSummaries`
|
|
1624
1903
|
// through the canonical `transformToolExecutions()` so consumers see
|
|
@@ -1642,7 +1921,7 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
|
|
|
1642
1921
|
return result;
|
|
1643
1922
|
}
|
|
1644
1923
|
async runStreamLoop(args) {
|
|
1645
|
-
const { maxSteps, modelId, url, fetchImpl, abortSignal, options, conversation, openAITools, openAIToolChoice, toolsRecord, toolNameFromWire, emitter, toolsUsed, toolExecutionSummaries, pushChunk, closeChannel, resolveUsage, resolveFinish, } = args;
|
|
1924
|
+
const { maxSteps, modelId, url, fetchImpl, abortSignal, options, conversation, openAITools, openAIToolChoice, toolsRecord, toolNameFromWire, emitter, toolsUsed, toolExecutionSummaries, pushChunk, closeChannel, resolveUsage, resolveFinish, responseFormat, } = args;
|
|
1646
1925
|
// Hoisted above the try so the catch can resolve the usage accumulated
|
|
1647
1926
|
// by steps that completed BEFORE the failure — those steps were billed.
|
|
1648
1927
|
let stepFinish = null;
|
|
@@ -1743,6 +2022,7 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
|
|
|
1743
2022
|
openAITools,
|
|
1744
2023
|
openAIToolChoice,
|
|
1745
2024
|
pushChunk,
|
|
2025
|
+
responseFormat,
|
|
1746
2026
|
});
|
|
1747
2027
|
lastObservedPromptTokens = stepResult.usage?.prompt_tokens;
|
|
1748
2028
|
stepFinish = stepResult.finishReason;
|
|
@@ -1800,6 +2080,9 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
|
|
|
1800
2080
|
? { toolChoice: args.openAIToolChoice }
|
|
1801
2081
|
: {}),
|
|
1802
2082
|
streaming: true,
|
|
2083
|
+
...(args.responseFormat
|
|
2084
|
+
? { responseFormat: args.responseFormat }
|
|
2085
|
+
: {}),
|
|
1803
2086
|
}), args.modelId));
|
|
1804
2087
|
// The initial fetch gets 429/5xx retry-with-backoff via the same
|
|
1805
2088
|
// primitive the non-streaming path already uses (withProviderRetry).
|
|
@@ -23,6 +23,40 @@ export declare const AgentExecuteRequestSchema: z.ZodObject<{
|
|
|
23
23
|
sessionId: z.ZodOptional<z.ZodString>;
|
|
24
24
|
userId: z.ZodOptional<z.ZodString>;
|
|
25
25
|
}, z.core.$strip>;
|
|
26
|
+
/**
|
|
27
|
+
* Options a WebSocket `generate`/`stream` message may pass through to
|
|
28
|
+
* `neurolink.generate()`/`.stream()`. Deliberately an allowlist, not the full
|
|
29
|
+
* `GenerateOptions`/`StreamOptions` shape: the raw client JSON is untrusted,
|
|
30
|
+
* and `GenerateOptions.credentials` in particular carries a per-provider
|
|
31
|
+
* `baseURL` (see `NeurolinkCredentials`) that would otherwise let any caller
|
|
32
|
+
* redirect the server's own outbound request to an arbitrary host (SSRF) or
|
|
33
|
+
* swap in their own API key. `z.object` strips unknown keys by default, so
|
|
34
|
+
* `credentials` and anything else outside this list never reaches the SDK
|
|
35
|
+
* call — the same posture `AgentExecuteRequestSchema` already takes for the
|
|
36
|
+
* equivalent HTTP route.
|
|
37
|
+
*/
|
|
38
|
+
export declare const WebSocketAgentOptionsSchema: z.ZodObject<{
|
|
39
|
+
provider: z.ZodOptional<z.ZodString>;
|
|
40
|
+
model: z.ZodOptional<z.ZodString>;
|
|
41
|
+
systemPrompt: z.ZodOptional<z.ZodString>;
|
|
42
|
+
temperature: z.ZodOptional<z.ZodNumber>;
|
|
43
|
+
maxTokens: z.ZodOptional<z.ZodNumber>;
|
|
44
|
+
maxSteps: z.ZodOptional<z.ZodNumber>;
|
|
45
|
+
}, z.core.$strip>;
|
|
46
|
+
/**
|
|
47
|
+
* WebSocket `generate`/`stream` message payload schema.
|
|
48
|
+
*/
|
|
49
|
+
export declare const WebSocketAgentRequestSchema: z.ZodObject<{
|
|
50
|
+
prompt: z.ZodString;
|
|
51
|
+
options: z.ZodOptional<z.ZodObject<{
|
|
52
|
+
provider: z.ZodOptional<z.ZodString>;
|
|
53
|
+
model: z.ZodOptional<z.ZodString>;
|
|
54
|
+
systemPrompt: z.ZodOptional<z.ZodString>;
|
|
55
|
+
temperature: z.ZodOptional<z.ZodNumber>;
|
|
56
|
+
maxTokens: z.ZodOptional<z.ZodNumber>;
|
|
57
|
+
maxSteps: z.ZodOptional<z.ZodNumber>;
|
|
58
|
+
}, z.core.$strip>>;
|
|
59
|
+
}, z.core.$strip>;
|
|
26
60
|
/**
|
|
27
61
|
* Tool execute request schema
|
|
28
62
|
*/
|
|
@@ -28,6 +28,33 @@ export const AgentExecuteRequestSchema = z.object({
|
|
|
28
28
|
sessionId: z.string().optional(),
|
|
29
29
|
userId: z.string().optional(),
|
|
30
30
|
});
|
|
31
|
+
/**
|
|
32
|
+
* Options a WebSocket `generate`/`stream` message may pass through to
|
|
33
|
+
* `neurolink.generate()`/`.stream()`. Deliberately an allowlist, not the full
|
|
34
|
+
* `GenerateOptions`/`StreamOptions` shape: the raw client JSON is untrusted,
|
|
35
|
+
* and `GenerateOptions.credentials` in particular carries a per-provider
|
|
36
|
+
* `baseURL` (see `NeurolinkCredentials`) that would otherwise let any caller
|
|
37
|
+
* redirect the server's own outbound request to an arbitrary host (SSRF) or
|
|
38
|
+
* swap in their own API key. `z.object` strips unknown keys by default, so
|
|
39
|
+
* `credentials` and anything else outside this list never reaches the SDK
|
|
40
|
+
* call — the same posture `AgentExecuteRequestSchema` already takes for the
|
|
41
|
+
* equivalent HTTP route.
|
|
42
|
+
*/
|
|
43
|
+
export const WebSocketAgentOptionsSchema = z.object({
|
|
44
|
+
provider: z.string().optional(),
|
|
45
|
+
model: z.string().optional(),
|
|
46
|
+
systemPrompt: z.string().optional(),
|
|
47
|
+
temperature: z.number().min(0).max(2).optional(),
|
|
48
|
+
maxTokens: z.number().positive().optional(),
|
|
49
|
+
maxSteps: z.number().positive().optional(),
|
|
50
|
+
});
|
|
51
|
+
/**
|
|
52
|
+
* WebSocket `generate`/`stream` message payload schema.
|
|
53
|
+
*/
|
|
54
|
+
export const WebSocketAgentRequestSchema = z.object({
|
|
55
|
+
prompt: z.string().min(1, "Prompt is required"),
|
|
56
|
+
options: WebSocketAgentOptionsSchema.optional(),
|
|
57
|
+
});
|
|
31
58
|
/**
|
|
32
59
|
* Tool execute request schema
|
|
33
60
|
*/
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Provides cross-framework WebSocket handling for real-time AI interactions.
|
|
5
5
|
* Supports connection management, message routing, and graceful shutdown.
|
|
6
6
|
*/
|
|
7
|
-
import type { WebSocketConfig, WebSocketHandler as IWebSocketHandler, WebSocketConnection, WebSocketMessage, AuthenticatedUser } from "../../types/index.js";
|
|
7
|
+
import type { WebSocketConfig, WebSocketHandler as IWebSocketHandler, WebSocketConnection, WebSocketMessage, WebSocketRequestId, AuthenticatedUser } from "../../types/index.js";
|
|
8
|
+
import type { NeuroLink } from "../../neurolink.js";
|
|
8
9
|
/**
|
|
9
10
|
* WebSocket connection manager
|
|
10
11
|
*/
|
|
@@ -91,7 +92,7 @@ export declare class WebSocketMessageRouter {
|
|
|
91
92
|
/**
|
|
92
93
|
* Register a message route
|
|
93
94
|
*/
|
|
94
|
-
route(type: string, handler: (connection: WebSocketConnection, payload: unknown) => Promise<unknown>): void;
|
|
95
|
+
route(type: string, handler: (connection: WebSocketConnection, payload: unknown, requestId?: WebSocketRequestId) => Promise<unknown>): void;
|
|
95
96
|
/**
|
|
96
97
|
* Handle incoming message
|
|
97
98
|
*/
|
|
@@ -101,7 +102,4 @@ export declare class WebSocketMessageRouter {
|
|
|
101
102
|
*/
|
|
102
103
|
getRoutes(): string[];
|
|
103
104
|
}
|
|
104
|
-
|
|
105
|
-
* Create a WebSocket handler for AI agent interactions
|
|
106
|
-
*/
|
|
107
|
-
export declare function createAgentWebSocketHandler(_neurolink: unknown): IWebSocketHandler;
|
|
105
|
+
export declare function createAgentWebSocketHandler(neurolink: NeuroLink): IWebSocketHandler;
|