@@ -275,10 +275,6 @@ export function createGatewayFetch(
275275 *
276276 * The binding has strict schema validation that may differ from the OpenAI API:
277277 * - `content` must be a string (not null)
278- * - `tool_call_id` must match `[a-zA-Z0-9]{9}` pattern
279- *
280- * This function patches these fields so that the full tool-call round-trip works
281- * even though the binding's own generated IDs may not pass its validation.
282278 */
283279function normalizeMessagesForBinding (
284280 messages : Record < string , unknown > [ ] ,
@@ -291,45 +287,10 @@ function normalizeMessagesForBinding(
291287 normalized . content = "" ;
292288 }
293289
294- // Normalize tool_call_id on tool messages
295- if ( normalized . tool_call_id && typeof normalized . tool_call_id === "string" ) {
296- normalized . tool_call_id = sanitizeToolCallId ( normalized . tool_call_id ) ;
297- }
298-
299- // Normalize tool_calls[].id on assistant messages
300- if ( Array . isArray ( normalized . tool_calls ) ) {
301- normalized . tool_calls = ( normalized . tool_calls as Record < string , unknown > [ ] ) . map (
302- ( tc ) => {
303- if ( tc . id && typeof tc . id === "string" ) {
304- return { ...tc , id : sanitizeToolCallId ( tc . id ) } ;
305- }
306- return tc ;
307- } ,
308- ) ;
309- }
310-
311290 return normalized ;
312291 } ) ;
313292}
314293
315- /**
316- * Strip non-alphanumeric characters and ensure the ID is exactly 9 chars,
317- * matching Workers AI's `[a-zA-Z0-9]{9}` validation pattern.
318- *
319- * **Why this exists:** The Workers AI binding validates `tool_call_id` with
320- * a strict `[a-zA-Z0-9]{9}` regex, but it *generates* IDs like
321- * `chatcmpl-tool-875d3ec6179676ae` (with dashes, >9 chars). Those IDs are
322- * then rejected when sent back in a follow-up request. This is a known
323- * Workers AI issue — see workers-ai.md (Issue 3). Once the Workers AI team
324- * fixes the validation, this function becomes an idempotent no-op for
325- * IDs that already match the pattern.
326- */
327- function sanitizeToolCallId ( id : string ) : string {
328- const alphanumeric = id . replace ( / [ ^ a - z A - Z 0 - 9 ] / g, "" ) ;
329- // Pad with zeros if too short, truncate if too long
330- return alphanumeric . slice ( 0 , 9 ) . padEnd ( 9 , "0" ) ;
331- }
332-
333294/**
334295 * Creates a fetch function that intercepts OpenAI SDK requests and translates them
335296 * to Workers AI binding calls (env.AI.run). This allows the WorkersAiTextAdapter
@@ -422,7 +383,7 @@ export function createWorkersAiBindingFetch(
422383 arguments : unknown ;
423384 function ?: { name : string ; arguments ?: unknown } ;
424385 } ) => ( {
425- id : sanitizeToolCallId ( tc . id || crypto . randomUUID ( ) ) ,
386+ id : tc . id || crypto . randomUUID ( ) ,
426387 type : "function" ,
427388 function : {
428389 name : tc . function ?. name || tc . name || "" ,
@@ -479,9 +440,9 @@ function transformWorkersAiStream(
479440 // like Qwen3, Kimi K2.5 stream OpenAI-compatible SSE through the binding).
480441 // In that case, flush() should only emit [DONE] and skip the finish chunk.
481442 let isOpenAiFormat = false ;
482- // Track which tool call indices we've already emitted an `id` for,
483- // so subsequent argument deltas don't duplicate the id/type/name fields .
484- const emittedToolCallStart = new Set < number > ( ) ;
443+ // Track tool call state per index: store the generated/assigned ID so that
444+ // subsequent argument deltas use the same ID (matching the working streaming.ts pattern) .
445+ const toolCallState = new Map < number , { id : string ; name : string } > ( ) ;
485446
486447 return source . pipeThrough (
487448 new TransformStream < Uint8Array , Uint8Array > ( {
@@ -505,15 +466,26 @@ function transformWorkersAiStream(
505466 // directly through the binding, with `choices[].delta.content` and
506467 // optional `reasoning_content`. Detect this and pass through as-is.
507468 if ( parsed . choices !== undefined ) {
508- // Already OpenAI format — pass through with only tool_call_id
509- // sanitization for any tool calls present .
469+ // Already OpenAI format — pass through but ensure each tool call
470+ // index gets a unique, stable ID across all chunks .
510471 isOpenAiFormat = true ;
511472 const choice = parsed . choices ?. [ 0 ] ;
512473 if ( choice ?. delta ?. tool_calls ) {
513474 hasToolCalls = true ;
514475 for ( const tc of choice . delta . tool_calls ) {
515- if ( tc . id && typeof tc . id === "string" ) {
516- tc . id = sanitizeToolCallId ( tc . id ) ;
476+ const tcIndex = tc . index ?? 0 ;
477+ if ( ! toolCallState . has ( tcIndex ) ) {
478+ // First chunk for this index — generate/store unique ID
479+ const id = tc . id || `call${ streamId } ${ tcIndex } ` ;
480+ toolCallState . set ( tcIndex , {
481+ id,
482+ name : tc . function ?. name || "" ,
483+ } ) ;
484+ tc . id = id ;
485+ } else {
486+ // Subsequent chunk — reuse stored ID, remove id from delta
487+ // (OpenAI format only sends id in first chunk)
488+ delete tc . id ;
517489 }
518490 }
519491 }
@@ -572,13 +544,11 @@ function transformWorkersAiStream(
572544 index : tcIndex ,
573545 } ;
574546
575- if ( ! emittedToolCallStart . has ( tcIndex ) ) {
547+ if ( ! toolCallState . has ( tcIndex ) ) {
576548 // First chunk for this tool call index — emit id, type, name.
577- // Use sanitizeToolCallId so the ID survives round-trip through
578- // the binding's strict `[a-zA-Z0-9]{9}` validation.
579- emittedToolCallStart . add ( tcIndex ) ;
580- const rawId = tcId || `call${ streamId } ${ tcIndex } ` ;
581- toolCallDelta . id = sanitizeToolCallId ( rawId ) ;
549+ const id = tcId || `call${ streamId } ${ tcIndex } ` ;
550+ toolCallState . set ( tcIndex , { id, name : tcName || "" } ) ;
551+ toolCallDelta . id = id ;
582552 toolCallDelta . type = "function" ;
583553 toolCallDelta . function = {
584554 name : tcName || "" ,
0 commit comments