Browse Source

fix(ai): preserve compatible reasoning details (#37708)

Aiden Cline 3 tuần trước cách đây
mục cha
commit
8b5655ed53

+ 120 - 17
packages/ai/src/protocols/openai-chat.ts

@@ -77,6 +77,7 @@ const OpenAIChatMessage = Schema.Union([
     reasoning_content: Schema.optional(Schema.String),
     reasoning: Schema.optional(Schema.String),
     reasoning_text: Schema.optional(Schema.String),
+    reasoning_details: optionalArray(Schema.Unknown),
   }),
   Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
 ]).pipe(Schema.toTaggedUnion("role"))
@@ -149,6 +150,7 @@ const OpenAIChatDelta = Schema.Struct({
   reasoning_content: optionalNull(Schema.String),
   reasoning: optionalNull(Schema.String),
   reasoning_text: optionalNull(Schema.String),
+  reasoning_details: optionalNull(Schema.Array(Schema.Unknown)),
   tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
 })
 
@@ -178,6 +180,9 @@ export interface ParserState {
   readonly finishReason?: FinishReason
   readonly lifecycle: Lifecycle.State
   readonly reasoningField?: "reasoning" | "reasoning_content" | "reasoning_text"
+  readonly reasoningDetails: Array<unknown>
+  readonly reasoningDetailsObserved: boolean
+  readonly reasoningEmitted: boolean
 }
 
 // =============================================================================
@@ -223,7 +228,15 @@ const openAICompatibleReasoningContent = (native: unknown) =>
 const reasoningField = (part: ReasoningPart) => {
   const field = part.providerMetadata?.openai?.reasoningField
   if (field === "reasoning" || field === "reasoning_content" || field === "reasoning_text") return field
-  return "reasoning_content"
+}
+
+const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown) => {
+  const observed = parts.flatMap((part) => {
+    const details = part.providerMetadata?.openai?.reasoningDetails
+    return Array.isArray(details) ? details : []
+  })
+  if (parts.some((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))) return observed
+  if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
 }
 
 const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
@@ -267,19 +280,28 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
     }
   }
   const text = reasoning.map((part) => part.text).join("")
-  const field = reasoning[0] ? reasoningField(reasoning[0]) : "reasoning_content"
+  const details = reasoningDetails(reasoning, message.native?.openaiCompatible)
+  const observedField = reasoning.map(reasoningField).find((value) => value !== undefined)
+  const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible)
+  const fullyStructured = reasoning.every((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))
+  const field = (() => {
+    if (reasoning.length === 0) return
+    if (observedField !== undefined) return observedField
+    if (nativeReasoning !== undefined) return "reasoning_content"
+    if (!fullyStructured) return "reasoning_content"
+  })()
+  const reasoningContent = (() => {
+    if (reasoning.length === 0) return nativeReasoning
+    if (field === "reasoning_content") return text
+  })()
   return {
     role: "assistant" as const,
     content: content.length === 0 ? null : ProviderShared.joinText(content),
     tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
-    reasoning_content:
-      reasoning.length === 0
-        ? openAICompatibleReasoningContent(message.native?.openaiCompatible)
-        : field === "reasoning_content"
-          ? text
-          : undefined,
+    reasoning_content: reasoningContent,
     reasoning: reasoning.length > 0 && field === "reasoning" ? text : undefined,
     reasoning_text: reasoning.length > 0 && field === "reasoning_text" ? text : undefined,
+    reasoning_details: details,
   }
 })
 
@@ -430,6 +452,59 @@ const reasoningDelta = (delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null
   if (delta?.reasoning_text) return { field: "reasoning_text", text: delta.reasoning_text } as const
 }
 
