fix(core): preserve compatible reasoning replay

This commit is contained in:
Jack 2026-07-16 07:42:32 +00:00
commit b6208a8c50
16 changed files with 169 additions and 22 deletions

View file

@ -74,17 +74,13 @@ const assistant = (message: SessionMessage.Assistant, model: Model) => {
const content = message.content.flatMap((item): ContentPart[] => {
if (item.type === "text") return [{ type: "text", text: item.text }]
if (item.type === "reasoning")
return sameModel
? [
{
type: "reasoning",
text: item.text,
providerMetadata: reuseProviderMetadata ? item.providerMetadata : undefined,
},
]
: item.text.length > 0
? [{ type: "text", text: item.text }]
: []
return [
{
type: "reasoning",
text: item.text,
providerMetadata: reuseProviderMetadata ? item.providerMetadata : undefined,
},
]
const call = toolCall(item, reuseProviderMetadata ? item.provider?.metadata : undefined)
if (item.provider?.executed !== true) return [call]
const result = toolResult(

View file

@ -457,7 +457,7 @@ Recent work
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Visible thought" },
{ type: "reasoning", text: "Visible thought", providerMetadata: undefined },
{
type: "tool-call",
id: "hosted-old-model",

View file

@ -447,10 +447,15 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
continue
}
if (part.type === "reasoning") {
const signature = part.encrypted ?? signatureFromMetadata(part.providerMetadata)
if (!signature) {
content.push({ type: "text", text: part.text })
continue
}
content.push({
type: "thinking",
thinking: part.text,
signature: part.encrypted ?? signatureFromMetadata(part.providerMetadata),
signature,
})
continue
}

View file

@ -348,9 +348,14 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
continue
}
if (part.type === "reasoning") {
const signature = reasoningSignature(part)
if (!signature) {
content.push({ text: part.text })
continue
}
content.push({
reasoningContent: {
reasoningText: { text: part.text, signature: reasoningSignature(part) },
reasoningText: { text: part.text, signature },
},
})
continue

View file

@ -236,7 +236,8 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
continue
}
if (part.type === "reasoning") {
parts.push({ text: part.text, thought: true, thoughtSignature: thoughtSignature(part.providerMetadata) })
const signature = thoughtSignature(part.providerMetadata)
parts.push(signature ? { text: part.text, thought: true, thoughtSignature: signature } : { text: part.text })
continue
}
if (part.type === "tool-call") {

View file

@ -383,9 +383,12 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
continue
}
if (part.type === "reasoning") {
flushText()
const reasoning = lowerReasoning(part)
if (!reasoning) continue
if (!reasoning) {
content.push({ type: "text", text: part.text })
continue
}
flushText()
if (store !== false) {
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
reasoningReferences.add(reasoning.id)

View file

@ -362,6 +362,18 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("lowers unsigned foreign reasoning to assistant text", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare(
LLM.request({ model, messages: [Message.assistant({ type: "reasoning", text: "foreign thought" })] }),
)
expect(prepared.body).toMatchObject({
messages: [{ role: "assistant", content: [{ type: "text", text: "foreign thought" }] }],
})
}),
)
it.effect("parses text, reasoning, and usage stream fixtures", () =>
Effect.gen(function* () {
const body = sseEvents(

View file

@ -355,6 +355,20 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("lowers unsigned foreign reasoning to assistant text", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
LLM.request({
model,
messages: [Message.assistant({ type: "reasoning", text: "foreign thought" })],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([{ role: "assistant", content: [{ text: "foreign thought" }] }])
}),
)
it.effect("emits provider-error for throttlingException", () =>
Effect.gen(function* () {
const body = eventStreamBody(

View file

@ -431,6 +431,16 @@ describe("Gemini route", () => {
}),
)
it.effect("lowers unsigned foreign reasoning to assistant text", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
LLM.request({ model, messages: [Message.assistant({ type: "reasoning", text: "foreign thought" })] }),
)
expect(prepared.body.contents).toEqual([{ role: "model", parts: [{ text: "foreign thought" }] }])
}),
)
it.effect("emits streamed tool calls and maps finish reason", () =>
Effect.gen(function* () {
const body = sseEvents({

View file

@ -818,6 +818,34 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("lowers foreign reasoning without continuation state to assistant text", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
LLM.request({
model,
messages: [
Message.assistant([
{ type: "text", text: "before" },
{ type: "reasoning", text: "foreign thought" },
{ type: "text", text: "after" },
]),
],
}),
)
expect(prepared.body.input).toEqual([
{
role: "assistant",
content: [
{ type: "output_text", text: "before" },
{ type: "output_text", text: "foreign thought" },
{ type: "output_text", text: "after" },
],
},
])
}),
)
it.effect("streams each reasoning summary part as a separate block", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(

View file

@ -110,7 +110,7 @@ const messages = (input: readonly ModelMessage[]) => {
Message.make({
role: message.role,
content: content(message.content),
native: isRecord(message.providerOptions) ? { providerOptions: message.providerOptions } : undefined,
native: isRecord(message.providerOptions) ? message.providerOptions : undefined,
}),
]
})

View file

@ -243,6 +243,10 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
if (msg.info.role === "assistant") {
const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}`
const replaysForeignReasoning =
model.api.npm === "@ai-sdk/openai-compatible" &&
typeof model.capabilities.interleaved === "object" &&
model.capabilities.interleaved.field === "reasoning_content"
const media: Array<{ mime: string; url: string; filename?: string }> = []
if (
@ -360,7 +364,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
})
}
if (part.type === "reasoning") {
if (differentModel) {
if (differentModel && !replaysForeignReasoning) {
if (part.text.trim().length > 0)
assistantMessage.parts.push({
type: "text",
@ -371,7 +375,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
assistantMessage.parts.push({
type: "reasoning",
text: part.text,
providerMetadata: part.metadata,
...(differentModel ? {} : { providerMetadata: part.metadata }),
})
}
}

View file

@ -332,6 +332,39 @@ describe("session.llm-native.request", () => {
])
})
it.effect("preserves OpenAI-compatible reasoning fields through the native adapter", () =>
Effect.gen(function* () {
const compatible = {
...baseModel,
providerID: ProviderV2.ID.make("moonshotai"),
api: {
id: "kimi-k2.5",
url: "https://api.moonshot.test/v1",
npm: "@ai-sdk/openai-compatible",
},
capabilities: {
...baseModel.capabilities,
interleaved: { field: "reasoning_content" as const },
},
}
const prepared = yield* prepareNativeRequest({
model: compatible,
apiKey: "test-key",
messages: [
{
role: "assistant",
content: [{ type: "text", text: "answer" }],
providerOptions: { openaiCompatible: { reasoning_content: "thinking" } },
},
],
})
expect(prepared.body).toMatchObject({
messages: [{ role: "assistant", content: "answer", reasoning_content: "thinking" }],
})
}),
)
test("selects native request routes for provider packages", () => {
const openai = LLMNative.model({
model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/openai" } },

View file

@ -684,6 +684,42 @@ describe("session.message-v2.toModelMessage", () => {
])
})
test("preserves foreign reasoning for OpenAI-compatible reasoning fields", async () => {
const assistantID = "m-assistant"
const compatible = {
...model,
api: { ...model.api, npm: "@ai-sdk/openai-compatible" },
capabilities: { ...model.capabilities, reasoning: true, interleaved: { field: "reasoning_content" } },
} satisfies Provider.Model
const input: SessionV1.WithParts[] = [
{
info: assistantInfo(assistantID, "m-user", undefined, { providerID: "other", modelID: "other" }),
parts: [
{
...basePart(assistantID, "a1"),
type: "reasoning",
text: "thinking",
metadata: { anthropic: { signature: "foreign" } },
time: { start: 0 },
},
{
...basePart(assistantID, "a2"),
type: "text",
text: "answer",
},
] as SessionV1.Part[],
},
]
expect(ProviderTransform.message(await MessageV2.toModelMessages(input, compatible), compatible, {})).toEqual([
{
role: "assistant",
content: [{ type: "text", text: "answer" }],
providerOptions: { openaiCompatible: { reasoning_content: "thinking" } },
},
])
})
test("replaces compacted tool output with placeholder", async () => {
const userID = "m-user"
const assistantID = "m-assistant"