From f985717d47cac1477fb2500214da08e8d9511091 Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Wed, 6 May 2026 18:09:52 -0400 Subject: [PATCH 1/2] fix(oracle): emit tool_calls in streaming responses The OCI streaming chunk transform discarded tool_calls when the provider sent them alongside the finishReason chunk, so agents using streamed tool-calling against OCI models received `finish_reason: "tool_calls"` with empty content and no tool data. Fix the streaming chunk transform to: - Track stable tool_call indices across chunks via OracleStreamState - Split the OCI tool_calls + finishReason chunk into a tool_calls delta followed by the finish-reason chunk and a [DONE] marker - Build the streaming delta object only with the fields actually present (role / content / tool_calls), matching OpenAI's wire shape Also propagate tool_calls and tool_call_id through the request transform and the non-streaming response transform so tool calling works end to end across stream and non-stream paths. Tests cover request shaping (toolCalls, toolCallId), non-stream extraction (with synthesised id fallback), and streaming chunk shape (tool_calls delta + finish chunk + DONE, plus index stability across multiple tool calls). Refs: OCI streaming tool-call bug surfaced via Strands Agents SDK. --- src/providers/oracle/chatComplete.test.ts | 235 ++++++++++++++++++++++ src/providers/oracle/chatComplete.ts | 157 +++++++++++++-- src/providers/oracle/types/ChatDetails.ts | 15 ++ 3 files changed, 387 insertions(+), 20 deletions(-) create mode 100644 src/providers/oracle/chatComplete.test.ts diff --git a/src/providers/oracle/chatComplete.test.ts b/src/providers/oracle/chatComplete.test.ts new file mode 100644 index 000000000..2327c5c48 --- /dev/null +++ b/src/providers/oracle/chatComplete.test.ts @@ -0,0 +1,235 @@ +import { + initOracleStreamState, + OracleChatCompleteResponseTransform, + OracleChatCompleteStreamChunkTransform, + OracleChatDetailsConfig, +} from './chatComplete'; +import { Message } from './types/ChatDetails'; + +const baseProviderOptions = { + oracleCompartmentId: 'ocid1.compartment.oc1..test', +} as any; + +const transformMessages = (messages: any[]): Message[] => { + const config = OracleChatDetailsConfig.messages as any; + return config.transform({ messages }, baseProviderOptions); +}; + +describe('Oracle Chat Complete — tool calls in request transform', () => { + it('attaches toolCalls to assistant messages', () => { + const out = transformMessages([ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { name: 'get_weather', arguments: '{"city":"NYC"}' }, + }, + ], + }, + ]); + + expect(out[0].toolCalls).toEqual([ + { + id: 'call_1', + type: 'FUNCTION', + name: 'get_weather', + arguments: '{"city":"NYC"}', + }, + ]); + }); + + it('attaches toolCallId to tool messages', () => { + const out = transformMessages([ + { + role: 'tool', + tool_call_id: 'call_1', + content: '{"temp":72}', + }, + ]); + + expect(out[0].toolCallId).toBe('call_1'); + expect(out[0].role).toBe('TOOL'); + }); + + it('does not attach toolCallId to non-tool messages', () => { + const out = transformMessages([{ role: 'user', content: 'hi' }]); + expect(out[0].toolCallId).toBeUndefined(); + }); +}); + +describe('Oracle Chat Complete — non-streaming response with tool_calls', () => { + it('extracts tool_calls into OpenAI shape', () => { + const response: any = { + modelId: 'meta.llama-3.3-70b-instruct', + chatResponse: { + timeCreated: '2026-01-01T00:00:00Z', + choices: [ + { + index: 0, + finishReason: 'tool_calls', + message: { + role: 'ASSISTANT', + content: [], + toolCalls: [ + { + id: 'call_abc', + name: 'get_weather', + arguments: '{"city":"NYC"}', + }, + ], + }, + }, + ], + usage: { + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + }, + }, + }; + + const result: any = OracleChatCompleteResponseTransform( + response, + 200, + new Headers() + ); + + expect(result.choices[0].message.tool_calls).toEqual([ + { + id: 'call_abc', + type: 'function', + function: { name: 'get_weather', arguments: '{"city":"NYC"}' }, + }, + ]); + expect(result.choices[0].finish_reason).toBe('tool_calls'); + }); + + it('synthesises an id when the provider omits it', () => { + const response: any = { + modelId: 'google.gemini-2.5-flash', + chatResponse: { + timeCreated: '2026-01-01T00:00:00Z', + choices: [ + { + index: 0, + finishReason: 'tool_calls', + message: { + role: 'ASSISTANT', + content: [], + toolCalls: [{ name: 'get_weather', arguments: '{}' }], + }, + }, + ], + usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 }, + }, + }; + + const result: any = OracleChatCompleteResponseTransform( + response, + 200, + new Headers() + ); + + expect(result.choices[0].message.tool_calls[0].id).toMatch(/^call_/); + }); +}); + +describe('Oracle Chat Complete — streaming chunk transform tool calls', () => { + const params: any = { model: 'meta.llama-3.3-70b-instruct' }; + + it('emits tool_calls delta separately from the finish chunk', () => { + const state = initOracleStreamState(); + + const sseChunk = + 'data: ' + + JSON.stringify({ + index: 0, + finishReason: 'tool_calls', + message: { + role: 'ASSISTANT', + content: [], + toolCalls: [ + { + id: 'call_xyz', + name: 'get_weather', + arguments: '{"city":"NYC"}', + }, + ], + }, + }); + + const out = OracleChatCompleteStreamChunkTransform( + sseChunk, + 'fallback-id', + state, + false, + params + ); + + expect(Array.isArray(out)).toBe(true); + const arr = out as string[]; + // Expect: [tool_calls delta, finish chunk, [DONE]] + expect(arr.length).toBe(3); + expect(arr[0]).toContain('"tool_calls"'); + expect(arr[0]).toContain('"call_xyz"'); + expect(arr[1]).toContain('"finish_reason":"tool_calls"'); + expect(arr[2]).toBe('data: [DONE]\n\n'); + }); + + it('preserves stable indices across multiple tool calls', () => { + const state = initOracleStreamState(); + + const sseChunk = + 'data: ' + + JSON.stringify({ + index: 0, + finishReason: 'tool_calls', + message: { + role: 'ASSISTANT', + content: [], + toolCalls: [ + { id: 'call_a', name: 'fn_a', arguments: '{}' }, + { id: 'call_b', name: 'fn_b', arguments: '{}' }, + ], + }, + }); + + const out = OracleChatCompleteStreamChunkTransform( + sseChunk, + 'fallback-id', + state, + false, + params + ) as string[]; + + const toolDelta = JSON.parse(out[0].replace(/^data: /, '')); + const calls = toolDelta.choices[0].delta.tool_calls; + expect(calls.map((c: any) => c.index)).toEqual([0, 1]); + expect(calls.map((c: any) => c.id)).toEqual(['call_a', 'call_b']); + }); + + it('returns DONE marker for [DONE] line', () => { + const out = OracleChatCompleteStreamChunkTransform( + 'data: [DONE]', + 'fallback-id', + initOracleStreamState(), + false, + params + ); + expect(out).toBe('data: [DONE]\n\n'); + }); + + it('skips ping events', () => { + const out = OracleChatCompleteStreamChunkTransform( + 'event: ping', + 'fallback-id', + initOracleStreamState(), + false, + params + ); + expect(out).toBeUndefined(); + }); +}); diff --git a/src/providers/oracle/chatComplete.ts b/src/providers/oracle/chatComplete.ts index 9a88aeea4..d6526f887 100644 --- a/src/providers/oracle/chatComplete.ts +++ b/src/providers/oracle/chatComplete.ts @@ -28,6 +28,22 @@ import { } from './types/GenericChatResponse'; import { openAIToOracleRoleMap, oracleToOpenAIRoleMap } from './utils'; +/** + * Stream state for tracking tool calls across streaming chunks. + * OCI sends tool calls in the message together with finishReason, so we need to + * track seen tool call IDs to assign stable indices for OpenAI-format deltas. + */ +export interface OracleStreamState { + currentToolCallIndex: number; + seenToolCallIds: Set; + finishReason?: string; +} + +export const initOracleStreamState = (): OracleStreamState => ({ + currentToolCallIndex: -1, + seenToolCallIds: new Set(), +}); + // transforms from openai format to oracle format for chat completions request export const OracleChatCompleteConfig: ProviderConfig = { model: [ @@ -127,10 +143,20 @@ export const OracleChatDetailsConfig: ProviderConfig = { } } } - transformedMessages.push({ + const transformedMessage: Message = { role, content, - }); + }; + + if (toolCalls.length > 0) { + transformedMessage.toolCalls = toolCalls; + } + + if (message.role === 'tool' && message.tool_call_id) { + transformedMessage.toolCallId = message.tool_call_id; + } + + transformedMessages.push(transformedMessage); } return transformedMessages; }, @@ -290,6 +316,21 @@ export const OracleChatCompleteResponseTransform: ( const content = choice.message?.content?.find( (item) => item.type === 'TEXT' )?.text; + + const toolCalls = (choice.message as any)?.toolCalls?.map( + (tc: ToolCall, index: number) => ({ + id: tc.id || `call_${Date.now().toString(36)}_${index}`, + type: 'function', + function: { + name: tc.name, + arguments: + typeof tc.arguments === 'string' + ? tc.arguments + : JSON.stringify(tc.arguments), + }, + }) + ); + return { index: choice.index, message: { @@ -297,6 +338,7 @@ export const OracleChatCompleteResponseTransform: ( choice.message.role as OracleMessageRole ], content, + ...(toolCalls && toolCalls.length > 0 && { tool_calls: toolCalls }), }, finish_reason: choice.finishReason, }; @@ -332,14 +374,14 @@ export const OracleChatCompleteResponseTransform: ( export const OracleChatCompleteStreamChunkTransform: ( response: string, fallbackId: string, - streamState: any, + streamState: OracleStreamState, _strictOpenAiCompliance: boolean, gatewayRequest: Params -) => string | undefined = ( +) => string | string[] | undefined = ( responseChunk, fallbackId, streamState, - strictOpenAiCompliance, + _strictOpenAiCompliance, gatewayRequest ) => { let chunk = responseChunk.trim(); @@ -350,17 +392,83 @@ export const OracleChatCompleteStreamChunkTransform: ( chunk = chunk.replace(/^data: /, ''); chunk = chunk.trim(); if (chunk === '[DONE]') { - return chunk; + return 'data: [DONE]\n\n'; } + + if (streamState.currentToolCallIndex === undefined) { + streamState.currentToolCallIndex = -1; + streamState.seenToolCallIds = new Set(); + } + const parsedChunk: ChatChoice = JSON.parse(chunk); if (parsedChunk.finishReason) { - return ( + streamState.finishReason = parsedChunk.finishReason; + } + + // Map OCI toolCalls to OpenAI tool_calls deltas with stable indices. + // OCI emits tool calls in the same chunk as finishReason, so we must split + // them across two SSE events: first the tool_calls delta, then the finish. + const rawToolCalls = (parsedChunk.message as any)?.toolCalls || []; + const toolCalls: Array<{ + index: number; + id: string; + type: string; + function: { name: string; arguments: string }; + }> = []; + + for (const tc of rawToolCalls) { + if (!streamState.seenToolCallIds.has(tc.id)) { + streamState.currentToolCallIndex++; + streamState.seenToolCallIds.add(tc.id); + } + const toolCallIndex = Array.from(streamState.seenToolCallIds).indexOf( + tc.id + ); + toolCalls.push({ + index: + toolCallIndex >= 0 ? toolCallIndex : streamState.currentToolCallIndex, + id: tc.id, + type: 'function', + function: { + name: tc.name, + arguments: + typeof tc.arguments === 'string' + ? tc.arguments + : JSON.stringify(tc.arguments), + }, + }); + } + + if (parsedChunk.finishReason) { + const chunks: string[] = []; + + if (toolCalls.length > 0) { + chunks.push( + `data: ${JSON.stringify({ + id: fallbackId, + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model: gatewayRequest.model || '', + provider: ORACLE, + choices: [ + { + index: 0, + delta: { tool_calls: toolCalls }, + finish_reason: null, + }, + ], + })}\n\n` + ); + } + + chunks.push( `data: ${JSON.stringify({ id: fallbackId, object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), model: gatewayRequest.model || '', + provider: ORACLE, choices: [ { index: 0, @@ -368,11 +476,27 @@ export const OracleChatCompleteStreamChunkTransform: ( finish_reason: parsedChunk.finishReason, }, ], - provider: ORACLE, - })}` + - '\n\n' + - 'data: [DONE]\n\n' + })}\n\n` ); + chunks.push('data: [DONE]\n\n'); + + return chunks; + } + + const content = parsedChunk.message?.content?.find( + (item) => item.type === 'TEXT' + )?.text; + + const delta: Record = {}; + if (parsedChunk.message?.role) { + delta.role = + oracleToOpenAIRoleMap[parsedChunk.message.role as OracleMessageRole]; + } + if (content) { + delta.content = content; + } + if (toolCalls.length > 0) { + delta.tool_calls = toolCalls; } return ( @@ -384,15 +508,8 @@ export const OracleChatCompleteStreamChunkTransform: ( provider: ORACLE, choices: [ { - index: parsedChunk.index, - delta: { - role: oracleToOpenAIRoleMap[ - parsedChunk.message.role as OracleMessageRole - ], - content: parsedChunk.message?.content?.find( - (item) => item.type === 'TEXT' - )?.text, - }, + index: parsedChunk.index ?? 0, + delta, }, ], })}` + '\n\n' diff --git a/src/providers/oracle/types/ChatDetails.ts b/src/providers/oracle/types/ChatDetails.ts index 8c564c225..3bbddaa0c 100644 --- a/src/providers/oracle/types/ChatDetails.ts +++ b/src/providers/oracle/types/ChatDetails.ts @@ -57,6 +57,21 @@ export interface Message { content?: Array; role: string; + + /** + * Tool calls made by the assistant (for ASSISTANT messages). + */ + toolCalls?: Array<{ + id: string; + type: string; + name: string; + arguments: string; + }>; + + /** + * The ID of the tool call this message is responding to (for TOOL messages). + */ + toolCallId?: string; } export interface StreamOptions { From b83acee052cb7797daf6771e7d2769b2c4013fdf Mon Sep 17 00:00:00 2001 From: Federico Kamelhar Date: Thu, 7 May 2026 11:50:20 -0400 Subject: [PATCH 2/2] fix(oracle): return single concatenated SSE payload, fix continuation index Two streaming-tool-call bugs surfaced during e2e testing against live OCI: 1) The chunk transform returned `string[]` for the finish chunk, but the gateway's `readStream` only handles single-string returns. The array was coerced to a string with comma separators, producing a malformed `,data: [DONE]` tail and eating the SSE separators around the finish chunk. Concatenate the tool_calls delta + finish + DONE into a single string with proper `\n\n` separation instead. 2) OCI streams tool calls progressively: the first chunk per tool carries `id` + `name`, subsequent chunks carry argument fragments without an `id`. The previous index-tracking code treated each id-less continuation as a new tool call (incrementing the index), so a single tool call's argument fragments were spread across indices 0, 1, 1, 1 instead of staying at 0. Anchor on `id` when present; otherwise reuse the current index for continuation fragments. Also drop `id` / `name` from the emitted delta when the provider chunk omits them, matching OpenAI's streaming shape. Tests updated for the single-string return shape and a new case that verifies continuation chunks stay attached to the same tool index when `id` is omitted. Verified e2e against `meta.llama-3.3-70b-instruct` on `us-chicago-1.oci.oraclecloud.com/20231130/actions/chat`: tool_calls deltas + finish + [DONE] now arrive as 7 well-formed SSE events with the tool index pinned at 0 across all continuation fragments. --- src/providers/oracle/chatComplete.test.ts | 91 +++++++++++++++++++---- src/providers/oracle/chatComplete.ts | 48 ++++++------ 2 files changed, 104 insertions(+), 35 deletions(-) diff --git a/src/providers/oracle/chatComplete.test.ts b/src/providers/oracle/chatComplete.test.ts index 2327c5c48..9c18f5f01 100644 --- a/src/providers/oracle/chatComplete.test.ts +++ b/src/providers/oracle/chatComplete.test.ts @@ -140,7 +140,24 @@ describe('Oracle Chat Complete — non-streaming response with tool_calls', () = describe('Oracle Chat Complete — streaming chunk transform tool calls', () => { const params: any = { model: 'meta.llama-3.3-70b-instruct' }; - it('emits tool_calls delta separately from the finish chunk', () => { + // Helper: split a concatenated SSE payload into individual event JSON objects. + const splitSse = (payload: string): { events: any[]; hasDone: boolean } => { + const events: any[] = []; + let hasDone = false; + for (const block of payload.split('\n\n')) { + const trimmed = block.trim(); + if (!trimmed.startsWith('data:')) continue; + const body = trimmed.slice('data:'.length).trim(); + if (body === '[DONE]') { + hasDone = true; + continue; + } + events.push(JSON.parse(body)); + } + return { events, hasDone }; + }; + + it('emits a tool_calls delta SSE then finish then [DONE] (bundled-finish chunk)', () => { const state = initOracleStreamState(); const sseChunk = @@ -167,19 +184,19 @@ describe('Oracle Chat Complete — streaming chunk transform tool calls', () => state, false, params - ); + ) as string; - expect(Array.isArray(out)).toBe(true); - const arr = out as string[]; - // Expect: [tool_calls delta, finish chunk, [DONE]] - expect(arr.length).toBe(3); - expect(arr[0]).toContain('"tool_calls"'); - expect(arr[0]).toContain('"call_xyz"'); - expect(arr[1]).toContain('"finish_reason":"tool_calls"'); - expect(arr[2]).toBe('data: [DONE]\n\n'); + const { events, hasDone } = splitSse(out); + expect(events.length).toBe(2); + expect(events[0].choices[0].delta.tool_calls[0].id).toBe('call_xyz'); + expect(events[0].choices[0].delta.tool_calls[0].function.name).toBe( + 'get_weather' + ); + expect(events[1].choices[0].finish_reason).toBe('tool_calls'); + expect(hasDone).toBe(true); }); - it('preserves stable indices across multiple tool calls', () => { + it('assigns stable indices when OCI bundles multiple tool calls', () => { const state = initOracleStreamState(); const sseChunk = @@ -203,14 +220,60 @@ describe('Oracle Chat Complete — streaming chunk transform tool calls', () => state, false, params - ) as string[]; + ) as string; - const toolDelta = JSON.parse(out[0].replace(/^data: /, '')); - const calls = toolDelta.choices[0].delta.tool_calls; + const { events } = splitSse(out); + const calls = events[0].choices[0].delta.tool_calls; expect(calls.map((c: any) => c.index)).toEqual([0, 1]); expect(calls.map((c: any) => c.id)).toEqual(['call_a', 'call_b']); }); + it('keeps continuation chunks attached to the same tool index when id is omitted', () => { + const state = initOracleStreamState(); + + const first = OracleChatCompleteStreamChunkTransform( + 'data: ' + + JSON.stringify({ + index: 0, + message: { + role: 'ASSISTANT', + content: [], + toolCalls: [ + { id: 'call_progressive', name: 'get_weather', arguments: '' }, + ], + }, + }), + 'fallback-id', + state, + false, + params + ) as string; + + const second = OracleChatCompleteStreamChunkTransform( + 'data: ' + + JSON.stringify({ + index: 0, + message: { + role: 'ASSISTANT', + content: [], + toolCalls: [{ arguments: '{"city":"' }], + }, + }), + 'fallback-id', + state, + false, + params + ) as string; + + const firstEvent = JSON.parse(first.split('\n\n')[0].slice('data:'.length)); + const secondEvent = JSON.parse( + second.split('\n\n')[0].slice('data:'.length) + ); + expect(firstEvent.choices[0].delta.tool_calls[0].index).toBe(0); + expect(secondEvent.choices[0].delta.tool_calls[0].index).toBe(0); + expect(secondEvent.choices[0].delta.tool_calls[0].id).toBeUndefined(); + }); + it('returns DONE marker for [DONE] line', () => { const out = OracleChatCompleteStreamChunkTransform( 'data: [DONE]', diff --git a/src/providers/oracle/chatComplete.ts b/src/providers/oracle/chatComplete.ts index d6526f887..3199d477e 100644 --- a/src/providers/oracle/chatComplete.ts +++ b/src/providers/oracle/chatComplete.ts @@ -377,7 +377,7 @@ export const OracleChatCompleteStreamChunkTransform: ( streamState: OracleStreamState, _strictOpenAiCompliance: boolean, gatewayRequest: Params -) => string | string[] | undefined = ( +) => string | undefined = ( responseChunk, fallbackId, streamState, @@ -407,44 +407,50 @@ export const OracleChatCompleteStreamChunkTransform: ( } // Map OCI toolCalls to OpenAI tool_calls deltas with stable indices. - // OCI emits tool calls in the same chunk as finishReason, so we must split - // them across two SSE events: first the tool_calls delta, then the finish. + // OCI may stream tool calls progressively across multiple chunks: the first + // chunk per tool carries `id` + `name`, subsequent chunks carry argument + // fragments without an `id`. Anchor on `id` when present; otherwise inherit + // the current index so continuation fragments stay attached to the right + // tool call. const rawToolCalls = (parsedChunk.message as any)?.toolCalls || []; const toolCalls: Array<{ index: number; - id: string; + id?: string; type: string; - function: { name: string; arguments: string }; + function: { name?: string; arguments: string }; }> = []; for (const tc of rawToolCalls) { - if (!streamState.seenToolCallIds.has(tc.id)) { - streamState.currentToolCallIndex++; - streamState.seenToolCallIds.add(tc.id); + let toolIndex = streamState.currentToolCallIndex; + if (tc.id) { + if (!streamState.seenToolCallIds.has(tc.id)) { + streamState.currentToolCallIndex++; + streamState.seenToolCallIds.add(tc.id); + } + toolIndex = Array.from(streamState.seenToolCallIds).indexOf(tc.id); } - const toolCallIndex = Array.from(streamState.seenToolCallIds).indexOf( - tc.id - ); toolCalls.push({ - index: - toolCallIndex >= 0 ? toolCallIndex : streamState.currentToolCallIndex, - id: tc.id, + index: toolIndex >= 0 ? toolIndex : 0, + ...(tc.id ? { id: tc.id } : {}), type: 'function', function: { - name: tc.name, + ...(tc.name ? { name: tc.name } : {}), arguments: typeof tc.arguments === 'string' ? tc.arguments - : JSON.stringify(tc.arguments), + : JSON.stringify(tc.arguments ?? ''), }, }); } + // Final chunk: emit (optional bundled tool_calls delta) + finish + DONE + // as a single concatenated SSE payload. (`readStream` only handles single + // strings, not arrays.) if (parsedChunk.finishReason) { - const chunks: string[] = []; + const parts: string[] = []; if (toolCalls.length > 0) { - chunks.push( + parts.push( `data: ${JSON.stringify({ id: fallbackId, object: 'chat.completion.chunk', @@ -462,7 +468,7 @@ export const OracleChatCompleteStreamChunkTransform: ( ); } - chunks.push( + parts.push( `data: ${JSON.stringify({ id: fallbackId, object: 'chat.completion.chunk', @@ -478,9 +484,9 @@ export const OracleChatCompleteStreamChunkTransform: ( ], })}\n\n` ); - chunks.push('data: [DONE]\n\n'); + parts.push('data: [DONE]\n\n'); - return chunks; + return parts.join(''); } const content = parsedChunk.message?.content?.find(