+const detailText = (details: ReadonlyArray<unknown>) => {
+  const text = details.flatMap((detail) => {
+    if (!isRecord(detail)) return []
+    if (detail.type === "reasoning.text" && typeof detail.text === "string" && detail.text) return [detail.text]
+    if (detail.type === "reasoning.summary" && typeof detail.summary === "string" && detail.summary)
+      return [detail.summary]
+    return []
+  })
+  if (text.length > 0) return text.join("")
+}
+
+const appendReasoningDetails = (result: Array<unknown>, details: ReadonlyArray<unknown>) => {
+  for (const detail of details) {
+    const previous = result.at(-1)
+    if (
+      !isRecord(previous) ||
+      previous.type !== "reasoning.text" ||
+      !isRecord(detail) ||
+      detail.type !== "reasoning.text" ||
+      conflictingReasoningTextDetails(previous, detail)
+    ) {
+      result.push(detail)
+      continue
+    }
+    result[result.length - 1] = {
+      ...previous,
+      ...Object.fromEntries(Object.entries(detail).filter((entry) => entry[1] !== undefined)),
+      text: `${typeof previous.text === "string" ? previous.text : ""}${typeof detail.text === "string" ? detail.text : ""}`,
+      signature: mergeDetailValue(previous.signature, detail.signature),
+      format: mergeDetailValue(previous.format, detail.format),
+    }
+  }
+}
+
+const mergeDetailValue = (previous: unknown, current: unknown) =>
+  previous || current || (previous !== undefined ? previous : current)
+
+const conflictingReasoningTextDetails = (previous: Record<string, unknown>, current: Record<string, unknown>) =>
+  conflictingDetailValue(previous.id, current.id) ||
+  conflictingDetailValue(previous.index, current.index) ||
+  conflictingDetailValue(previous.format, current.format) ||
+  (Boolean(previous.signature) && Boolean(current.signature) && previous.signature !== current.signature)
+
+const conflictingDetailValue = (previous: unknown, current: unknown) =>
+  previous !== undefined && previous !== null && current !== undefined && current !== null && previous !== current
+
+const reasoningMetadata = (field: ParserState["reasoningField"], details?: ReadonlyArray<unknown>) => ({
+  openai: {
+    ...(field ? { reasoningField: field } : {}),
+    ...(details ? { reasoningDetails: details } : {}),
+  },
+})
+
 const step = (state: ParserState, event: OpenAIChatEvent) =>
   Effect.gen(function* () {
     const events: LLMEvent[] = []
@@ -444,19 +519,32 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
     let lifecycle = state.lifecycle
 
     const reasoning = reasoningDelta(delta)
-    const reasoningField = state.reasoningField ?? reasoning?.field
-    if (reasoning)
-      lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", reasoning.text, {
-        openai: { reasoningField: reasoningField ?? reasoning.field },
-      })
+    const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined)
+    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)
+    else if (
+      reasoningDetailsObserved &&
+      !lifecycle.reasoning.has("reasoning-0") &&
+      (Boolean(delta?.content) || toolDeltas.length > 0)
+    )
+      lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
+    const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
 
     if (delta?.content) {
-      lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
+      lifecycle = Lifecycle.reasoningEnd(
+        lifecycle,
+        events,
+        "reasoning-0",
+        reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
+      )
       lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
     }
 
-    if (toolDeltas.length) lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
-
     for (const tool of toolDeltas) {
       const current = tools[tool.index]
       const pending = pendingTools[tool.index]
@@ -503,6 +591,9 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
         finishReason,
         lifecycle,
         reasoningField,
+        reasoningDetails: state.reasoningDetails,
+        reasoningDetailsObserved,
+        reasoningEmitted,
       },
       events,
     ] as const
@@ -512,7 +603,16 @@ const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
   const events: LLMEvent[] = []
   const hasToolCalls = state.toolCallEvents.length > 0
   const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
-  const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
+  const metadata = reasoningMetadata(
+    state.reasoningField,
+    state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
+  )
+  const started =
+    state.reasoningDetailsObserved && !state.reasoningEmitted
+      ? 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 })
   return events
@@ -541,6 +641,9 @@ export const protocol = Protocol.make({
       toolCallEvents: [],
       lifecycle: Lifecycle.initial(),
       reasoningField: undefined,
+      reasoningDetails: [],
+      reasoningDetailsObserved: false,
+      reasoningEmitted: false,
     }),
     step,
     onHalt: finishEvents,

+ 1 - 1
packages/ai/src/protocols/utils/lifecycle.ts

