diff --git a/packages/ai/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts index cfffb95ce783..69ae01cd3b25 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -371,12 +371,13 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func return text })() const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined) + const cacheControl = options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined) const result = { role: "assistant" as const, - content: content.length === 0 ? null : ProviderShared.joinText(content), - tool_calls: toolCalls.length === 0 ? undefined : toolCalls, - reasoning_details: details, - cache_control: options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined), + content: content.length > 0 ? content.map((part) => part.text).join("") : toolCalls.length > 0 ? null : "", + ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}), + ...(details !== undefined ? { reasoning_details: details } : {}), + ...(cacheControl !== undefined ? { cache_control: cacheControl } : {}), } if (field === undefined || reasoningText === undefined) return result return { ...result, [field]: reasoningText } @@ -716,14 +717,13 @@ const step = (state: ParserState, event: OpenAIChatEvent) => return [{ ...state, usage }, events] as const } - const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined) + const reasoningField = state.reasoningField ?? reasoning?.field const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta) const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined const deltaMetadata = reasoningMetadata(reasoningField) const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text - if (!state.lifecycle.text.has("text-0") && text !== undefined) - lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata) + if (text !== undefined) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata) else if ( reasoningDetailsObserved && !lifecycle.reasoning.has("reasoning-0") && @@ -812,14 +812,18 @@ const step = (state: ParserState, event: OpenAIChatEvent) => const finishEvents = (state: ParserState): ReadonlyArray => { const events: LLMEvent[] = [] - const hasToolCalls = state.toolCallEvents.length > 0 + const toolCallEvents = + state.finishReason === undefined && Object.keys(state.tools).length > 0 + ? Effect.runSync(ToolStream.finishAll(ADAPTER, state.tools)).events + : state.toolCallEvents + const hasToolCalls = toolCallEvents.length > 0 const reason = state.finishReason ? { ...state.finishReason, normalized: state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized, } - : undefined + : { normalized: hasToolCalls ? ("tool-calls" as const) : ("unknown" as const) } const metadata = reasoningMetadata( state.reasoningField, state.reasoningDetailsObserved ? state.reasoningDetails : undefined, @@ -829,9 +833,9 @@ const finishEvents = (state: ParserState): ReadonlyArray => { ? Lifecycle.reasoningStart(state.lifecycle, events, "reasoning-0", reasoningMetadata(state.reasoningField)) : state.lifecycle const ended = Lifecycle.reasoningEnd(started, events, "reasoning-0", metadata) - const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended - events.push(...state.toolCallEvents) - if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage }) + const lifecycle = toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended + events.push(...toolCallEvents) + Lifecycle.finish(lifecycle, events, { reason, usage: state.usage }) return events } diff --git a/packages/ai/test/provider/openai-chat.test.ts b/packages/ai/test/provider/openai-chat.test.ts index 22992c8f7f16..6a0f7740ef60 100644 --- a/packages/ai/test/provider/openai-chat.test.ts +++ b/packages/ai/test/provider/openai-chat.test.ts @@ -102,6 +102,24 @@ describe("OpenAI Chat route", () => { }), ) + it.effect("concatenates assistant text parts without adding separators", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model, + messages: [ + Message.assistant([ + { type: "text", text: "Hello" }, + { type: "text", text: " world" }, + ]), + ], + }), + ) + + expect(prepared.body.messages).toEqual([{ role: "assistant", content: "Hello world" }]) + }), + ) + it.effect("writes reasoning to a configured custom field on every assistant message", () => Effect.gen(function* () { const prepared = yield* compileRequest( @@ -578,7 +596,7 @@ describe("OpenAI Chat route", () => { }), ) - expect(prepared.body.messages).toEqual([{ role: "assistant", content: null, reasoning_content: "hidden" }]) + expect(prepared.body.messages).toEqual([{ role: "assistant", content: "", reasoning_content: "hidden" }]) }), ) @@ -827,7 +845,7 @@ describe("OpenAI Chat route", () => { }), ) - it.effect("ignores scalar reasoning after content starts", () => + it.effect("preserves scalar reasoning after content starts", () => Effect.gen(function* () { const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }] const response = yield* LLMClient.generate(request).pipe( @@ -843,11 +861,11 @@ describe("OpenAI Chat route", () => { ), ) - expect(response.reasoning).toBe("detail") - expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1) - expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1) + expect(response.reasoning).toBe("detailscalar") + expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(2) + expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(2) expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({ - openai: { reasoningDetails: details }, + openai: { reasoningField: "reasoning", reasoningDetails: details }, }) }), ) @@ -947,7 +965,7 @@ describe("OpenAI Chat route", () => { expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1) const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] })) - expect(replay.body.messages).toEqual([{ role: "assistant", content: null, reasoning_details: details }]) + expect(replay.body.messages).toEqual([{ role: "assistant", content: "", reasoning_details: details }]) }), ) @@ -997,7 +1015,7 @@ describe("OpenAI Chat route", () => { ) expect(replay.body.messages).toEqual([ - { role: "assistant", content: null, reasoning: "firstsecond", reasoning_details: [first, second] }, + { role: "assistant", content: "", reasoning: "firstsecond", reasoning_details: [first, second] }, ]) }), ) @@ -1022,7 +1040,7 @@ describe("OpenAI Chat route", () => { ) expect(replay.body.messages).toEqual([ - { role: "assistant", content: null, reasoning_content: "AB", reasoning_details: [detail] }, + { role: "assistant", content: "", reasoning_content: "AB", reasoning_details: [detail] }, ]) }), ) @@ -1044,7 +1062,7 @@ describe("OpenAI Chat route", () => { ) expect(replay.body.messages).toEqual([ - { role: "assistant", content: null, reasoning_content: "thinking", reasoning_details: details }, + { role: "assistant", content: "", reasoning_content: "thinking", reasoning_details: details }, ]) }), ) @@ -1152,7 +1170,7 @@ describe("OpenAI Chat route", () => { }), ) - it.effect("fails a streamed tool call when the provider ends without a finish reason", () => + it.effect("finalizes a streamed tool call when the provider ends without a finish reason", () => Effect.gen(function* () { const body = sseEvents( deltaChunk({ @@ -1164,27 +1182,31 @@ describe("OpenAI Chat route", () => { const input = LLMRequest.update(request, { tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })], }) - const events: LLMEvent[] = [] - const streamError = yield* LLMClient.stream(input).pipe( - Stream.runForEach((event) => Effect.sync(() => events.push(event))), - Effect.flip, - Effect.provide(fixedResponse(body)), - ) - const error = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)), Effect.flip) + const response = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body))) - expect(events).toEqual([ + expect(response.events).toEqual([ { type: "step-start", index: 0 }, { type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, + { type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined }, + { + type: "tool-call", + id: "call_1", + name: "lookup", + input: { query: "weather" }, + providerExecuted: undefined, + providerMetadata: undefined, + }, + { + type: "step-finish", + index: 0, + reason: { normalized: "tool-calls" }, + usage: undefined, + providerMetadata: undefined, + }, + { type: "finish", reason: { normalized: "tool-calls" }, usage: undefined }, ]) - expect(events.filter(LLMEvent.is.toolCall)).toEqual([]) - expect(streamError.reason).toMatchObject({ - _tag: "InvalidProviderOutput", - classification: "incomplete-stream", - }) - expect(streamError.message).toContain("The provider response ended unexpectedly.") - expect(error.message).toContain("The provider response ended unexpectedly.") }), )