@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
824 lines (823 loc) • 33.7 kB
JavaScript
/**
* Anthropic Messages API fallback over the pooled Codex Responses transport.
*
* This module deliberately contains only wire-format conversion and buffered
* SSE parsing. Account selection, OAuth, cooldowns, and quota persistence stay
* in the native Codex proxy handler so fallback traffic follows the same pool
* rules as a native Codex request.
*/
import { createHash } from "node:crypto";
import { ClaudeStreamSerializer, generateToolUseId } from "./claudeFormat.js";
import { classifyProxyFailureCode } from "./proxyFailureDetails.js";
import { extractCodexUsage } from "./codexUsage.js";
import { proxyTokenUsage } from "./proxyTokenUsage.js";
import { sanitizeForLog } from "../utils/logSanitize.js";
export class CodexFallbackResponseError extends Error {
status;
responseBody;
code;
retryable;
constructor(status, responseBody) {
super(`Codex fallback request returned HTTP ${status}`);
this.name = "CodexFallbackResponseError";
this.status = status;
this.responseBody = responseBody;
try {
const payload = JSON.parse(responseBody);
if (isRecord(payload)) {
const detail = streamFailureDetails(payload, "http_error");
this.code = detail.code;
this.retryable = detail.retryable;
if (detail.message) {
this.message = detail.message;
}
}
}
catch {
// Non-JSON upstream responses retain the HTTP status without exposing HTML.
}
}
}
/** A provider terminal event, preserved through the format adapter. */
export class CodexFallbackStreamError extends Error {
status;
code;
retryable;
usage;
constructor(payload, eventType, usage) {
const detail = streamFailureDetails(payload, eventType);
super(detail.message ?? `Codex fallback stream terminated with ${eventType}`);
this.name = "CodexFallbackStreamError";
this.code = detail.code;
this.status = detail.status;
this.retryable = detail.retryable;
const observed = extractCodexUsage(payload);
this.usage = observed
? {
...proxyTokenUsage({
inputTokens: observed.inputTokens,
outputTokens: observed.outputTokens,
reasoningTokens: observed.reasoningTokensObserved
? observed.reasoningTokens
: undefined,
cacheReadTokens: observed.cacheReadTokens,
cacheCreationTokens: observed.cacheCreationTokens,
inputIncludesCachedTokens: true,
}),
inputTokensObserved: observed.inputTokensObserved,
outputTokensObserved: observed.outputTokensObserved,
cacheReadTokensObserved: observed.cacheReadTokensObserved,
cacheCreationTokensObserved: observed.cacheCreationTokensObserved,
}
: usage;
}
}
function streamFailureDetails(payload, eventType) {
const response = isRecord(payload.response) ? payload.response : payload;
const error = isRecord(response.error) ? response.error : response;
const incomplete = isRecord(response.incomplete_details)
? response.incomplete_details
: {};
const code = sanitizeForLog(asNonEmptyString(error.code) ??
asNonEmptyString(incomplete.reason) ??
eventType, 200);
const known = classifyProxyFailureCode(code);
return {
code,
status: known.status ?? 502,
retryable: known.retryable ??
(typeof error.retryable === "boolean" ? error.retryable : undefined),
message: typeof error.message === "string"
? sanitizeForLog(error.message, 500)
: undefined,
};
}
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function asNonEmptyString(value) {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function buildSystemInstructions(body) {
const instructions = [];
if (typeof body.system === "string") {
if (body.system) {
instructions.push(body.system);
}
}
else if (Array.isArray(body.system)) {
const text = body.system
.map((block) => (typeof block.text === "string" ? block.text : ""))
.filter(Boolean)
.join("\n\n");
if (text) {
instructions.push(text);
}
}
for (const message of body.messages) {
// Claude's public Messages type restricts message roles to user/assistant,
// but Claude Code can emit an inline system message for context-management
// edits. Preserve its text as instructions instead of sending a non-leading
// system item to the Codex conversation input.
if (message.role !== "system") {
continue;
}
const text = typeof message.content === "string"
? message.content
: message.content
.map((block) => (block.type === "text" ? block.text : ""))
.filter(Boolean)
.join("\n");
if (text) {
instructions.push(text);
}
}
return instructions.length > 0 ? instructions.join("\n\n") : undefined;
}
function imageUrlForBlock(block) {
if (block.source.type === "url" && block.source.url) {
return block.source.url;
}
if (block.source.type === "base64" && block.source.data) {
return `data:${block.source.media_type ?? "image/png"};base64,${block.source.data}`;
}
return undefined;
}
function flattenClaudeContent(content) {
if (typeof content === "string") {
return content;
}
return content
.map((block) => {
switch (block.type) {
case "text":
return block.text;
case "thinking":
return block.thinking;
case "image":
return "[image attachment]";
case "tool_use":
return `[tool call ${block.name}] ${JSON.stringify(block.input ?? {})}`;
case "tool_result":
return flattenClaudeContent(block.content);
}
})
.join("\n");
}
function toCodexContentPart(block, role) {
const textType = role === "assistant" ? "output_text" : "input_text";
switch (block.type) {
case "text":
return { type: textType, text: block.text };
case "thinking":
return { type: textType, text: block.thinking };
case "image": {
const imageUrl = imageUrlForBlock(block);
if (role === "user" && imageUrl) {
return { type: "input_image", image_url: imageUrl };
}
return { type: textType, text: "[image attachment]" };
}
}
}
function convertClaudeMessage(role, content) {
if (typeof content === "string") {
return [
{
role,
content: [
{
type: role === "assistant" ? "output_text" : "input_text",
text: content,
},
],
},
];
}
const input = [];
let messageContent = [];
const flushMessage = () => {
if (messageContent.length === 0) {
return;
}
input.push({ role, content: messageContent });
messageContent = [];
};
for (const block of content) {
if (block.type === "tool_use") {
flushMessage();
input.push({
type: "function_call",
call_id: block.id,
name: block.name,
arguments: JSON.stringify(block.input ?? {}),
});
continue;
}
if (block.type === "tool_result") {
flushMessage();
input.push({
type: "function_call_output",
call_id: block.tool_use_id,
output: flattenClaudeContent(block.content),
});
continue;
}
// Claude Code context-management messages can contain `tool_addition`
// blocks. The live tool definitions are already carried by `body.tools`;
// these history markers must not become undefined array entries because
// JSON encodes array undefined values as null and Codex rejects them.
if (block.type === "tool_addition") {
continue;
}
const contentPart = toCodexContentPart(block, role);
if (contentPart) {
messageContent.push(contentPart);
}
}
flushMessage();
return input;
}
/**
* Routing key that pins a request to the cache already holding its prefix.
*
* Anthropic caching is explicit — the client marks breakpoints and the provider
* honours them. OpenAI caching is automatic: a prefix is reused only when the
* request lands on infrastructure already holding it, and `prompt_cache_key` is
* what pins a request to one. Without it this hop takes its chances on routing,
* which is the worst case for a fallback that arrives in bursts separated by
* long gaps.
*
* The key must therefore describe the *prefix*, not the caller. Keying it by
* session id gives every session a private namespace, so N agents that share
* one system prompt and tool set each pay that shared prefix in full rather
* than paying it once — the cost is linear in fan-out width and invisible,
* because each request looks individually well-behaved. Across 42 captured
* fallback bodies the prefixes collapse to 10 distinct values, the largest
* shared by 12 requests belonging to different sessions.
*
* So derive the key from the prefix itself: the instructions and the tool
* declarations, which are exactly the leading bytes the upstream caches and
* the only part stable across a conversation's turns. Identical prefixes then
* share one cache across sessions and subagents, a prefix that genuinely
* differs still gets its own key so nothing unrelated contends, and turns
* within one session keep sharing a key because neither input changes between
* them.
*
* A request carrying neither instructions nor tools has no prefix worth
* pinning, and falls back to the session id so its turns at least stay
* together. With neither available the field is omitted rather than filled
* with something shared: a key common to every request would pin unrelated
* prefixes onto one cache instead of separating them.
*
* The digest covers only content already sent upstream in `instructions` and
* `tools`; no account, device or session identifier reaches the wire.
*/
export function codexPromptCacheKey(body) {
const prefix = codexCachePrefix(body);
if (prefix !== undefined) {
return createHash("sha256").update(prefix).digest("hex");
}
const sessionId = codexSessionId(body);
if (sessionId !== undefined) {
return createHash("sha256").update(sessionId).digest("hex");
}
return undefined;
}
/**
* How much of the instructions the key covers.
*
* Claude Code's system prompt is not byte-stable: measured across live 16,722
* character prompts, the first 14,768 characters (88.3%) are identical and the
* divergence sits in a trailing session-memory section that is rewritten every
* turn. Hashing the whole thing therefore produces a fresh key per request —
* the opposite of pinning. Hashing a bounded head instead tracks the part the
* upstream can actually reuse, since its cache is built forward from the first
* token.
*
* 8192 is below the observed divergence point with margin, and the error is
* asymmetric: too long only costs sharing, while too short pins genuinely
* unrelated prefixes onto one cache where they evict each other.
*
* The bound only helps a prompt long enough to have a tail beyond it. A
* shorter prompt is hashed whole, so a churning section inside it would move
* the key every turn — where keying by session used to hold still. Measured on
* the same corpus, that does not happen: 85 of 92 prompts carrying a system
* block sit under this bound, and within a session they resolve to a handful
* of keys rather than one per request (28 requests to 4 keys, 17 to 2, 31 to
* 2) — those are distinct agents, not one agent churning. Identifying the
* stable section structurally would remove the assumption, and is worth doing
* if a prompt ever proves otherwise.
*/
const CACHE_KEY_PREFIX_CHARS = 8192;
/**
* The leading, turn-stable bytes of a Codex request: a bounded head of the
* instructions plus the tool names, which distinguish one agent's tool surface
* from another's when their instructions open identically. Descriptions and
* schemas are deliberately left out — they sit behind the instructions in the
* prefix, so two requests agreeing on this much already share a cacheable head
* worth routing together.
*/
function codexCachePrefix(body) {
const head = (buildSystemInstructions(body) ?? "").slice(0, CACHE_KEY_PREFIX_CHARS);
const toolNames = (body.tools ?? []).map((tool) => tool.name).join("\u0000");
if (!head && !toolNames) {
return undefined;
}
return `${head}\u0001${toolNames}`;
}
/** Claude Code carries its session id inside `metadata.user_id`. */
function codexSessionId(body) {
const raw = body.metadata?.user_id;
if (typeof raw !== "string" || raw.length === 0) {
return undefined;
}
let parsed;
try {
parsed = JSON.parse(raw);
}
catch {
return undefined;
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
return undefined;
}
const fields = parsed;
// `??` would let an empty or non-string session_id shadow a usable
// parent_session_id, so each field has to qualify on its own.
return [fields.session_id, fields.parent_session_id].find((value) => typeof value === "string" && value.length > 0);
}
/** Convert a Claude Messages request into the ChatGPT Codex Responses shape. */
export function convertClaudeRequestToCodex(body, model, reasoningEffort) {
const input = body.messages.flatMap((message) => message.role === "system"
? []
: convertClaudeMessage(message.role, message.content));
const request = {
model,
input,
stream: true,
// ChatGPT's backend rejects requests unless this is explicitly false.
store: false,
...(reasoningEffort !== undefined
? { reasoning: { effort: reasoningEffort } }
: {}),
};
const promptCacheKey = codexPromptCacheKey(body);
if (promptCacheKey) {
request.prompt_cache_key = promptCacheKey;
}
const instructions = buildSystemInstructions(body);
if (instructions) {
request.instructions = instructions;
}
if (body.tools && body.tools.length > 0) {
request.tools = body.tools.map((tool) => ({
type: "function",
name: tool.name,
...(tool.description ? { description: tool.description } : {}),
parameters: tool.input_schema,
}));
}
if (body.tool_choice) {
switch (body.tool_choice.type) {
case "any":
request.tool_choice = "required";
break;
case "tool":
request.tool_choice = { type: "function", name: body.tool_choice.name };
break;
default:
request.tool_choice = body.tool_choice.type;
break;
}
}
// The ChatGPT Codex backend is not the public Responses API. In particular,
// it rejects `max_output_tokens`, and its model-owned sampling controls do
// not map safely from Anthropic's `temperature` or `top_p`. Omit all three
// so the backend uses its supported defaults instead of rejecting fallback
// traffic before it can be served.
return request;
}
function parseFunctionArguments(value) {
if (isRecord(value)) {
return value;
}
if (typeof value !== "string") {
throw new Error("Codex fallback function call is missing JSON arguments");
}
try {
const parsed = JSON.parse(value || "{}");
if (!isRecord(parsed)) {
throw new Error("not an object");
}
return parsed;
}
catch {
throw new Error("Codex fallback function call returned invalid JSON arguments");
}
}
function outputTextFromItem(value) {
if (!isRecord(value) ||
value.type !== "message" ||
!Array.isArray(value.content)) {
return "";
}
return value.content
.filter(isRecord)
.filter((part) => part.type === "output_text")
.map((part) => (typeof part.text === "string" ? part.text : ""))
.join("");
}
function addFunctionCall(item, toolCalls) {
if (item.type !== "function_call") {
return;
}
const callId = asNonEmptyString(item.call_id);
const name = asNonEmptyString(item.name);
if (!callId || !name) {
throw new Error("Codex fallback function call is missing an id or name");
}
toolCalls.set(callId, {
toolCallId: callId,
toolName: name,
args: parseFunctionArguments(item.arguments),
});
}
function parseSSEPayloads(sse) {
const frames = sse.replace(/\r\n?/g, "\n").split("\n\n");
const parsed = [];
for (const frame of frames) {
if (!frame.trim()) {
continue;
}
let event;
const data = [];
for (const line of frame.split("\n")) {
if (!line || line.startsWith(":")) {
continue;
}
if (line.startsWith("event:")) {
event = line.slice("event:".length).trim();
continue;
}
if (line.startsWith("data:")) {
data.push(line.slice("data:".length).trimStart());
continue;
}
if (line.startsWith("id:") || line.startsWith("retry:")) {
continue;
}
// SSE permits extension fields that this parser does not consume.
continue;
}
if (data.length === 0) {
continue;
}
const raw = data.join("\n").trim();
if (raw === "[DONE]") {
continue;
}
try {
const payload = JSON.parse(raw);
if (!isRecord(payload)) {
throw new Error("not an object");
}
parsed.push({ event, payload });
}
catch {
throw new Error("Codex fallback stream contains malformed JSON");
}
}
return parsed;
}
function responseStatus(payload) {
const response = payload.response;
return isRecord(response) ? asNonEmptyString(response.status) : undefined;
}
function outputTextFromResponse(payload) {
const response = payload.response;
if (!isRecord(response) || !Array.isArray(response.output)) {
return "";
}
return response.output.map(outputTextFromItem).join("");
}
/**
* Parse a complete Codex Responses SSE stream before emitting Claude output.
*
* A missing terminal event, malformed JSON, terminal error, or empty response
* is rejected. That makes it safe for the caller to try the next configured
* fallback without ever replaying output already sent to a client.
*/
export function parseCodexFallbackSSE(sse) {
const payloads = parseSSEPayloads(sse);
const toolCalls = new Map();
let textFromDeltas = "";
let textFromCompletedItems = "";
let textFromResponse = "";
let usage;
let sawCompleted = false;
for (const { event, payload } of payloads) {
const type = asNonEmptyString(payload.type) ?? event;
if (!type) {
throw new Error("Codex fallback stream event is missing a type");
}
if (type === "error" ||
type === "response.failed" ||
type === "response.incomplete") {
throw new CodexFallbackStreamError(payload, type, usage);
}
if (type === "response.output_text.delta") {
if (typeof payload.delta !== "string") {
throw new Error("Codex fallback text delta is malformed");
}
textFromDeltas += payload.delta;
continue;
}
if (type === "response.output_item.done") {
if (!isRecord(payload.item)) {
throw new Error("Codex fallback output item is malformed");
}
addFunctionCall(payload.item, toolCalls);
textFromCompletedItems += outputTextFromItem(payload.item);
continue;
}
if (type !== "response.completed") {
continue;
}
if (sawCompleted) {
throw new Error("Codex fallback stream emitted more than one completion event");
}
sawCompleted = true;
const status = responseStatus(payload);
if (status !== "completed") {
throw new Error(`Codex fallback stream completed with unexpected status ${status ?? "unknown"}`);
}
const parsedUsage = extractCodexUsage(payload);
if (parsedUsage) {
usage = {
...proxyTokenUsage({
inputTokens: parsedUsage.inputTokens,
outputTokens: parsedUsage.outputTokens,
reasoningTokens: parsedUsage.reasoningTokensObserved
? parsedUsage.reasoningTokens
: undefined,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
inputIncludesCachedTokens: true,
}),
inputTokensObserved: parsedUsage.inputTokensObserved,
outputTokensObserved: parsedUsage.outputTokensObserved,
cacheReadTokensObserved: parsedUsage.cacheReadTokensObserved,
cacheCreationTokensObserved: parsedUsage.cacheCreationTokensObserved,
};
}
textFromResponse = outputTextFromResponse(payload);
}
if (!sawCompleted) {
throw new Error("Codex fallback stream ended before response.completed");
}
const text = textFromDeltas || textFromCompletedItems || textFromResponse;
const resolvedToolCalls = [...toolCalls.values()];
if (!text && resolvedToolCalls.length === 0) {
throw new CodexFallbackStreamError({
code: "empty_response",
message: "Codex fallback returned no content or tool calls",
}, "empty_response", usage);
}
return {
text,
toolCalls: resolvedToolCalls,
...(usage ? { usage } : {}),
finishReason: resolvedToolCalls.length > 0 ? "tool_use" : "end_turn",
};
}
/** Consume and validate a native Codex response before producing Claude output. */
export async function consumeCodexFallbackResponse(response) {
if (!response.ok) {
throw new CodexFallbackResponseError(response.status, await response.text().catch(() => ""));
}
if (!response.body) {
throw new Error("Codex fallback returned an empty stream");
}
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.toLowerCase().includes("text/event-stream")) {
// Consume it before failing so the underlying connection can be reused.
await response.text().catch(() => "");
throw new Error("Codex fallback returned a non-SSE response");
}
return parseCodexFallbackSSE(await response.text());
}
/**
* Translate a Codex stream as events arrive. A completed tool call is emitted
* once its arguments validate; text does not wait for response.completed.
* The caller owns error framing and must never retry after emitting output.
*/
export async function createCodexFallbackStream(response, model) {
if (!response.ok) {
throw new CodexFallbackResponseError(response.status, await response.text().catch(() => ""));
}
if (!response.body ||
!(response.headers.get("content-type") ?? "")
.toLowerCase()
.includes("text/event-stream")) {
await response.body?.cancel().catch(() => undefined);
throw new Error("Codex fallback returned a non-SSE or empty response");
}
const reader = response.body.getReader();
let cancellation;
const cancel = (reason) => {
cancellation ??= reader
.cancel(reason)
.catch(() => undefined)
.finally(() => reader.releaseLock());
return cancellation;
};
async function* frames() {
const serializer = new ClaudeStreamSerializer(model);
const decoder = new TextDecoder();
const toolCalls = new Map();
const emittedTools = new Set();
const emittedTextItems = new Set();
const textParts = [];
let textLength = 0;
let toolChars = 0;
let carry = "";
let searchFrom = 0;
let completed = false;
let usage;
const maxChars = 16 * 1024 * 1024;
function* text(value, index) {
if (!value) {
return;
}
textLength += value.length;
if (textLength > maxChars) {
throw new Error("Codex fallback output exceeded the stream limit");
}
textParts.push(value);
emittedTextItems.add(index);
yield* serializer.pushDelta(value);
}
function* item(value, index) {
if (!isRecord(value)) {
throw new Error("Codex fallback output item is malformed");
}
if (value.type === "function_call") {
const id = asNonEmptyString(value.call_id);
if (!id || !emittedTools.has(id)) {
toolChars += JSON.stringify(value).length;
if (toolChars > maxChars || toolCalls.size >= 4096) {
throw new Error("Codex fallback tools exceeded the stream limit");
}
addFunctionCall(value, toolCalls);
const call = id ? toolCalls.get(id) : undefined;
if (id && call) {
emittedTools.add(id);
yield* serializer.pushToolUse(generateToolUseId(), call.toolName, call.args);
}
}
}
if (!emittedTextItems.has(index)) {
yield* text(outputTextFromItem(value), index);
}
}
function* event(frame) {
for (const { event: eventName, payload } of parseSSEPayloads(frame)) {
const type = asNonEmptyString(payload.type) ?? eventName;
if (!type) {
throw new Error("Codex fallback stream event is missing a type");
}
if (completed) {
throw new Error("Codex fallback stream emitted events after completion");
}
if (type === "error" ||
type === "response.failed" ||
type === "response.incomplete") {
throw new CodexFallbackStreamError(payload, type, usage);
}
const index = typeof payload.output_index === "number" ? payload.output_index : 0;
if (type === "response.output_text.delta") {
if (typeof payload.delta !== "string") {
throw new Error("Codex fallback text delta is malformed");
}
yield* text(payload.delta, index);
}
else if (type === "response.output_item.done") {
if (!isRecord(payload.item)) {
throw new Error("Codex fallback output item is malformed");
}
yield* item(payload.item, index);
}
else if (type === "response.completed") {
if (responseStatus(payload) !== "completed") {
throw new Error("Codex fallback response did not complete");
}
completed = true;
const parsedUsage = extractCodexUsage(payload);
if (parsedUsage) {
usage = {
...proxyTokenUsage({
inputTokens: parsedUsage.inputTokens,
outputTokens: parsedUsage.outputTokens,
reasoningTokens: parsedUsage.reasoningTokensObserved
? parsedUsage.reasoningTokens
: undefined,
cacheReadTokens: parsedUsage.cacheReadTokens,
cacheCreationTokens: parsedUsage.cacheCreationTokens,
inputIncludesCachedTokens: true,
}),
inputTokensObserved: parsedUsage.inputTokensObserved,
outputTokensObserved: parsedUsage.outputTokensObserved,
cacheReadTokensObserved: parsedUsage.cacheReadTokensObserved,
cacheCreationTokensObserved: parsedUsage.cacheCreationTokensObserved,
};
}
const responseBody = payload.response;
if (isRecord(responseBody) && Array.isArray(responseBody.output)) {
for (const [i, output] of responseBody.output.entries()) {
yield* item(output, i);
}
}
}
}
}
try {
yield* serializer.start();
while (true) {
let chunk;
try {
chunk = await reader.read();
}
catch (err) {
// A premature stream close after a complete SSE stream (but before
// the zero-length terminator) rejects here. If response.completed
// already landed, treat the close as a normal end-of-stream rather
// than failing a fully successful turn; otherwise this is genuine
// truncation and must still fail visibly.
if (completed) {
break;
}
throw err;
}
carry += decoder.decode(chunk.value, { stream: !chunk.done });
// Search only new bytes plus the boundary overlap. Long tool payloads
// split over many chunks must not rescan their accumulated prefix.
const boundary = /\r?\n\r?\n/g;
boundary.lastIndex = searchFrom;
let match;
while ((match = boundary.exec(carry)) !== null) {
if (match.index > maxChars) {
throw new Error("Codex fallback event exceeded the stream limit");
}
yield* event(carry.slice(0, match.index));
carry = carry.slice(match.index + match[0].length);
boundary.lastIndex = 0;
}
if (carry.length > maxChars) {
throw new Error("Codex fallback event exceeded the stream limit");
}
searchFrom = Math.max(0, carry.length - 3);
if (chunk.done) {
break;
}
}
if (carry.trim()) {
yield* event(carry);
}
if (!completed) {
throw new Error("Codex fallback stream ended before response.completed");
}
if (textLength === 0 && toolCalls.size === 0) {
throw new CodexFallbackStreamError({
code: "empty_response",
message: "Codex fallback returned no content or tool calls",
}, "empty_response", usage);
}
const finishReason = toolCalls.size > 0 ? "tool_use" : "end_turn";
// A count Codex never reported is omitted rather than sent as 0: this is
// what the Claude client reads, and a zero there is indistinguishable
// from an observed cache miss. `proxyTokenUsage` floors an absent count
// to 0, so the observation flags are the only thing that still knows.
yield* serializer.finish(usage?.output, finishReason, {
input_tokens: usage?.input,
cache_read_input_tokens: usage?.cacheReadTokensObserved
? usage.cacheReadTokens
: undefined,
cache_creation_input_tokens: usage?.cacheCreationTokensObserved
? usage.cacheCreationTokens
: undefined,
});
return {
text: textParts.join(""),
toolCalls: [...toolCalls.values()],
finishReason,
...(usage ? { usage } : {}),
};
}
finally {
await cancel();
reader.releaseLock();
}
}
return { frames: frames(), cancel };
}