@@ -44,7 +44,7 @@ export const reasoningDelta = (
   providerMetadata?: ProviderMetadata,
 ): State => {
   const started = reasoningStart(state, events, id, providerMetadata)
-  events.push(LLMEvent.reasoningDelta({ id, text }))
+  events.push(LLMEvent.reasoningDelta({ id, text, providerMetadata }))
   return started
 }
 

+ 25 - 7
packages/ai/src/providers/openrouter.ts

@@ -41,13 +41,31 @@ export const protocol = Protocol.make({
     schema: OpenRouterBody,
     from: (request) =>
       OpenAIChat.protocol.body.from(request).pipe(
-        Effect.map(
-          (body) =>
-            ({
-              ...body,
-              ...bodyOptions(request.providerOptions?.openrouter),
-            }) as OpenRouterBody,
-        ),
+        Effect.map((body) => {
+          const sourceAssistants = request.messages.filter((message) => message.role === "assistant")
+          let assistantIndex = 0
+          const messages = body.messages.map((message) => {
+            if (message.role !== "assistant") return message
+            const source = sourceAssistants[assistantIndex++]
+            const reasoning = source?.content
+              .filter((part) => part.type === "reasoning")
+              .map((part) => part.text)
+              .join("")
+            const reasoningDetails = Array.isArray(message.reasoning_details) ? message.reasoning_details : undefined
+            return {
+              ...message,
+              reasoning_content: undefined,
+              reasoning_text: undefined,
+              reasoning: reasoning && reasoningDetails && reasoningDetails.length > 0 ? reasoning : undefined,
+              reasoning_details: reasoningDetails,
+            }
+          })
+          return {
+            ...body,
+            messages,
+            ...bodyOptions(request.providerOptions?.openrouter),
+          } as OpenRouterBody
+        }),
       ),
   },
   stream: OpenAIChat.protocol.stream,

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 32 - 0
packages/ai/test/fixtures/recordings/openrouter-reasoning-tool-loop.json


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 32 - 0
packages/ai/test/fixtures/recordings/vercel-ai-gateway-reasoning-tool-loop.json


+ 54 - 1
packages/ai/test/provider/cloudflare.test.ts

@@ -1,7 +1,7 @@
 import { describe, expect } from "bun:test"
 import { ConfigProvider, Effect, Schema } from "effect"
 import { HttpClientRequest } from "effect/unstable/http"
-import { LLM } from "../../src"
+import { LLM, LLMEvent } from "../../src"
 import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare"
 import { LLMClient } from "../../src/route"
 import { it } from "../lib/effect"
@@ -83,6 +83,59 @@ describe("Cloudflare", () => {
     }),
   )
 
