UNPKG

workers-ai-provider

Version:

Workers AI Provider for the vercel AI SDK

1 lines 205 kB
{"version":3,"file":"index.mjs","names":["coreNormalizeMessagesForBinding","exhaustiveCheck","coreParseLeakedToolCalls","toUint8Array","uint8ArrayToBase64","exhaustiveCheck","exhaustiveCheck","toUint8Array","ai"],"sources":["../src/workersai-error.ts","../src/utils.ts","../src/convert-to-workersai-chat-messages.ts","../src/map-workersai-usage.ts","../src/map-workersai-finish-reason.ts","../src/streaming.ts","../src/aisearch-chat-language-model.ts","../src/workersai-embedding-model.ts","../src/workersai-chat-language-model.ts","../src/workersai-image-model.ts","../src/workersai-transcription-model.ts","../src/workersai-speech-model.ts","../src/workersai-reranking-model.ts","../src/autorag-chat-language-model.ts","../src/client-fallback.ts","../src/gateway-delegate.ts","../src/index.ts"],"sourcesContent":["import { APICallError } from \"@ai-sdk/provider\";\nimport {\n\theadersToObject,\n\tisAbortError,\n\tmessageOf,\n\tparseWorkersAIErrorCode,\n\tWORKERS_AI_ERROR_CODE_TO_STATUS,\n} from \"@cloudflare/gateway-core\";\n\n// Re-exported from `@cloudflare/gateway-core` (single source of truth, shared\n// with `@cloudflare/tanstack-ai`) so existing importers of this module keep\n// working unchanged.\nexport { parseWorkersAIErrorCode, WORKERS_AI_ERROR_CODE_TO_STATUS };\n\n/**\n * Normalize an error thrown by the Workers AI **binding** (`env.AI.run`) into an\n * `APICallError` so the AI SDK can classify and retry it.\n *\n * Cancellations (`AbortError` / `TimeoutError` / `ResponseAborted`, including\n * `DOMException` aborts) and errors that are already an `APICallError` pass\n * through unchanged. Everything else becomes an\n * `APICallError`; when the internal code maps to a known HTTP status, that\n * `statusCode` is attached and `APICallError` derives `isRetryable` from it.\n * Unrecognized errors get no `statusCode`, so they stay non-retryable (the\n * prior behavior).\n */\nexport function normalizeBindingError(\n\terror: unknown,\n\tcontext: { model: string; requestBodyValues: unknown },\n): unknown {\n\tif (APICallError.isInstance(error) || isAbortError(error)) {\n\t\treturn error;\n\t}\n\n\tconst code = parseWorkersAIErrorCode(error);\n\tconst statusCode = code != null ? WORKERS_AI_ERROR_CODE_TO_STATUS[code] : undefined;\n\tconst message = messageOf(error);\n\n\treturn new APICallError({\n\t\tmessage,\n\t\turl: `workers-ai:binding/run/${context.model}`,\n\t\trequestBodyValues: context.requestBodyValues,\n\t\tstatusCode,\n\t\tresponseBody: message,\n\t\tcause: error,\n\t\t...(code != null ? { data: { workersAIErrorCode: code } } : {}),\n\t});\n}\n\n/**\n * Build an `APICallError` from a non-OK Workers AI **REST** response. The HTTP\n * status is authoritative here, so `APICallError` derives `isRetryable` from it\n * directly (429 / 5xx → retryable). Response headers are preserved so the AI\n * SDK can honor `Retry-After`. The message keeps the historical\n * `\"Workers AI API error (<status> <statusText>): <body>\"` shape.\n */\nexport function apiCallErrorFromResponse(\n\tresponse: Response,\n\terrorBody: string,\n\tcontext: { url: string; requestBodyValues: unknown },\n): APICallError {\n\treturn new APICallError({\n\t\tmessage: `Workers AI API error (${response.status} ${response.statusText}): ${errorBody}`,\n\t\turl: context.url,\n\t\trequestBodyValues: context.requestBodyValues,\n\t\tstatusCode: response.status,\n\t\tresponseHeaders: headersToObject(response.headers),\n\t\tresponseBody: errorBody,\n\t});\n}\n","import type { LanguageModelV3, LanguageModelV3ToolCall } from \"@ai-sdk/provider\";\nimport {\n\tgetToolNames,\n\tisForcedToolChoice,\n\tnormalizeMessagesForBinding as coreNormalizeMessagesForBinding,\n\tparseLeakedToolCalls as coreParseLeakedToolCalls,\n\tprocessText,\n} from \"@cloudflare/gateway-core\";\nimport { generateId } from \"ai\";\nimport type { WorkersAIChatPrompt } from \"./workersai-chat-prompt\";\nimport { apiCallErrorFromResponse } from \"./workersai-error\";\n\n// Re-exported from `@cloudflare/gateway-core` (single source of truth) so the\n// existing `workers-ai-provider/src/utils` import paths keep working unchanged.\nexport { getToolNames, isForcedToolChoice, processText } from \"@cloudflare/gateway-core\";\n\n// ---------------------------------------------------------------------------\n// Workers AI quirk workarounds\n// ---------------------------------------------------------------------------\n\n/**\n * Normalize messages before passing to the Workers AI binding.\n *\n * The binding has strict schema validation that differs from the OpenAI API:\n * - `content` must not be null\n */\nexport function normalizeMessagesForBinding(messages: WorkersAIChatPrompt): WorkersAIChatPrompt {\n\treturn coreNormalizeMessagesForBinding(\n\t\tmessages as unknown as Record<string, unknown>[],\n\t) as unknown as WorkersAIChatPrompt;\n}\n\n// ---------------------------------------------------------------------------\n// REST API client\n// ---------------------------------------------------------------------------\n\n/**\n * General AI run interface with overloads to handle distinct return types.\n */\nexport interface AiRun {\n\t<Name extends keyof AiModels>(\n\t\tmodel: Name,\n\t\tinputs: AiModels[Name][\"inputs\"],\n\t\toptions: AiOptions & { returnRawResponse: true },\n\t): Promise<Response>;\n\n\t<Name extends keyof AiModels>(\n\t\tmodel: Name,\n\t\tinputs: AiModels[Name][\"inputs\"] & { stream: true },\n\t\toptions?: AiOptions,\n\t): Promise<ReadableStream<Uint8Array>>;\n\n\t<Name extends keyof AiModels>(\n\t\tmodel: Name,\n\t\tinputs: AiModels[Name][\"inputs\"],\n\t\toptions?: AiOptions,\n\t): Promise<AiModels[Name][\"postProcessedOutputs\"]>;\n}\n\n/**\n * Parameters for configuring the Cloudflare-based AI runner.\n */\nexport interface CreateRunConfig {\n\t/** Your Cloudflare account identifier. */\n\taccountId: string;\n\t/** Cloudflare API token/key with appropriate permissions. */\n\tapiKey: string;\n\t/** Custom fetch implementation for intercepting requests. */\n\tfetch?: typeof globalThis.fetch;\n}\n\n/**\n * Creates a run method that emulates the Cloudflare Workers AI binding,\n * but uses the Cloudflare REST API under the hood.\n */\nexport function createRun(config: CreateRunConfig): AiRun {\n\tconst { accountId, apiKey } = config;\n\tconst fetchFn = config.fetch ?? globalThis.fetch;\n\n\treturn async function run<Name extends keyof AiModels>(\n\t\tmodel: Name,\n\t\tinputs: AiModels[Name][\"inputs\"],\n\t\toptions?: AiOptions & Record<string, unknown>,\n\t): Promise<Response | ReadableStream<Uint8Array> | AiModels[Name][\"postProcessedOutputs\"]> {\n\t\tconst {\n\t\t\tgateway,\n\t\t\tprefix: _prefix,\n\t\t\textraHeaders,\n\t\t\treturnRawResponse,\n\t\t\tsignal, // AbortSignal — not serializable as a query parameter\n\t\t\t...passthroughOptions\n\t\t} = options || {};\n\n\t\tconst urlParams = new URLSearchParams();\n\t\tfor (const [key, value] of Object.entries(passthroughOptions)) {\n\t\t\tif (value === undefined || value === null) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`Value for option '${key}' is not able to be coerced into a string.`,\n\t\t\t\t);\n\t\t\t}\n\t\t\ttry {\n\t\t\t\tconst valueStr = String(value);\n\t\t\t\tif (!valueStr) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\turlParams.append(key, valueStr);\n\t\t\t} catch {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`Value for option '${key}' is not able to be coerced into a string.`,\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\tconst queryString = urlParams.toString();\n\n\t\tconst modelPath = String(model).startsWith(\"run/\") ? model : `run/${model}`;\n\n\t\t// Build URL: use AI Gateway if gateway option is provided, otherwise direct API\n\t\tconst url = gateway?.id\n\t\t\t? `https://gateway.ai.cloudflare.com/v1/${accountId}/${gateway.id}/workers-ai/${modelPath}${\n\t\t\t\t\tqueryString ? `?${queryString}` : \"\"\n\t\t\t\t}`\n\t\t\t: `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/${modelPath}${\n\t\t\t\t\tqueryString ? `?${queryString}` : \"\"\n\t\t\t\t}`;\n\n\t\tconst headers: Record<string, string> = {\n\t\t\tAuthorization: `Bearer ${apiKey}`,\n\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t...(extraHeaders && typeof extraHeaders === \"object\"\n\t\t\t\t? (extraHeaders as Record<string, string>)\n\t\t\t\t: {}),\n\t\t};\n\n\t\tif (gateway) {\n\t\t\tif (gateway.skipCache) {\n\t\t\t\theaders[\"cf-aig-skip-cache\"] = \"true\";\n\t\t\t}\n\t\t\tif (typeof gateway.cacheTtl === \"number\") {\n\t\t\t\theaders[\"cf-aig-cache-ttl\"] = String(gateway.cacheTtl);\n\t\t\t}\n\t\t\tif (gateway.cacheKey) {\n\t\t\t\theaders[\"cf-aig-cache-key\"] = gateway.cacheKey;\n\t\t\t}\n\t\t\tif (gateway.metadata) {\n\t\t\t\theaders[\"cf-aig-metadata\"] = JSON.stringify(gateway.metadata);\n\t\t\t}\n\t\t}\n\n\t\tconst body = JSON.stringify(inputs);\n\n\t\tconst response = await fetchFn(url, {\n\t\t\tbody,\n\t\t\theaders,\n\t\t\tmethod: \"POST\",\n\t\t\tsignal: signal as AbortSignal | undefined,\n\t\t});\n\n\t\t// Check for HTTP errors before processing. Surface as an APICallError so\n\t\t// the AI SDK can classify retryability from the status (429 / 5xx → retry)\n\t\t// and honor any Retry-After header.\n\t\tif (!response.ok && !returnRawResponse) {\n\t\t\tlet errorBody: string;\n\t\t\ttry {\n\t\t\t\terrorBody = await response.text();\n\t\t\t} catch {\n\t\t\t\terrorBody = \"<unable to read response body>\";\n\t\t\t}\n\t\t\tthrow apiCallErrorFromResponse(response, errorBody, {\n\t\t\t\turl,\n\t\t\t\trequestBodyValues: inputs,\n\t\t\t});\n\t\t}\n\n\t\tif (returnRawResponse) {\n\t\t\treturn response;\n\t\t}\n\n\t\tif ((inputs as AiTextGenerationInput).stream === true) {\n\t\t\tconst contentType = response.headers.get(\"content-type\") || \"\";\n\t\t\tif (contentType.includes(\"event-stream\") && response.body) {\n\t\t\t\treturn response.body;\n\t\t\t}\n\t\t\tif (response.body && !contentType.includes(\"json\")) {\n\t\t\t\t// Unknown content type — assume it's a stream\n\t\t\t\treturn response.body;\n\t\t\t}\n\n\t\t\t// Some models (e.g. GPT-OSS) don't support streaming via the /ai/run/\n\t\t\t// endpoint and return a JSON response with empty result instead of SSE.\n\t\t\t// Retry without streaming so doStream's graceful degradation path can\n\t\t\t// wrap the complete response as a synthetic stream.\n\t\t\t// Use the same URL (gateway or direct) as the original request.\n\t\t\tconst retryResponse = await fetchFn(url, {\n\t\t\t\tbody: JSON.stringify({\n\t\t\t\t\t...(inputs as Record<string, unknown>),\n\t\t\t\t\tstream: false,\n\t\t\t\t}),\n\t\t\t\theaders,\n\t\t\t\tmethod: \"POST\",\n\t\t\t\tsignal: signal as AbortSignal | undefined,\n\t\t\t});\n\n\t\t\tif (!retryResponse.ok) {\n\t\t\t\tlet errorBody: string;\n\t\t\t\ttry {\n\t\t\t\t\terrorBody = await retryResponse.text();\n\t\t\t\t} catch {\n\t\t\t\t\terrorBody = \"<unable to read response body>\";\n\t\t\t\t}\n\t\t\t\tthrow apiCallErrorFromResponse(retryResponse, errorBody, {\n\t\t\t\t\turl,\n\t\t\t\t\trequestBodyValues: inputs,\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tconst retryData = await retryResponse.json<{\n\t\t\t\tresult: AiModels[Name][\"postProcessedOutputs\"];\n\t\t\t}>();\n\t\t\treturn retryData.result;\n\t\t}\n\n\t\tconst data = await response.json<{\n\t\t\tresult: AiModels[Name][\"postProcessedOutputs\"];\n\t\t}>();\n\t\treturn data.result;\n\t};\n}\n\n/**\n * Make a binary REST API call to Workers AI.\n *\n * Some models (e.g. `@cf/deepgram/nova-3`) require raw audio bytes\n * with an appropriate `Content-Type` header instead of JSON.\n *\n * @param config Credentials config\n * @param model Workers AI model name\n * @param audioBytes Raw audio bytes\n * @param contentType MIME type (e.g. \"audio/wav\")\n * @param signal Optional AbortSignal\n * @returns The parsed JSON response body\n */\nexport async function createRunBinary(\n\tconfig: CreateRunConfig,\n\tmodel: string,\n\taudioBytes: Uint8Array,\n\tcontentType: string,\n\tsignal?: AbortSignal,\n): Promise<Record<string, unknown>> {\n\tconst url = `https://api.cloudflare.com/client/v4/accounts/${config.accountId}/ai/run/${model}`;\n\n\tconst response = await fetch(url, {\n\t\tmethod: \"POST\",\n\t\theaders: {\n\t\t\tAuthorization: `Bearer ${config.apiKey}`,\n\t\t\t\"Content-Type\": contentType,\n\t\t},\n\t\tbody: audioBytes,\n\t\tsignal,\n\t});\n\n\tif (!response.ok) {\n\t\tlet errorBody: string;\n\t\ttry {\n\t\t\terrorBody = await response.text();\n\t\t} catch {\n\t\t\terrorBody = \"<unable to read response body>\";\n\t\t}\n\t\tthrow apiCallErrorFromResponse(response, errorBody, {\n\t\t\turl,\n\t\t\trequestBodyValues: { contentType, byteLength: audioBytes.byteLength },\n\t\t});\n\t}\n\n\tconst data = await response.json<{ result?: Record<string, unknown> }>();\n\treturn (data.result ?? data) as Record<string, unknown>;\n}\n\n// ---------------------------------------------------------------------------\n// Structured output (JSON mode)\n// ---------------------------------------------------------------------------\n\n/**\n * Build the `response_format.json_schema` payload for native Workers AI models.\n *\n * Native Workers AI (`@cf/...`) expects `json_schema` to be a **bare** JSON\n * Schema, NOT OpenAI's `{ name, schema, strict }` envelope. That envelope is\n * only required by partner-model routes (e.g. `openai/...`), which never reach\n * this code — they go through the gateway delegate and the real `@ai-sdk/*`\n * providers, which build the envelope themselves. Wrapping the schema here would\n * break native models, so we must keep the bare shape.\n *\n * The AI SDK's structured-output `name` / `description` (from\n * `Output.object({ schema, name, description })` / `generateObject`) would\n * otherwise be silently dropped on this path. We preserve them as the standard\n * JSON Schema `title` (from `name`) and `description` keywords, which keeps the\n * payload a valid bare schema while still passing the LLM guidance through.\n *\n * Existing schema-level `title` / `description` are never overwritten, empty\n * strings are ignored, and the input schema object is never mutated.\n *\n * See https://github.com/cloudflare/ai/issues/559.\n */\nexport function buildJsonSchemaPayload(\n\tschema: unknown,\n\tname?: string,\n\tdescription?: string,\n): unknown {\n\t// Only objects can carry JSON Schema keywords. Anything else (incl.\n\t// `undefined` when no schema was supplied) passes through untouched.\n\tif (typeof schema !== \"object\" || schema === null || Array.isArray(schema)) {\n\t\treturn schema;\n\t}\n\n\tconst record = schema as Record<string, unknown>;\n\tconst addTitle = !!name && record.title === undefined;\n\tconst addDescription = !!description && record.description === undefined;\n\n\tif (!addTitle && !addDescription) {\n\t\treturn schema;\n\t}\n\n\treturn {\n\t\t...record,\n\t\t...(addTitle ? { title: name } : {}),\n\t\t...(addDescription ? { description } : {}),\n\t};\n}\n\n// ---------------------------------------------------------------------------\n// Tool preparation\n// ---------------------------------------------------------------------------\n\nexport function prepareToolsAndToolChoice(\n\ttools: Parameters<LanguageModelV3[\"doGenerate\"]>[0][\"tools\"],\n\ttoolChoice: Parameters<LanguageModelV3[\"doGenerate\"]>[0][\"toolChoice\"],\n) {\n\tif (tools == null) {\n\t\treturn { tool_choice: undefined, tools: undefined };\n\t}\n\n\tconst mappedTools = tools.map((tool) => ({\n\t\tfunction: {\n\t\t\tdescription: tool.type === \"function\" ? tool.description : undefined,\n\t\t\tname: tool.name,\n\t\t\tparameters: tool.type === \"function\" ? tool.inputSchema : undefined,\n\t\t},\n\t\ttype: \"function\",\n\t}));\n\n\tif (toolChoice == null) {\n\t\treturn { tool_choice: undefined, tools: mappedTools };\n\t}\n\n\tconst type = toolChoice.type;\n\n\tswitch (type) {\n\t\tcase \"auto\":\n\t\t\treturn { tool_choice: type, tools: mappedTools };\n\t\tcase \"none\":\n\t\t\treturn { tool_choice: type, tools: mappedTools };\n\t\tcase \"required\":\n\t\t\treturn { tool_choice: \"required\", tools: mappedTools };\n\n\t\t// Force a specific tool via the OpenAI-style named-function form.\n\t\t// Workers AI enforces this server-side, unlike \"required\" which is\n\t\t// advisory and \"fails open\" on long contexts / reasoning models (the\n\t\t// model can answer in prose instead of calling the tool). The full tool\n\t\t// list is kept (not filtered to the single function) to match OpenAI\n\t\t// semantics and preserve tool-result context fidelity.\n\t\t// See https://github.com/cloudflare/ai/issues/560.\n\t\tcase \"tool\":\n\t\t\treturn {\n\t\t\t\ttool_choice: { type: \"function\", function: { name: toolChoice.toolName } },\n\t\t\t\ttools: mappedTools,\n\t\t\t};\n\t\tdefault: {\n\t\t\tconst exhaustiveCheck = type satisfies never;\n\t\t\tthrow new Error(`Unsupported tool choice type: ${exhaustiveCheck}`);\n\t\t}\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Message helpers\n// ---------------------------------------------------------------------------\n\n// ---------------------------------------------------------------------------\n// Tool call processing\n// ---------------------------------------------------------------------------\n\nconst TOOL_CALL_ID_MARKER = \"::cf-wai-tool-call::\";\n\nexport function createAISDKToolCallId(toolCallId: string | null | undefined): string {\n\tconst originalId = toolCallId || generateId();\n\treturn `${originalId}${TOOL_CALL_ID_MARKER}${generateId()}`;\n}\n\nexport function toWorkersAIToolCallId(toolCallId: string): string {\n\tconst markerIndex = toolCallId.lastIndexOf(TOOL_CALL_ID_MARKER);\n\tif (markerIndex === -1) return toolCallId;\n\n\tconst suffixIndex = markerIndex + TOOL_CALL_ID_MARKER.length;\n\tif (suffixIndex >= toolCallId.length) return toolCallId;\n\n\treturn toolCallId.slice(0, markerIndex);\n}\n\n/** Workers AI flat tool call format (non-streaming, native) */\ninterface FlatToolCall {\n\tname: string;\n\targuments: unknown;\n\tid?: string;\n}\n\n/** Workers AI OpenAI-compatible tool call format */\ninterface OpenAIToolCall {\n\tid: string;\n\ttype: \"function\";\n\tfunction: {\n\t\tname: string;\n\t\targuments: unknown;\n\t};\n}\n\n/** Partial tool call from streaming (has index for merging) */\ninterface PartialToolCall {\n\tindex?: number;\n\tid?: string;\n\ttype?: string;\n\tfunction?: {\n\t\tname?: string;\n\t\targuments?: string;\n\t};\n\t// Flat format fields\n\tname?: string;\n\targuments?: string;\n}\n\nfunction mergePartialToolCalls(partialCalls: PartialToolCall[]) {\n\tconst mergedCallsByIndex: Record<\n\t\tnumber,\n\t\t{ function: { arguments: string; name: string }; id: string; type: string }\n\t> = {};\n\n\tfor (const partialCall of partialCalls) {\n\t\tconst index = partialCall.index ?? 0;\n\n\t\tif (!mergedCallsByIndex[index]) {\n\t\t\tmergedCallsByIndex[index] = {\n\t\t\t\tfunction: {\n\t\t\t\t\targuments: \"\",\n\t\t\t\t\tname: partialCall.function?.name || \"\",\n\t\t\t\t},\n\t\t\t\tid: partialCall.id || \"\",\n\t\t\t\ttype: partialCall.type || \"\",\n\t\t\t};\n\t\t} else {\n\t\t\tif (partialCall.id) {\n\t\t\t\tmergedCallsByIndex[index].id = partialCall.id;\n\t\t\t}\n\t\t\tif (partialCall.type) {\n\t\t\t\tmergedCallsByIndex[index].type = partialCall.type;\n\t\t\t}\n\t\t\tif (partialCall.function?.name) {\n\t\t\t\tmergedCallsByIndex[index].function.name = partialCall.function.name;\n\t\t\t}\n\t\t}\n\n\t\t// Append arguments if available (they arrive in order during streaming)\n\t\tif (partialCall.function?.arguments) {\n\t\t\tmergedCallsByIndex[index].function.arguments += partialCall.function.arguments;\n\t\t}\n\t}\n\n\treturn Object.values(mergedCallsByIndex);\n}\n\nfunction processToolCall(toolCall: FlatToolCall | OpenAIToolCall): LanguageModelV3ToolCall {\n\t// OpenAI format: has function.name (the key discriminator)\n\tconst fn =\n\t\t\"function\" in toolCall && typeof toolCall.function === \"object\" && toolCall.function\n\t\t\t? (toolCall.function as { name?: string; arguments?: unknown })\n\t\t\t: null;\n\n\tif (fn?.name) {\n\t\treturn {\n\t\t\tinput:\n\t\t\t\ttypeof fn.arguments === \"string\"\n\t\t\t\t\t? fn.arguments\n\t\t\t\t\t: JSON.stringify(fn.arguments || {}),\n\t\t\ttoolCallId: createAISDKToolCallId(toolCall.id),\n\t\t\ttype: \"tool-call\",\n\t\t\ttoolName: fn.name,\n\t\t};\n\t}\n\n\t// Flat format (native Workers AI non-streaming): has top-level name\n\tconst flat = toolCall as FlatToolCall;\n\treturn {\n\t\tinput:\n\t\t\ttypeof flat.arguments === \"string\"\n\t\t\t\t? flat.arguments\n\t\t\t\t: JSON.stringify(flat.arguments || {}),\n\t\ttoolCallId: createAISDKToolCallId(flat.id),\n\t\ttype: \"tool-call\",\n\t\ttoolName: flat.name,\n\t};\n}\n\nexport function processToolCalls(output: Record<string, unknown>): LanguageModelV3ToolCall[] {\n\tif (output.tool_calls && Array.isArray(output.tool_calls)) {\n\t\treturn output.tool_calls.map((toolCall: FlatToolCall | OpenAIToolCall) =>\n\t\t\tprocessToolCall(toolCall),\n\t\t);\n\t}\n\n\tconst choices = output.choices as\n\t\t| Array<{ message?: { tool_calls?: Array<FlatToolCall | OpenAIToolCall> } }>\n\t\t| undefined;\n\tif (choices?.[0]?.message?.tool_calls && Array.isArray(choices[0].message.tool_calls)) {\n\t\treturn choices[0].message.tool_calls.map((toolCall) => processToolCall(toolCall));\n\t}\n\n\treturn [];\n}\n\nexport function processPartialToolCalls(partialToolCalls: PartialToolCall[]) {\n\tconst mergedToolCalls = mergePartialToolCalls(partialToolCalls);\n\treturn processToolCalls({ tool_calls: mergedToolCalls });\n}\n\n// ---------------------------------------------------------------------------\n// Forced tool-call salvage (gpt-oss harmony quirk)\n// ---------------------------------------------------------------------------\n\n/**\n * Parse tool calls that a model leaked as JSON text instead of structured\n * `tool_calls`, assigning AI-SDK tool-call ids.\n *\n * The recovery logic (which JSON shapes count as a leaked call) lives in\n * `@cloudflare/gateway-core`; this wrapper only layers the framework id on each\n * neutral result so the existing `LanguageModelV3ToolCall` shape is preserved.\n */\nexport function parseLeakedToolCalls(\n\ttext: string,\n\tknownToolNames: Set<string>,\n): LanguageModelV3ToolCall[] {\n\treturn coreParseLeakedToolCalls(text, knownToolNames).map((call) => ({\n\t\tinput: call.input,\n\t\ttoolCallId: createAISDKToolCallId(undefined),\n\t\ttype: \"tool-call\",\n\t\ttoolName: call.toolName,\n\t}));\n}\n\n/**\n * Salvage a tool call that a model leaked into text content instead of the\n * structured `tool_calls` field.\n *\n * Workers AI's gpt-oss models (harmony format) sometimes emit a forced tool\n * call as raw JSON in `message.content` with an empty `tool_calls` array and\n * `finish_reason: \"stop\"` — typically when the forced tool is a poor fit for\n * the conversation. The content looks like one of:\n *\n * {\"name\":\"read_skill_resource\",\"path\":\"feedback.txt\"} (flat args)\n * {\"name\":\"calc\",\"arguments\":{\"a\":1}} (wrapped args)\n * [{\"name\":\"calc\",\"parameters\":{\"a\":1}}] (array form)\n *\n * This reinterprets that text as a structured tool call. It is intentionally\n * narrow to avoid false positives:\n * - only runs when a tool was *forced* (required / named-function), so a\n * tool call was explicitly demanded by the caller;\n * - only runs when there are no real structured tool calls to override;\n * - only matches JSON objects whose `name` is one of the requested tools.\n *\n * Returns the salvaged tool calls, or `null` when nothing was salvaged.\n *\n * See https://github.com/cloudflare/ai/issues/560.\n */\nexport function salvageToolCallsFromText(\n\toutput: Record<string, unknown>,\n\tcontext: {\n\t\ttools: Array<{ function: { name?: string } }> | undefined;\n\t\ttoolChoice: unknown;\n\t},\n): LanguageModelV3ToolCall[] | null {\n\tif (!isForcedToolChoice(context.toolChoice)) return null;\n\n\t// Never override real tool calls.\n\tif (processToolCalls(output).length > 0) return null;\n\n\tconst knownToolNames = getToolNames(context.tools);\n\tif (knownToolNames.size === 0) return null;\n\n\tconst text = processText(output);\n\tif (!text) return null;\n\n\tconst salvaged = parseLeakedToolCalls(text, knownToolNames);\n\treturn salvaged.length > 0 ? salvaged : null;\n}\n","import type { LanguageModelV3DataContent, LanguageModelV3Prompt } from \"@ai-sdk/provider\";\nimport { UnsupportedFunctionalityError } from \"@ai-sdk/provider\";\nimport { toWorkersAIToolCallId } from \"./utils\";\nimport type { WorkersAIContentPart, WorkersAIChatPrompt } from \"./workersai-chat-prompt\";\n\n/**\n * Normalise any LanguageModelV3DataContent value to a Uint8Array.\n *\n * Handles:\n * - Uint8Array → returned as-is\n * - string → decoded from base64 (with or without data-URL prefix)\n * - URL → not supported (Workers AI needs raw bytes, not a reference)\n */\nfunction toUint8Array(data: LanguageModelV3DataContent): Uint8Array | null {\n\tif (data instanceof Uint8Array) {\n\t\treturn data;\n\t}\n\n\tif (typeof data === \"string\") {\n\t\tlet base64 = data;\n\t\tif (base64.startsWith(\"data:\")) {\n\t\t\tconst commaIndex = base64.indexOf(\",\");\n\t\t\tif (commaIndex >= 0) {\n\t\t\t\tbase64 = base64.slice(commaIndex + 1);\n\t\t\t}\n\t\t}\n\t\tconst binaryString = atob(base64);\n\t\tconst bytes = new Uint8Array(binaryString.length);\n\t\tfor (let i = 0; i < binaryString.length; i++) {\n\t\t\tbytes[i] = binaryString.charCodeAt(i);\n\t\t}\n\t\treturn bytes;\n\t}\n\n\tif (data instanceof URL) {\n\t\tthrow new Error(\n\t\t\t\"URL image sources are not supported by Workers AI. \" +\n\t\t\t\t\"Provide image data as a Uint8Array or base64 string instead.\",\n\t\t);\n\t}\n\n\treturn null;\n}\n\nfunction assertImageMediaType(mediaType: string | undefined): string {\n\tif (!mediaType) {\n\t\tthrow new UnsupportedFunctionalityError({\n\t\t\tfunctionality: \"file-part-without-media-type\",\n\t\t\tmessage:\n\t\t\t\t\"Workers AI chat only supports image file parts with an image/* mediaType. \" +\n\t\t\t\t\"Received a file part without a mediaType.\",\n\t\t});\n\t}\n\n\t// Media types are case-insensitive (RFC 2045), so compare against a\n\t// lower-cased copy while preserving the caller's original casing on output.\n\tif (!mediaType.toLowerCase().startsWith(\"image/\")) {\n\t\tthrow new UnsupportedFunctionalityError({\n\t\t\tfunctionality: \"non-image-file-part\",\n\t\t\tmessage:\n\t\t\t\t\"Workers AI chat only supports image file parts with an image/* mediaType. \" +\n\t\t\t\t`Received mediaType \"${mediaType}\".`,\n\t\t});\n\t}\n\n\treturn mediaType;\n}\n\nfunction uint8ArrayToBase64(bytes: Uint8Array): string {\n\tlet binary = \"\";\n\tconst chunkSize = 8192;\n\tfor (let i = 0; i < bytes.length; i += chunkSize) {\n\t\tconst chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length));\n\t\tbinary += String.fromCharCode(...chunk);\n\t}\n\treturn btoa(binary);\n}\n\nexport function convertToWorkersAIChatMessages(prompt: LanguageModelV3Prompt): {\n\tmessages: WorkersAIChatPrompt;\n} {\n\tconst messages: WorkersAIChatPrompt = [];\n\n\tfor (const { role, content } of prompt) {\n\t\tswitch (role) {\n\t\t\tcase \"system\": {\n\t\t\t\tmessages.push({ content, role: \"system\" });\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"user\": {\n\t\t\t\tconst textParts: string[] = [];\n\t\t\t\tconst imageParts: { image: Uint8Array; mediaType: string }[] = [];\n\n\t\t\t\tfor (const part of content) {\n\t\t\t\t\tswitch (part.type) {\n\t\t\t\t\t\tcase \"text\": {\n\t\t\t\t\t\t\ttextParts.push(part.text);\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcase \"file\": {\n\t\t\t\t\t\t\tconst mediaType = assertImageMediaType(part.mediaType);\n\t\t\t\t\t\t\tconst imageBytes = toUint8Array(part.data);\n\t\t\t\t\t\t\tif (imageBytes) {\n\t\t\t\t\t\t\t\timageParts.push({\n\t\t\t\t\t\t\t\t\timage: imageBytes,\n\t\t\t\t\t\t\t\t\tmediaType,\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif (imageParts.length > 0) {\n\t\t\t\t\tconst contentArray: WorkersAIContentPart[] = [];\n\t\t\t\t\tif (textParts.length > 0) {\n\t\t\t\t\t\tcontentArray.push({ type: \"text\", text: textParts.join(\"\\n\") });\n\t\t\t\t\t}\n\t\t\t\t\tfor (const img of imageParts) {\n\t\t\t\t\t\tconst base64 = uint8ArrayToBase64(img.image);\n\t\t\t\t\t\tcontentArray.push({\n\t\t\t\t\t\t\ttype: \"image_url\",\n\t\t\t\t\t\t\timage_url: { url: `data:${img.mediaType};base64,${base64}` },\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t\tmessages.push({ content: contentArray, role: \"user\" });\n\t\t\t\t} else {\n\t\t\t\t\tmessages.push({ content: textParts.join(\"\\n\"), role: \"user\" });\n\t\t\t\t}\n\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"assistant\": {\n\t\t\t\tlet text = \"\";\n\t\t\t\tlet reasoning = \"\";\n\t\t\t\tconst toolCalls: Array<{\n\t\t\t\t\tid: string;\n\t\t\t\t\ttype: \"function\";\n\t\t\t\t\tfunction: { name: string; arguments: string };\n\t\t\t\t}> = [];\n\n\t\t\t\tfor (const part of content) {\n\t\t\t\t\tswitch (part.type) {\n\t\t\t\t\t\tcase \"text\": {\n\t\t\t\t\t\t\ttext += part.text;\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tcase \"reasoning\": {\n\t\t\t\t\t\t\t// Reasoning is accumulated separately and sent as the `reasoning`\n\t\t\t\t\t\t\t// field on the message object. This is the field name vLLM expects\n\t\t\t\t\t\t\t// on input for reasoning models (kimi-k2.7-code, glm-4.7-flash).\n\t\t\t\t\t\t\t// Concatenating it into `content` corrupts the conversation history\n\t\t\t\t\t\t\t// and causes models to produce empty or garbled responses on the\n\t\t\t\t\t\t\t// next turn.\n\t\t\t\t\t\t\treasoning += part.text;\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tcase \"file\": {\n\t\t\t\t\t\t\t// File parts in assistant messages - no action needed\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tcase \"tool-call\": {\n\t\t\t\t\t\t\ttoolCalls.push({\n\t\t\t\t\t\t\t\tfunction: {\n\t\t\t\t\t\t\t\t\targuments: JSON.stringify(part.input),\n\t\t\t\t\t\t\t\t\tname: part.toolName,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\tid: toWorkersAIToolCallId(part.toolCallId),\n\t\t\t\t\t\t\t\ttype: \"function\",\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tcase \"tool-result\": {\n\t\t\t\t\t\t\t// Tool results in assistant messages - no action needed\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tdefault: {\n\t\t\t\t\t\t\tconst exhaustiveCheck = part satisfies never;\n\t\t\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\t\t`Unsupported part type: ${(exhaustiveCheck as { type: string }).type}`,\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tmessages.push({\n\t\t\t\t\tcontent: text,\n\t\t\t\t\trole: \"assistant\",\n\t\t\t\t\t...(reasoning ? { reasoning } : {}),\n\t\t\t\t\ttool_calls:\n\t\t\t\t\t\ttoolCalls.length > 0\n\t\t\t\t\t\t\t? toolCalls.map(({ function: { name, arguments: args }, id }) => ({\n\t\t\t\t\t\t\t\t\tfunction: { arguments: args, name },\n\t\t\t\t\t\t\t\t\tid,\n\t\t\t\t\t\t\t\t\ttype: \"function\" as const,\n\t\t\t\t\t\t\t\t}))\n\t\t\t\t\t\t\t: undefined,\n\t\t\t\t});\n\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"tool\": {\n\t\t\t\tfor (const toolResponse of content) {\n\t\t\t\t\tif (toolResponse.type === \"tool-result\") {\n\t\t\t\t\t\tconst output = toolResponse.output;\n\t\t\t\t\t\tlet content: string;\n\t\t\t\t\t\tswitch (output.type) {\n\t\t\t\t\t\t\tcase \"text\":\n\t\t\t\t\t\t\tcase \"error-text\":\n\t\t\t\t\t\t\t\tcontent = output.value;\n\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t\tcase \"json\":\n\t\t\t\t\t\t\tcase \"error-json\":\n\t\t\t\t\t\t\t\tcontent = JSON.stringify(output.value);\n\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t\tcase \"execution-denied\":\n\t\t\t\t\t\t\t\tcontent = output.reason\n\t\t\t\t\t\t\t\t\t? `Tool execution denied: ${output.reason}`\n\t\t\t\t\t\t\t\t\t: \"Tool execution was denied.\";\n\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t\tcase \"content\":\n\t\t\t\t\t\t\t\tcontent = output.value\n\t\t\t\t\t\t\t\t\t.filter(\n\t\t\t\t\t\t\t\t\t\t(p): p is { type: \"text\"; text: string } =>\n\t\t\t\t\t\t\t\t\t\t\tp.type === \"text\",\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t.map((p) => p.text)\n\t\t\t\t\t\t\t\t\t.join(\"\\n\");\n\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t\t\tcontent = \"\";\n\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tmessages.push({\n\t\t\t\t\t\t\tcontent,\n\t\t\t\t\t\t\tname: toolResponse.toolName,\n\t\t\t\t\t\t\ttool_call_id: toWorkersAIToolCallId(toolResponse.toolCallId),\n\t\t\t\t\t\t\trole: \"tool\",\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t\t// Skip tool-approval-response parts as they're not supported by Workers AI\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tdefault: {\n\t\t\t\tconst exhaustiveCheck = role satisfies never;\n\t\t\t\tthrow new Error(`Unsupported role: ${exhaustiveCheck}`);\n\t\t\t}\n\t\t}\n\t}\n\n\treturn { messages };\n}\n","import type { LanguageModelV3Usage } from \"@ai-sdk/provider\";\n\n/**\n * Map Workers AI usage data to the AI SDK V3 usage format.\n * Accepts any object that may have a `usage` property with token counts.\n *\n * Workers AI mirrors the OpenAI usage shape, including\n * `prompt_tokens_details.cached_tokens` for prompt-cache hits. OpenAI-style\n * responses don't distinguish cache reads from cache writes, so we treat\n * `cached_tokens` as `cacheRead` and leave `cacheWrite` undefined.\n */\nexport function mapWorkersAIUsage(\n\toutput: Record<string, unknown> | AiTextGenerationOutput | AiTextToImageOutput,\n): LanguageModelV3Usage {\n\tconst usage = (\n\t\toutput as {\n\t\t\tusage?: {\n\t\t\t\tprompt_tokens?: number;\n\t\t\t\tcompletion_tokens?: number;\n\t\t\t\tprompt_tokens_details?: { cached_tokens?: number };\n\t\t\t};\n\t\t}\n\t).usage ?? {\n\t\tcompletion_tokens: 0,\n\t\tprompt_tokens: 0,\n\t};\n\n\tconst promptTokens = usage.prompt_tokens ?? 0;\n\tconst completionTokens = usage.completion_tokens ?? 0;\n\tconst cachedTokens = usage.prompt_tokens_details?.cached_tokens;\n\n\t// Clamp at 0 in case the provider ever reports cached_tokens > prompt_tokens;\n\t// the v3 spec expects non-negative counts.\n\tconst noCache =\n\t\tcachedTokens !== undefined ? Math.max(0, promptTokens - cachedTokens) : undefined;\n\n\treturn {\n\t\toutputTokens: {\n\t\t\ttotal: completionTokens,\n\t\t\ttext: undefined,\n\t\t\treasoning: undefined,\n\t\t},\n\t\tinputTokens: {\n\t\t\ttotal: promptTokens,\n\t\t\tnoCache,\n\t\t\tcacheRead: cachedTokens,\n\t\t\tcacheWrite: undefined,\n\t\t},\n\t\traw: { total: promptTokens + completionTokens },\n\t};\n}\n","import type { LanguageModelV3FinishReason } from \"@ai-sdk/provider\";\n\n/**\n * Map a Workers AI finish reason to the AI SDK unified finish reason.\n *\n * Accepts either:\n * - A raw finish reason string (e.g., \"stop\", \"tool_calls\")\n * - A full response object with finish_reason in various locations\n */\nexport function mapWorkersAIFinishReason(\n\tfinishReasonOrResponse: string | null | undefined | Record<string, unknown>,\n): LanguageModelV3FinishReason {\n\tlet finishReason: string | null | undefined;\n\n\tif (\n\t\ttypeof finishReasonOrResponse === \"string\" ||\n\t\tfinishReasonOrResponse === null ||\n\t\tfinishReasonOrResponse === undefined\n\t) {\n\t\tfinishReason = finishReasonOrResponse;\n\t} else if (typeof finishReasonOrResponse === \"object\" && finishReasonOrResponse !== null) {\n\t\tconst response = finishReasonOrResponse;\n\n\t\t// OpenAI format: { choices: [{ finish_reason: \"stop\" }] }\n\t\tconst choices = response.choices as Array<{ finish_reason?: string }> | undefined;\n\t\tif (Array.isArray(choices) && choices.length > 0) {\n\t\t\tfinishReason = choices[0].finish_reason;\n\t\t} else if (\"finish_reason\" in response) {\n\t\t\tfinishReason = response.finish_reason as string;\n\t\t} else {\n\t\t\tfinishReason = undefined;\n\t\t}\n\t} else {\n\t\t// Numbers, booleans, etc. -- default to stop\n\t\tfinishReason = undefined;\n\t}\n\n\tconst raw = finishReason ?? \"stop\";\n\n\tswitch (finishReason) {\n\t\tcase \"stop\":\n\t\t\treturn { unified: \"stop\", raw };\n\t\tcase \"length\":\n\t\tcase \"model_length\":\n\t\t\treturn { unified: \"length\", raw };\n\t\tcase \"tool_calls\":\n\t\t\treturn { unified: \"tool-calls\", raw };\n\t\tcase \"error\":\n\t\t\treturn { unified: \"error\", raw };\n\t\tcase \"other\":\n\t\tcase \"unknown\":\n\t\t\treturn { unified: \"other\", raw };\n\t\tdefault:\n\t\t\treturn { unified: \"stop\", raw };\n\t}\n}\n","import type {\n\tLanguageModelV3FinishReason,\n\tLanguageModelV3StreamPart,\n\tLanguageModelV3Usage,\n} from \"@ai-sdk/provider\";\nimport { SSEDecoder } from \"@cloudflare/gateway-core\";\nimport { generateId } from \"ai\";\nimport { mapWorkersAIFinishReason } from \"./map-workersai-finish-reason\";\nimport { mapWorkersAIUsage } from \"./map-workersai-usage\";\nimport {\n\tcreateAISDKToolCallId,\n\tgetToolNames,\n\tisForcedToolChoice,\n\tparseLeakedToolCalls,\n} from \"./utils\";\n\n/**\n * Prepend a stream-start event to an existing LanguageModelV3 stream.\n * Uses pipeThrough for proper backpressure and error propagation.\n */\nexport function prependStreamStart(\n\tsource: ReadableStream<LanguageModelV3StreamPart>,\n\twarnings: LanguageModelV3StreamPart extends { type: \"stream-start\" } ? never : unknown,\n): ReadableStream<LanguageModelV3StreamPart> {\n\tlet sentStart = false;\n\treturn source.pipeThrough(\n\t\tnew TransformStream<LanguageModelV3StreamPart, LanguageModelV3StreamPart>({\n\t\t\ttransform(chunk, controller) {\n\t\t\t\tif (!sentStart) {\n\t\t\t\t\tsentStart = true;\n\t\t\t\t\tcontroller.enqueue({\n\t\t\t\t\t\ttype: \"stream-start\",\n\t\t\t\t\t\twarnings: warnings as [],\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\tcontroller.enqueue(chunk);\n\t\t\t},\n\t\t\tflush(controller) {\n\t\t\t\tif (!sentStart) {\n\t\t\t\t\tcontroller.enqueue({\n\t\t\t\t\t\ttype: \"stream-start\",\n\t\t\t\t\t\twarnings: warnings as [],\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t},\n\t\t}),\n\t);\n}\n\n/**\n * Check if a streaming tool call chunk is a null-finalization sentinel.\n */\nfunction isNullFinalizationChunk(tc: Record<string, unknown>): boolean {\n\tconst fn = tc.function as Record<string, unknown> | undefined;\n\tconst name = fn?.name ?? tc.name ?? null;\n\tconst args = fn?.arguments ?? tc.arguments ?? null;\n\tconst id = tc.id ?? null;\n\treturn !id && !name && (!args || args === \"\");\n}\n\n/**\n * Maps a Workers AI SSE stream into AI SDK V3 LanguageModelV3StreamPart events.\n *\n * Uses a TransformStream pipeline for proper backpressure — chunks are emitted\n * one at a time as the downstream consumer pulls, not buffered eagerly.\n *\n * Handles two distinct formats:\n * 1. Native format: { response: \"chunk\", tool_calls: [...] }\n * 2. OpenAI format: { choices: [{ delta: { content: \"chunk\" } }] }\n */\nexport function getMappedStream(\n\tresponse: Response | ReadableStream<Uint8Array>,\n\tsalvageContext?: {\n\t\ttools: Array<{ function: { name?: string } }> | undefined;\n\t\ttoolChoice: unknown;\n\t},\n): ReadableStream<LanguageModelV3StreamPart> {\n\tconst rawStream =\n\t\tresponse instanceof ReadableStream\n\t\t\t? response\n\t\t\t: (response.body as ReadableStream<Uint8Array>);\n\n\tif (!rawStream) {\n\t\tthrow new Error(\"No readable stream available for SSE parsing.\");\n\t}\n\n\t// gpt-oss harmony quirk: a forced tool call can be streamed as `content`\n\t// text deltas instead of structured tool calls. When a tool was forced,\n\t// buffer the text content (rather than emitting it incrementally) so we can\n\t// reinterpret it as a tool call at flush time. Text is unexpected in forced\n\t// mode anyway, so buffering it does not regress a useful stream.\n\t// See https://github.com/cloudflare/ai/issues/560.\n\tconst knownToolNames = getToolNames(salvageContext?.tools);\n\tconst bufferContentForSalvage =\n\t\tisForcedToolChoice(salvageContext?.toolChoice) && knownToolNames.size > 0;\n\tlet contentBuffer = \"\";\n\tlet anyToolCallStarted = false;\n\n\t// State shared across the transform\n\tlet usage: LanguageModelV3Usage = {\n\t\toutputTokens: { total: 0, text: undefined, reasoning: undefined },\n\t\tinputTokens: {\n\t\t\ttotal: 0,\n\t\t\tnoCache: undefined,\n\t\t\tcacheRead: undefined,\n\t\t\tcacheWrite: undefined,\n\t\t},\n\t\traw: { totalTokens: 0 },\n\t};\n\tlet textId: string | null = null;\n\tlet reasoningId: string | null = null;\n\tlet finishReason: LanguageModelV3FinishReason | null = null;\n\tlet receivedDone = false;\n\tlet receivedAnyData = false;\n\n\t// Track tool call streaming state per index.\n\t// When we see the first chunk for a tool call index, we emit tool-input-start.\n\t// Subsequent argument deltas emit tool-input-delta.\n\t// tool-input-end is emitted eagerly when a new tool index starts or a null\n\t// finalization chunk arrives; any remaining open calls are closed in flush().\n\tconst activeToolCalls = new Map<number, { id: string; toolName: string; args: string }>();\n\tconst closedToolCalls = new Set<number>();\n\tlet lastActiveToolIndex: number | null = null;\n\n\t// Step 1: Decode bytes into SSE lines\n\tconst sseStream = rawStream.pipeThrough(new SSEDecoder());\n\n\t// Step 2: Transform SSE events into LanguageModelV3StreamPart\n\treturn sseStream.pipeThrough(\n\t\tnew TransformStream<string, LanguageModelV3StreamPart>({\n\t\t\ttransform(data, controller) {\n\t\t\t\tif (!data || data === \"[DONE]\") {\n\t\t\t\t\tif (data === \"[DONE]\") receivedDone = true;\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\treceivedAnyData = true;\n\t\t\t\tlet chunk: Record<string, unknown>;\n\t\t\t\ttry {\n\t\t\t\t\tchunk = JSON.parse(data);\n\t\t\t\t} catch {\n\t\t\t\t\tconsole.warn(\"[workers-ai-provider] failed to parse SSE event:\", data);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tif (chunk.usage) {\n\t\t\t\t\tusage = mapWorkersAIUsage(chunk as Parameters<typeof mapWorkersAIUsage>[0]);\n\t\t\t\t}\n\n\t\t\t\t// Extract finish_reason\n\t\t\t\tconst choices = chunk.choices as\n\t\t\t\t\t| Array<{\n\t\t\t\t\t\t\tfinish_reason?: string;\n\t\t\t\t\t\t\tdelta?: Record<string, unknown>;\n\t\t\t\t\t }>\n\t\t\t\t\t| undefined;\n\t\t\t\tconst choiceFinishReason = choices?.[0]?.finish_reason;\n\t\t\t\tconst directFinishReason = chunk.finish_reason as string | undefined;\n\n\t\t\t\tif (choiceFinishReason != null) {\n\t\t\t\t\tfinishReason = mapWorkersAIFinishReason(choiceFinishReason);\n\t\t\t\t} else if (directFinishReason != null) {\n\t\t\t\t\tfinishReason = mapWorkersAIFinishReason(directFinishReason);\n\t\t\t\t}\n\n\t\t\t\t// --- Native format: top-level `response` field ---\n\t\t\t\tconst nativeResponse = chunk.response;\n\t\t\t\tif (nativeResponse != null && nativeResponse !== \"\") {\n\t\t\t\t\tconst responseText = String(nativeResponse);\n\t\t\t\t\tif (responseText.length > 0) {\n\t\t\t\t\t\tif (bufferContentForSalvage) {\n\t\t\t\t\t\t\tcontentBuffer += responseText;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t// Close active reasoning block before text starts\n\t\t\t\t\t\t\tif (reasoningId) {\n\t\t\t\t\t\t\t\tcontroller.enqueue({ type: \"reasoning-end\", id: reasoningId });\n\t\t\t\t\t\t\t\treasoningId = null;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif (!textId) {\n\t\t\t\t\t\t\t\ttextId = generateId();\n\t\t\t\t\t\t\t\tcontroller.enqueue({ type: \"text-start\", id: textId });\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tcontroller.enqueue({\n\t\t\t\t\t\t\t\ttype: \"text-delta\",\n\t\t\t\t\t\t\t\tid: textId,\n\t\t\t\t\t\t\t\tdelta: responseText,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// --- Native format: top-level `tool_calls` ---\n\t\t\t\tif (Array.isArray(chunk.tool_calls)) {\n\t\t\t\t\t// Close active reasoning block before tool calls start\n\t\t\t\t\tif (reasoningId) {\n\t\t\t\t\t\tcontroller.enqueue({ type: \"reasoning-end\", id: reasoningId });\n\t\t\t\t\t\treasoningId = null;\n\t\t\t\t\t}\n\t\t\t\t\temitToolCallDeltas(chunk.tool_calls as Record<string, unknown>[], controller);\n\t\t\t\t}\n\n\t\t\t\t// --- OpenAI format: choices[0].delta ---\n\t\t\t\tif (choices?.[0]?.delta) {\n\t\t\t\t\tconst delta = choices[0].delta;\n\n\t\t\t\t\tconst reasoningDelta = (delta.reasoning_content ?? delta.reasoning) as\n\t\t\t\t\t\t| string\n\t\t\t\t\t\t| undefined;\n\t\t\t\t\tif (reasoningDelta && reasoningDelta.length > 0) {\n\t\t\t\t\t\tif (!reasoningId) {\n\t\t\t\t\t\t\treasoningId = generateId();\n\t\t\t\t\t\t\tcontroller.enqueue({\n\t\t\t\t\t\t\t\ttype: \"reasoning-start\",\n\t\t\t\t\t\t\t\tid: reasoningId,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}\n\t\t\t\t\t\tcontroller.enqueue({\n\t\t\t\t\t\t\ttype: \"reasoning-delta\",\n\t\t\t\t\t\t\tid: reasoningId,\n\t\t\t\t\t\t\tdelta: reasoningDelta,\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\n\t\t\t\t\tconst textDelta = delta.content as string | undefined;\n\t\t\t\t\tif (textDelta && textDelta.length > 0) {\n\t\t\t\t\t\tif (bufferContentForSalvage) {\n\t\t\t\t\t\t\tcontentBuffer += textDelta;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t// Close active reasoning block before text starts\n\t\t\t\t\t\t\tif (reasoningId) {\n\t\t\t\t\t\t\t\tcontroller.enqueue({ type: \"reasoning-end\", id: reasoningId });\n\t\t\t\t\t\t\t\treasoningId = null;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif (!textId) {\n\t\t\t\t\t\t\t\ttextId = generateId();\n\t\t\t\t\t\t\t\tcontroller.enqueue({ type: \"text-start\", id: textId });\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tcontroller.enqueue({\n\t\t\t\t\t\t\t\ttype: \"text-delta\",\n\t\t\t\t\t\t\t\tid: textId,\n\t\t\t\t\t\t\t\tdelta: textDelta,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tconst deltaToolCalls = delta.tool_calls as\n\t\t\t\t\t\t| Record<string, unknown>[]\n\t\t\t\t\t\t| undefined;\n\t\t\t\t\tif (Array.isArray(deltaToolCalls)) {\n\t\t\t\t\t\t// Close active reasoning block before tool calls start\n\t\t\t\t\t\tif (reasoningId) {\n\t\t\t\t\t\t\tcontroller.enqueue({ type: \"reasoning-end\", id: reasoningId });\n\t\t\t\t\t\t\treasoningId = null;\n\t\t\t\t\t\t}\n\t\t\t\t\t\temitToolCallDeltas(deltaToolCalls, controller);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t},\n\n\t\t\tflush(controller) {\n\t\t\t\t// Close any tool calls that weren't already closed during streaming\n\t\t\t\tfor (const [idx] of activeToolCalls) {\n\t\t\t\t\tif (closedToolCalls.has(idx)) continue;\n\t\t\t\t\tcloseToolCall(idx, controller);\n\t\t\t\t}\n\n\t\t\t\t// Close open reasoning block before any salvaged tool calls.\n\t\t\t\tif (reasoningId) {\n\t\t\t\t\tcontroller.enqueue({ type: \"reasoning-end\", id: reasoningId });\n\t\t\t\t}\n\n\t\t\t\t// Salvage a forced tool call that streamed as buffered text.\n\t\t\t\tlet salvagedToolCalls = false;\n\t\t\t\tif (bufferContentForSalvage && !anyToolCallStarted && contentBuffer.trim()) {\n\t\t\t\t\tconst salvaged = parseLeakedToolCalls(contentBuffer, knownToolNames);\n\t\t\t\t\tif (salvaged.length > 0) {\n\t\t\t\t\t\tfor (const call of salvaged) {\n\t\t\t\t\t\t\tcontroller.enqueue({\n\t\t\t\t\t\t\t\ttype: \"tool-input-start\",\n\t\t\t\t\t\t\t\tid: call.toolCallId,\n\t\t\t\t\t\t\t\ttoolName: call.toolName,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\tcontroller.enqueue({\n\t\t\t\t\t\t\t\ttype: \"tool-input-delta\",\n\t\t\t\t\t\t\t\tid: call.toolCallId,\n\t\t\t\t\t\t\t\tdelta: call.input,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\tcontroller.enqueue({ type: \"tool-input-end\", id: call.toolCallId });\n\t\t\t\t\t\t\tcontroller.enqueue(call);\n\t\t\t\t\t\t}\n\t\t\t\t\t\tsalvagedToolCalls = true;\n\t\t\t\t\t\t// Stream warnings are fixed at stream-start, so surface the\n\t\t\t\t\t\t// reinterpretation here for observability instead.\n\t\t\t\t\t\tconsole.warn(\n\t\t\t\t\t\t\t`[workers-ai-provider] Recovered ${salvaged.length} forced tool call(s) that the model streamed as text content instead of structured tool calls.`,\n\t\t\t\t\t\t);\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Not a recoverable tool call — emit the buffered text as-is.\n\t\t\t\t\t\tconst id = generateId();\n\t\t\t\t\t\tcontroller.enqueue({ type: \"text-start\", id });\n\t\t\t\t\t\tcontroller.enqueue({ type: \"text-delta\", id, delta: contentBuffer });\n\t\t\t\t\t\tcontroller.enqueue({ type: \"text-end\", id });\n\t\t\t\t\t}\n\t\t\t\t} else if (bufferContentForSalvage && contentBuffer.trim()) {\n\t\t\t\t\t// Real tool calls were present alongside buffered text — emit text.\n\t\t\t\t\tconst id = generateId();\n\t\t\t\t\tcontroller.enqueue({ type: \"text-start\", id });\n\t\t\t\t\tcontroller.enqueue({ type: \"text-delta\", id, delta: contentBuffer });\n\t\t\t\t\tcontroller.enqueue({ type: \"text-end\", id });\n\t\t\t\t}\n\n\t\t\t\tif (textId) {\n\t\t\t\t\tcontroller.enqueue({ type: \"text-end\", id: textId });\n\t\t\t\t}\n\n\t\t\t\t// Detect premature termination\n\t\t\t\tconst effectiveFinishReason = salvagedToolCalls\n\t\t\t\t\t? ({ unified: \"tool-calls\", raw: \"stop\" } as LanguageModelV3FinishReason)\n\t\t\t\t\t: !receivedDone && receivedAnyData && !finishReason\n\t\t\t\t\t\t? ({\n\t\t\t\t\t\t\t\tunified: \"error\",\n\t\t\t\t\t\t\t\traw: \"stream-truncated\",\n\t\t\t\t\t\t\t} as LanguageModelV3FinishReason)\n\t\t\t\t\t\t: (finishReason ?? { unified: \"stop\", raw: \"stop\" });\n\n\t\t\t\tcontroller.enqueue({\n\t\t\t\t\tfinishReason: effectiveFinishReason,\n\t\t\t\t\ttype: \"finish\",\n\t\t\t\t\tusage,\n\t\t\t\t});\n\t\t\t},\n\t\t}),\n\t);\n\n\t/**\n\t * Emit tool-input-end + tool-call for a tool call that is complete.\n\t */\n\tfunction closeToolCall(\n\t\tindex: number,\n\t\tcontroller: TransformStreamDefaultController<LanguageModelV3StreamPart>,\n\t) {\n\t\tconst tc = activeToolCalls.get(index);\n\t\tif (!tc || closedToolCalls.has(index)) return;\n\t\tclosedToolCalls.add(index);\n\t\tcontroller.enqueue({ type: \"tool-input-end\", id: tc.id });\n\t\tcontroller.enqueue({\n\t\t\ttype: \"tool-call\",\n\t\t\ttoolCallId: tc.id,\n\t\t\ttoolName: tc.toolName,\n\t\t\tinput: tc.args,\n\t\t});\n\t}\n\n\t/**\n\t * Emit incremental tool call events from streaming chunks.\n\t *\n\t * Workers AI streams tool calls as:\n\t * Chunk A: { id, type, index, function: { name } } — start\n\t * Chunk B: { index, function: { arguments: \"partial...\" } } — args delta\n\t * Chunk C: { index, function: { arguments: \"rest...