@juspay/neurolink
Version:
The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002
227 lines (226 loc) • 9.7 kB
JavaScript
/**
* Bridges Bedrock's native `BedrockMessage[]` conversation shape and
* `AgenticLoopChunk` stream to the AI SDK V3 model-middleware protocol
* (`LanguageModelV3`), so `BaseProvider.applyMiddlewareToModel()` can wrap a
* turn built entirely out of `runAgenticLoop` — the same engine Bedrock's
* `generate()` and `executeStream()` already run on.
*
* Scope: only the conversation `generate()`/`executeStream()` hand to the
* loop as its STARTING point ever crosses this boundary. Both reset
* `conversationHistory` to a single fresh message before calling the loop
* (see `client.ts`), so that starting point is always `system + one user
* message` — never prior assistant/tool-result turns, which are constructed
* entirely inside the loop's own steps and stay in native
* `BedrockMessage[]`/`ConverseCommand` shape. A general, lossless
* `BedrockMessage` <-> `ModelMessage` converter is not attempted here, and
* would not be possible anyway: `BedrockToolResult` carries no `toolName`,
* which a faithful `ToolModelMessage` requires.
*
* Further scoped to TEXT-ONLY turns. A multimodal starting message (image,
* document, or — pathologically — a tool-use/tool-result block) skips
* wrapping entirely; the caller checks `isTextOnlyBedrockConversation` and
* falls back to calling the loop directly, logging that the turn was
* excluded. This mirrors the plan's AI Studio precedent: an excluded shape
* must be explicit in code, not implied by no test ever sending one.
*
* Who owns the outcome. When the wrapped `doGenerate`/`doStream` ran the loop,
* the loop's own result is authoritative for `finishReason`, `usage`,
* `toolExecutions` and the final conversation: a middleware may replace the
* content or the stream parts a caller sees, but it cannot rewrite the
* metadata of a turn that already reached the wire. When a middleware never
* calls through (a cache hit, a refusal, a synthetic answer) no loop ran, and
* its own V3 result is what gets reported.
*/
import { logger } from "../../utils/logger.js";
/**
* Sampling and output-shaping options of a V3 call that Converse is never
* sent: both turn builders fill `inferenceConfig` with `maxTokens` and
* `temperature` alone. The bridge seeds none of these, so a value present
* when `doGenerate`/`doStream` runs was put there by a middleware, and would
* otherwise be dropped without a trace.
*/
const UNSENT_V3_OPTIONS = [
"topP",
"topK",
"presencePenalty",
"frequencyPenalty",
"stopSequences",
"seed",
"toolChoice",
"responseFormat",
];
/** Names of the unsent options `params` carries; an empty list counts as unset. */
export function unsentBedrockMiddlewareOptions(params) {
return UNSENT_V3_OPTIONS.filter((key) => {
const value = params[key];
return Array.isArray(value) ? value.length > 0 : value !== undefined;
});
}
/**
* Unwrap Bedrock's `ToolSpecification.inputSchema` — always `{ json:
* DocumentType }` on the wire (see `ToolInputSchema.JsonMember` in the AWS
* SDK), never the bare schema — to the plain JSON schema object the V3 tool
* shape's `inputSchema` field expects. `client.ts`'s own tool-formatting path
* (`formatToolsForBedrock`) always sends `{ json: schema }` to AWS for this
* exact reason; handing a middleware the wrapper itself instead of the
* schema breaks any schema-inspecting middleware (e.g. a guardrail
* validating tool arguments against the declared shape).
*/
export function unwrapBedrockToolInputSchema(inputSchema) {
return inputSchema && "json" in inputSchema ? inputSchema.json : undefined;
}
/** True when every content block of every message is plain text. */
export function isTextOnlyBedrockConversation(conversation) {
return conversation.every((message) => message.content.every((block) => block.text !== undefined &&
block.image === undefined &&
block.document === undefined &&
block.toolUse === undefined &&
block.toolResult === undefined));
}
/**
* `system` + the (already text-only) starting conversation, as a V3 prompt.
* The system prompt is always sent as its own leading message — Bedrock
* carries it out-of-band (`ConverseCommandInput.system`), but the V3 prompt
* shape has no separate slot, so `bedrockConversationFromV3Prompt` is the
* inverse that pulls it back out.
*/
export function buildV3PromptFromBedrock(systemPrompt, conversation) {
const prompt = [{ role: "system", content: systemPrompt }];
for (const message of conversation) {
const text = message.content.map((block) => block.text ?? "").join("");
if (message.role === "user") {
prompt.push({ role: "user", content: text });
}
else {
prompt.push({ role: "assistant", content: text });
}
}
return prompt;
}
function contentToText(content) {
if (typeof content === "string") {
return content;
}
return content
.filter((part) => typeof part === "object" && part !== null && part.type === "text")
.map((part) => part.text)
.join("");
}
/**
* Inverse of `buildV3PromptFromBedrock`, read AFTER `transformParams` has
* run — this is what makes a middleware's prompt rewrite (correction 1)
* reach the wire: the caller passes the ORIGINAL prompt into `doGenerate`/
* `doStream`, and by AI SDK contract `transformParams` has already produced
* the version handed to the closure by the time this runs.
*
* A `tool` role message has no faithful Bedrock shape at this boundary (no
* toolUseId/toolName pairing survives `ToolModelMessage`), so one is
* dropped with a WARN rather than guessed at. The starting turn Bedrock
* ever hands across this boundary never contains one; seeing one here means
* middleware injected it.
*/
export function bedrockConversationFromV3Prompt(prompt) {
const systemParts = [];
const conversation = [];
for (const message of prompt) {
if (message.role === "system") {
systemParts.push(message.content);
continue;
}
if (message.role === "tool") {
logger.warn("[AmazonBedrockProvider] middleware transformParams introduced a 'tool' role message; it has no Bedrock wire shape at this boundary and was dropped");
continue;
}
conversation.push({
role: message.role,
content: [{ text: contentToText(message.content) }],
});
}
return { system: systemParts.join("\n\n"), conversation };
}
/**
* Wrap a native `AgenticLoopChunk` source into the V3 stream contract, one
* chunk at a time, forwarding cancellation to both the source iterator and
* `cancel` (which the caller wires to the turn's own `AbortController`, so
* breaking out of a wrapped stream tears down the underlying AWS SDK
* request — correction 4). `finish` resolves the terminal `finish` part
* once the loop settles; middleware inspecting the returned stream sees a
* genuine usage-bearing finish part even when nothing downstream reads it
* back out (a guardrail that blocks before calling `doStream` never
* produces this stream at all, which is the `!loopPromise` case its own
* caller handles).
*/
export function bedrockChunksToV3Stream(source, finish, cancel) {
const iterator = source[Symbol.asyncIterator]();
return new ReadableStream({
async pull(controller) {
try {
const next = await iterator.next();
if (next.done) {
controller.enqueue(await finish);
controller.close();
}
else if (next.value.reasoning) {
controller.enqueue({
type: "reasoning-delta",
delta: next.value.reasoning,
});
}
else {
controller.enqueue({ type: "text-delta", delta: next.value.content });
}
}
catch (error) {
controller.error(error);
}
},
async cancel() {
cancel();
await iterator.return?.();
},
});
}
/**
* Inverse of `bedrockChunksToV3Stream`: drains a (possibly
* middleware-wrapped) V3 stream back into Bedrock's native chunk shape.
* `finish`/`error` parts are consumed here but not surfaced as chunks —
* `streamingConversationLoop` reads the turn's outcome from the loop's own
* `resultPromise`, which already carries richer usage/finishReason/
* toolExecutions than a V3 finish part does; this function exists only to
* satisfy the wrapped stream's own contract (a middleware may expect its
* returned stream to be drained to completion) and to `throw` a middleware
* `error` part into the consumer.
*/
export async function* bedrockV3StreamToChunks(stream) {
const reader = stream.getReader();
let done = false;
try {
while (true) {
const next = await reader.read();
if (next.done) {
done = true;
return;
}
const part = next.value;
if (part.type === "text-delta") {
yield { content: part.delta };
}
else if (part.type === "reasoning-delta") {
yield { content: "", reasoning: part.delta };
}
else if (part.type === "error") {
throw part.error;
}
}
}
finally {
try {
if (!done) {
await reader.cancel();
}
}
finally {
reader.releaseLock();
}
}
}