+  it.effect("preserves reasoning details for AI Gateway continuation", () =>
+    Effect.gen(function* () {
+      const model = CloudflareAIGateway.configure({
+        accountId: "test-account",
+        gatewayId: "test-gateway",
+        apiKey: "test-token",
+      }).model("anthropic/claude-sonnet-4.6")
+      const details = [
+        { type: "reasoning.text", text: "Think", format: "anthropic-claude-v1", index: 0 },
+        { type: "reasoning.text", text: "ing", format: "anthropic-claude-v1", index: 0 },
+        { type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
+      ]
+      const merged = [
+        {
+          type: "reasoning.text",
+          text: "Thinking",
+          signature: "signed",
+          format: "anthropic-claude-v1",
+          index: 0,
+        },
+      ]
+      const response = yield* LLM.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
+        Effect.provide(
+          dynamicResponse((input) =>
+            Effect.succeed(
+              input.respond(
+                sseEvents(
+                  deltaChunk({ reasoning: "Think", reasoning_details: [details[0]] }),
+                  deltaChunk({ reasoning: "ing", reasoning_details: [details[1]] }),
+                  deltaChunk({ reasoning_details: [details[2]] }),
+                  deltaChunk({ content: "Hello" }),
+                  deltaChunk({}, "stop"),
+                ),
+                { headers: { "content-type": "text/event-stream" } },
+              ),
+            ),
+          ),
+        ),
+      )
+
+      expect(response.reasoning).toBe("Thinking")
+      expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(2)
+      expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
+        openai: { reasoningField: "reasoning", reasoningDetails: merged },
+      })
+
+      const replay = yield* LLMClient.prepare(LLM.request({ model, messages: [response.message] }))
+      expect(replay.body.messages).toEqual([
+        { role: "assistant", content: "Hello", reasoning: "Thinking", reasoning_details: merged },
+      ])
+    }),
+  )
+
   it.effect("defaults AI Gateway id to default when omitted or blank", () =>
     Effect.gen(function* () {
       expect(

+ 78 - 3
packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts

@@ -1,10 +1,12 @@
 import { describe, expect } from "bun:test"
 import { Effect } from "effect"
-import { LLM, LLMEvent } from "../../src"
+import { LLM, LLMEvent, LLMResponse } from "../../src"
+import { OpenAIChat } from "../../src/protocols/openai-chat"
 import * as OpenAICompatible from "../../src/providers/openai-compatible"
 import * as OpenRouter from "../../src/providers/openrouter"
 import { LLMClient } from "../../src/route"
 import { recordedTests } from "../recorded-test"
+import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop } from "../recorded-scenarios"
 
 const cases = [
   {
@@ -15,6 +17,7 @@ const cases = [
     }).model("anthropic/claude-sonnet-4.6"),
     requires: ["OPENROUTER_API_KEY"],
     cassette: "openrouter-reasoning",
+    structured: true,
   },
   {
     name: "Vercel AI Gateway",
@@ -26,6 +29,7 @@ const cases = [
     }).model("anthropic/claude-sonnet-4.6"),
     requires: ["AI_GATEWAY_API_KEY"],
     cassette: "vercel-ai-gateway-reasoning",
+    structured: true,
   },
 ] as const
 
@@ -57,11 +61,82 @@ for (const item of cases) {
           expect(response.text.replaceAll(",", "").trim()).toBe("37887")
           expect(response.reasoning.length).toBeGreaterThan(0)
           expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
-          expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
-            openai: { reasoningField: "reasoning" },
+          const metadata = response.message.content.find((part) => part.type === "reasoning")?.providerMetadata
+          expect(metadata?.openai?.reasoningField).toBe(item.structured ? "reasoning" : "reasoning_content")
+          expect(Array.isArray(metadata?.openai?.reasoningDetails)).toBe(item.structured)
+          if (!item.structured) return
+          const details = metadata?.openai?.reasoningDetails
+          if (!Array.isArray(details)) return
+          expect(
+            details.some(
+              (detail) =>
+                typeof detail === "object" &&
+                detail !== null &&
+                "signature" in detail &&
+                typeof detail.signature === "string" &&
+                detail.signature.length > 0,
+            ),
+          ).toBe(true)
+
+          const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
+            LLM.request({ model: item.model, messages: [response.message] }),
+          )
+          expect(replay.body.messages).toMatchObject([
+            { role: "assistant", content: response.text, reasoning: response.reasoning },
+          ])
+          const replayDetails =
+            replay.body.messages[0]?.role === "assistant" ? replay.body.messages[0].reasoning_details : undefined
+          expect(Array.isArray(replayDetails)).toBe(true)
+          if (!Array.isArray(replayDetails)) return
+          expect(replayDetails).toEqual(details)
+          expect(replayDetails).toHaveLength(1)
+          expect(replayDetails[0]).toMatchObject({
+            type: "reasoning.text",
+            text: response.reasoning,
+            signature: expect.any(String),
           })
         }),
       30_000,
     )
+
+    recorded.effect.with(
+      "continues signed reasoning through a tool loop",
+      { cassette: `${item.cassette}-tool-loop`, tags: ["continuation", "tool", "tool-loop"] },
+      () =>
+        Effect.gen(function* () {
+          const events = yield* runWeatherToolLoop(
+            goldenWeatherToolLoopRequest({
+              id: `${item.cassette}-tool-loop`,
+              model: item.model,
+              maxTokens: 1536,
+              temperature: false,
+            }),
+          )
+
+          expectWeatherToolLoop(events)
+          expect(
+            LLMResponse.text({
+              events: events.slice(events.findIndex(LLMEvent.is.stepFinish) + 1),
+            }).trim(),
+          ).toMatch(/^Paris is sunny\.?$/)
+          const details = events
+            .filter(LLMEvent.is.reasoningEnd)
+            .map((event) => event.providerMetadata?.openai?.reasoningDetails)
+            .find(Array.isArray)
+          expect(Array.isArray(details)).toBe(item.structured)
+          if (!item.structured || !Array.isArray(details)) return
+          expect(
+            details.some(
+              (detail) =>
+                typeof detail === "object" &&
+                detail !== null &&
+                "signature" in detail &&
+                typeof detail.signature === "string" &&
+                detail.signature.length > 0,
+            ),
+          ).toBe(true)
+        }),
+      60_000,
+    )
   })
 }

+ 369 - 0
packages/ai/test/provider/openai-chat.test.ts

@@ -570,6 +570,375 @@ describe("OpenAI Chat route", () => {
     }),
   )
 
+  it.effect("preserves and replays reasoning details alongside scalar reasoning", () =>
+    Effect.gen(function* () {
+      const details = [
+        { type: "reasoning.text", text: "thinking", format: "anthropic-claude-v1", index: 0 },
+        { type: "reasoning.encrypted", data: "opaque", format: "anthropic-claude-v1", index: 1 },
+      ]
+      const response = yield* LLMClient.generate(
+        LLM.updateRequest(request, {
+          tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
+        }),
+      ).pipe(
+        Effect.provide(
+          fixedResponse(
+            sseEvents(
+              { choices: [{ delta: { reasoning: "thinking", reasoning_details: [details[0]] } }] },
+              { choices: [{ delta: { reasoning_details: [details[1]] } }] },
+              {
+                choices: [
+                  {
+                    delta: {
+                      tool_calls: [
+                        { index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query":"weather"}' } },
+                      ],
+                    },
+                    finish_reason: "tool_calls",
+                  },
+                ],
+              },
+            ),
+          ),
+        ),
+      )
+
+      expect(response.reasoning).toBe("thinking")
+      expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
+        openai: { reasoningField: "reasoning", reasoningDetails: details },
+      })
+
+      const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
+        LLM.request({ model, messages: [response.message] }),
+      )
+      expect(replay.body.messages).toEqual([
+        {
+          role: "assistant",
+          content: null,
+          reasoning: "thinking",
+          reasoning_details: details,
+          tool_calls: [
+            {
+              id: "call_1",
+              type: "function",
+              function: { name: "lookup", arguments: '{"query":"weather"}' },
+            },
+          ],
+        },
+      ])
+    }),
+  )
+
+  it.effect("uses reasoning details as display fallback without inventing a scalar replay field", () =>
+    Effect.gen(function* () {
+      const details = [
+        { type: "reasoning.summary", summary: "thinking", format: "openai-responses-v1", index: 0 },
+        { type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 1 },
+      ]
+      const response = yield* LLMClient.generate(request).pipe(
+        Effect.provide(
+          fixedResponse(
+            sseEvents(
+              { choices: [{ delta: { reasoning_details: [details[0]] } }] },
+              { choices: [{ delta: { reasoning_details: [details[1]] } }] },
+              { choices: [{ delta: { content: "Hello" } }] },
+              { choices: [{ delta: {}, finish_reason: "stop" }] },
+            ),
+          ),
+        ),
+      )
+
+      expect(response.reasoning).toBe("thinking")
+      expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
+        openai: { reasoningDetails: details },
+      })
+
+      const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
+        LLM.request({ model, messages: [response.message] }),
+      )
+      expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: details }])
+    }),
+  )
+
+  it.effect("preserves unknown reasoning details while using scalar display text", () =>
+    Effect.gen(function* () {
+      const details = [{ type: "reasoning.future", format: "provider-v2", state: { opaque: true } }]
+      const response = yield* LLMClient.generate(request).pipe(
+        Effect.provide(
+          fixedResponse(
+            sseEvents(
+              { choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
+              { choices: [{ delta: { content: "Hello" } }] },
+              { choices: [{ delta: {}, finish_reason: "stop" }] },
+            ),
+          ),
+        ),
+      )
+
+      expect(response.reasoning).toBe("thinking")
+      expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
+        openai: { reasoningField: "reasoning", reasoningDetails: details },
+      })
+
+      const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
+        LLM.request({ model, messages: [response.message] }),
+      )
+      expect(replay.body.messages).toEqual([
+        { role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: details },
+      ])
+    }),
+  )
+
+  it.effect("uses scalar display text for signature-only reasoning details", () =>
+    Effect.gen(function* () {
+      const details = [{ type: "reasoning.text", signature: "signed", format: "provider-v2", index: 0 }]
+      const response = yield* LLMClient.generate(request).pipe(
+        Effect.provide(
+          fixedResponse(
+            sseEvents(
+              { choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
+              { choices: [{ delta: { content: "Hello" } }] },
+              { choices: [{ delta: {}, finish_reason: "stop" }] },
+            ),
+          ),
+        ),
+      )
+
+      expect(response.reasoning).toBe("thinking")
+      expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
+        openai: { reasoningField: "reasoning", reasoningDetails: details },
+      })
+    }),
+  )
+
+  it.effect("ignores 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(
+        Effect.provide(
+          fixedResponse(
+            sseEvents(
+              { choices: [{ delta: { reasoning_details: details } }] },
+              { choices: [{ delta: { content: "Hello" } }] },
+              { choices: [{ delta: { reasoning: "scalar" } }] },
+              { choices: [{ delta: {}, finish_reason: "stop" }] },
+            ),
+          ),
+        ),
+      )
+
+      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.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
+        openai: { reasoningDetails: details },
+      })
+    }),
+  )
+
+  it.effect("preserves an explicitly empty reasoning details array", () =>
+    Effect.gen(function* () {
+      const response = yield* LLMClient.generate(request).pipe(
+        Effect.provide(
+          fixedResponse(
+            sseEvents(
+              { choices: [{ delta: { reasoning_details: [] } }] },
+              { choices: [{ delta: { content: "Hello" } }] },
+              { choices: [{ delta: {}, finish_reason: "stop" }] },
+            ),
+          ),
+        ),
+      )
+
+      expect(response.reasoning).toBe("")
+      expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
+        openai: { reasoningDetails: [] },
+      })
+
+      const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
+        LLM.request({ model, messages: [response.message] }),
+      )
+      expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: [] }])
+    }),
+  )
+
+  it.effect("attaches signature-only details that arrive after content", () =>
+    Effect.gen(function* () {
+      const details = [
+        { type: "reasoning.text", text: "thinking", format: "anthropic-claude-v1", index: 0 },
+        { type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
+      ]
+      const merged = [
+        {
+          type: "reasoning.text",
+          text: "thinking",
+          signature: "signed",
+          format: "anthropic-claude-v1",
+          index: 0,
+        },
+      ]
+      const response = yield* LLMClient.generate(request).pipe(
+        Effect.provide(
+          fixedResponse(
+            sseEvents(
+              { choices: [{ delta: { reasoning: "thinking", reasoning_details: [details[0]] } }] },
+              { choices: [{ delta: { content: "Hello" } }] },
+              { choices: [{ delta: { reasoning_details: [details[1]] } }] },
+              { choices: [{ delta: {}, finish_reason: "stop" }] },
+            ),
+          ),
+        ),
+      )
+
+      expect(response.reasoning).toBe("thinking")
+      expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
+      expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
+        openai: { reasoningField: "reasoning", reasoningDetails: merged },
+      })
+      expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
+      expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(1)
+      expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
+      expect(response.events.filter(LLMEvent.is.reasoningEnd).at(-1)?.providerMetadata).toEqual({
+        openai: { reasoningField: "reasoning", reasoningDetails: merged },
+      })
+      expect(response.events.findIndex(LLMEvent.is.reasoningEnd)).toBeLessThan(
+        response.events.findIndex(LLMEvent.is.textStart),
+      )
+
+      const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
+        LLM.request({ model, messages: [response.message] }),
+      )
+      expect(replay.body.messages).toEqual([
+        { role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: merged },
+      ])
+    }),
+  )
+
+  it.effect("preserves metadata-only reasoning when the stream ends", () =>
+    Effect.gen(function* () {
+      const details = [{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 0 }]
+      const response = yield* LLMClient.generate(request).pipe(
+        Effect.provide(
+          fixedResponse(
+            sseEvents(
+              { choices: [{ delta: { reasoning_details: details } }] },
+              { choices: [{ delta: {}, finish_reason: "stop" }] },
+            ),
+          ),
+        ),
+      )
+
+      expect(response.message.content).toEqual([
+        { type: "reasoning", text: "", providerMetadata: { openai: { reasoningDetails: details } } },
+      ])
+      expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
+      expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
+
+      const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
+        LLM.request({ model, messages: [response.message] }),
+      )
+      expect(replay.body.messages).toEqual([{ role: "assistant", content: null, reasoning_details: details }])
+    }),
+  )
+
+  it.effect("flushes details-only display reasoning when the stream ends", () =>
+    Effect.gen(function* () {
+      const details = [{ type: "reasoning.summary", summary: "summary", format: "openai-responses-v1", index: 0 }]
+      const response = yield* LLMClient.generate(request).pipe(
+        Effect.provide(
+          fixedResponse(
+            sseEvents(
+              { choices: [{ delta: { reasoning_details: details } }] },
+              { choices: [{ delta: {}, finish_reason: "stop" }] },
+            ),
+          ),
+        ),
+      )
+
+      expect(response.reasoning).toBe("summary")
+      expect(response.message.content).toEqual([
+        { type: "reasoning", text: "summary", providerMetadata: { openai: { reasoningDetails: details } } },
+      ])
+    }),
+  )
+
+  it.effect("replays details from multiple reasoning parts in order", () =>
+    Effect.gen(function* () {
+      const first = { type: "reasoning.text", text: "first", signature: "signed-0", index: 0 }
+      const second = { type: "reasoning.text", text: "second", signature: "signed-1", index: 1 }
+      const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
+        LLM.request({
+          model,
+          messages: [
+            Message.assistant([
+              {
+                type: "reasoning",
+                text: "first",
+                providerMetadata: { openai: { reasoningDetails: [first] } },
+              },
+              {
+                type: "reasoning",
+                text: "second",
+                providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: [second] } },
+              },
+            ]),
+          ],
+        }),
+      )
+
+      expect(replay.body.messages).toEqual([
+        { role: "assistant", content: null, reasoning: "firstsecond", reasoning_details: [first, second] },
+      ])
+    }),
+  )
+
+  it.effect("retains scalar replay for mixed structured reasoning parts", () =>
+    Effect.gen(function* () {
+      const detail = { type: "reasoning.encrypted", data: "opaque", index: 0 }
+      const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
+        LLM.request({
+          model,
+          messages: [
+            Message.assistant([
+              {
+                type: "reasoning",
+                text: "A",
+                providerMetadata: { openai: { reasoningDetails: [detail] } },
+              },
+              { type: "reasoning", text: "B" },
+            ]),
+          ],
+        }),
+      )
+
+      expect(replay.body.messages).toEqual([
+        { role: "assistant", content: null, reasoning_content: "AB", reasoning_details: [detail] },
+      ])
+    }),
+  )
+
+  it.effect("replays native scalar reasoning alongside native details", () =>
+    Effect.gen(function* () {
+      const details = [{ type: "reasoning.encrypted", data: "opaque", index: 0 }]
+      const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
+        LLM.request({
+          model,
+          messages: [
+            Message.make({
+              role: "assistant",
+              content: [{ type: "reasoning", text: "thinking" }],
+              native: { openaiCompatible: { reasoning_content: "thinking", reasoning_details: details } },
+            }),
+          ],
+        }),
+      )
+
+      expect(replay.body.messages).toEqual([
+        { role: "assistant", content: null, reasoning_content: "thinking", reasoning_details: details },
+      ])
+    }),
+  )
+
   it.effect("assembles streamed tool call input", () =>
     Effect.gen(function* () {
       const body = sseEvents(

+ 99 - 1
packages/ai/test/provider/openrouter.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect } from "bun:test"
 import { Effect } from "effect"
-import { LLM } from "../../src"
+import { LLM, Message } from "../../src"
 import { LLMClient } from "../../src/route"
 import * as OpenRouter from "../../src/providers/openrouter"
 import { it } from "../lib/effect"
@@ -53,4 +53,102 @@ describe("OpenRouter", () => {
       })
     }),
   )
+
+  it.effect("preserves manually supplied reasoning details", () =>
+    Effect.gen(function* () {
+      const details = [
+        { type: "reasoning.text", text: "Think", format: "anthropic-claude-v1", index: 0 },
+        { type: "reasoning.text", text: "ing", format: "anthropic-claude-v1", index: 0 },
+        { type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
+        { type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 1 },
+      ]
+      const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
+        LLM.request({
+          model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
+          messages: [
+            Message.assistant([
+              {
+                type: "reasoning",
+                text: "Thinking",
+                providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
+              },
+            ]),
+          ],
+        }),
+      )
+
+      expect(prepared.body.messages).toEqual([
+        {
+          role: "assistant",
+          content: null,
+          reasoning: "Thinking",
+          reasoning_details: details,
+        },
+      ])
+    }),
+  )
+
+  it.effect("preserves opaque and duplicate continuation details", () =>
+    Effect.gen(function* () {
+      const details = [
+        { type: "reasoning.future", format: "provider-v2", state: { opaque: true } },
+        { type: "reasoning.encrypted", id: "state", data: "opaque" },
+        { type: "reasoning.encrypted", id: "state", data: "opaque" },
+      ]
+      const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
+        LLM.request({
+          model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
+          messages: [
+            Message.assistant({
+              type: "reasoning",
+              text: "Thinking",
+              providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
+            }),
+          ],
+        }),
+      )
+
+      expect(prepared.body.messages).toEqual([
+        { role: "assistant", content: null, reasoning: "Thinking", reasoning_details: details },
+      ])
+    }),
+  )
+
+  it.effect("does not merge distinct adjacent reasoning text blocks", () =>
+    Effect.gen(function* () {
+      const details = [
+        { type: "reasoning.text", id: "first", index: 0, text: "A", opaque: "first" },
+        { type: "reasoning.text", id: "second", index: 1, text: "B", opaque: "second" },
+      ]
+      const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
+        LLM.request({
+          model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
+          messages: [
+            Message.assistant({
+              type: "reasoning",
+              text: "AB",
+              providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
+            }),
+          ],
+        }),
+      )
+
+      expect(prepared.body.messages).toEqual([
+        { role: "assistant", content: null, reasoning: "AB", reasoning_details: details },
+      ])
+    }),
+  )
+
+  it.effect("omits scalar reasoning without continuation details", () =>
+    Effect.gen(function* () {
+      const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
+        LLM.request({
+          model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
+          messages: [Message.assistant({ type: "reasoning", text: "Thinking" })],
+        }),
+      )
+
+      expect(prepared.body.messages).toEqual([{ role: "assistant", content: null }])
+    }),
+  )
 })

+ 2 - 23
packages/ai/test/recorded-scenarios.ts

@@ -120,29 +120,8 @@ export const runWeatherToolLoop = (request: LLMRequest) =>
     throw new Error("Weather tool loop exceeded 10 steps")
   })
 
-const assistantContent = (events: ReadonlyArray<LLMEvent>) => {
-  const content: ContentPart[] = []
-  for (const event of events) {
-    if (event.type === "text-delta" || event.type === "reasoning-delta") {
-      const type = event.type === "text-delta" ? "text" : "reasoning"
-      const last = content.at(-1)
-      if (last?.type === type) {
-        content[content.length - 1] = { ...last, text: `${last.text}${event.text}` }
-      } else {
-        content.push({ type, text: event.text })
-      }
-      continue
-    }
-    if (event.type === "text-end" || event.type === "reasoning-end") {
-      const type = event.type === "text-end" ? "text" : "reasoning"
-      const last = content.at(-1)
-      if (last?.type === type) content[content.length - 1] = { ...last, providerMetadata: event.providerMetadata }
-      continue
-    }
-    if (event.type === "tool-call") content.push(event)
-  }
-  return content
-}
+const assistantContent = (events: ReadonlyArray<LLMEvent>) =>
+  events.reduce(LLMResponse.reduce, LLMResponse.empty()).message.content
 
 export const expectFinish = (
   events: ReadonlyArray<LLMEvent>,

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác