Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline e82f68fafb fix(ai): parse compatible reasoning deltas 2026-07-18 03:26:10 +00:00
8 changed files with 599 additions and 13 deletions
+178 -9
View File
@@ -56,6 +56,8 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
})
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
type OpenAIChatReasoningDetail = Schema.Schema.Type<typeof JsonObject>
const OpenAIChatUserContent = Schema.Union([
Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }),
Schema.Struct({
@@ -75,6 +77,9 @@ const OpenAIChatMessage = Schema.Union([
content: Schema.NullOr(Schema.String),
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
reasoning_content: Schema.optional(Schema.String),
reasoning: Schema.optional(Schema.String),
reasoning_text: Schema.optional(Schema.String),
reasoning_details: optionalArray(JsonObject),
}),
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
]).pipe(Schema.toTaggedUnion("role"))
@@ -145,6 +150,9 @@ type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta
const OpenAIChatDelta = Schema.Struct({
content: optionalNull(Schema.String),
reasoning_content: optionalNull(Schema.String),
reasoning: optionalNull(Schema.String),
reasoning_text: optionalNull(Schema.String),
reasoning_details: optionalNull(Schema.Array(JsonObject)),
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
})
@@ -166,6 +174,8 @@ export interface ParserState {
readonly usage?: Usage
readonly finishReason?: FinishReason
readonly lifecycle: Lifecycle.State
readonly reasoningDetails: ReadonlyArray<OpenAIChatReasoningDetail>
readonly reasoningField?: NonNullable<ReturnType<typeof reasoningDelta>>["field"]
}
// =============================================================================
@@ -208,6 +218,27 @@ const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart
const openAICompatibleReasoningContent = (native: unknown) =>
isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined
const reasoningState = (part: ReasoningPart | ToolCallPart) => {
const state = part.providerMetadata?.openai
return isRecord(state) ? state : undefined
}
const reasoningField = (part: ReasoningPart) => {
const field = reasoningState(part)?.reasoningField
if (
field === "reasoning" ||
field === "reasoning_content" ||
field === "reasoning_text" ||
field === "reasoning_details"
)
return field
}
const reasoningDetails = (part: ReasoningPart | ToolCallPart) => {
const details = reasoningState(part)?.reasoningDetails
return Array.isArray(details) ? details.filter(isRecord) : []
}
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
const content: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
for (const part of message.content) {
@@ -248,14 +279,24 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
continue
}
}
const text = reasoning.map((part) => part.text).join("")
const field = reasoning.map(reasoningField).find((item) => item !== undefined) ?? "reasoning_content"
const details = message.content.flatMap((part) =>
part.type === "reasoning" || part.type === "tool-call" ? reasoningDetails(part) : [],
)
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
? reasoning.map((part) => part.text).join("")
: openAICompatibleReasoningContent(message.native?.openaiCompatible),
reasoning.length === 0
? openAICompatibleReasoningContent(message.native?.openaiCompatible)
: field === "reasoning_content"
? text
: undefined,
reasoning: reasoning.length > 0 && field === "reasoning" ? text : undefined,
reasoning_text: reasoning.length > 0 && field === "reasoning_text" ? text : undefined,
reasoning_details: details.length > 0 ? details : undefined,
}
})
@@ -400,6 +441,97 @@ const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
})
}
const reasoningDelta = (delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null | undefined) => {
if (delta?.reasoning_content) return { field: "reasoning_content", text: delta.reasoning_content } as const
if (delta?.reasoning) return { field: "reasoning", text: delta.reasoning } as const
if (delta?.reasoning_text) return { field: "reasoning_text", text: delta.reasoning_text } as const
const text = delta?.reasoning_details
?.flatMap((detail) => {
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 []
})
.join("")
return text ? ({ field: "reasoning_details", text } as const) : undefined
}
const reasoningMetadata = (
field: NonNullable<ReturnType<typeof reasoningDelta>>["field"],
details: ReadonlyArray<OpenAIChatReasoningDetail>,
) => ({
openai: {
reasoningField: field,
...(details.length > 0 ? { reasoningDetails: details } : {}),
},
})
const withEncryptedReasoningDetails = (
events: ReadonlyArray<LLMEvent>,
details: ReadonlyArray<OpenAIChatReasoningDetail>,
) => {
const encrypted = details.filter(
(detail) => detail.type === "reasoning.encrypted" && typeof detail.data === "string" && detail.data,
)
let attached = false
return events.map((event) => {
if (event.type !== "tool-call" || attached || encrypted.length === 0) return event
attached = true
const current = event.providerMetadata?.openai
return LLMEvent.toolCall({
...event,
providerMetadata: {
...event.providerMetadata,
openai: { ...(isRecord(current) ? current : {}), reasoningDetails: encrypted },
},
})
})
}
const mergeReasoningDetails = (
current: ReadonlyArray<OpenAIChatReasoningDetail>,
incoming: ReadonlyArray<OpenAIChatReasoningDetail>,
) => {
const result = [...current]
for (const detail of incoming) {
let index = result.findIndex((item) => {
if (item.type !== detail.type) return false
if (typeof item.id === "string" && typeof detail.id === "string") return item.id === detail.id
return typeof detail.index === "number" && item.index === detail.index
})
if (index === -1 && typeof detail.id !== "string" && typeof detail.index !== "number") {
const last = result.length - 1
if (result[last]?.type === detail.type) index = last
}
if (index === -1) {
result.push(detail)
continue
}
const previous = result[index]!
result[index] = {
...previous,
...detail,
...(typeof detail.signature === "string" && detail.signature
? { signature: detail.signature }
: typeof previous.signature === "string" && previous.signature
? { signature: previous.signature }
: {}),
...(typeof previous.format === "string" && previous.format
? { format: previous.format }
: typeof detail.format === "string" && detail.format
? { format: detail.format }
: {}),
...(typeof detail.text === "string"
? { text: `${typeof previous.text === "string" ? previous.text : ""}${detail.text}` }
: {}),
...(typeof detail.summary === "string"
? { summary: `${typeof previous.summary === "string" ? previous.summary : ""}${detail.summary}` }
: {}),
}
}
return result
}
const step = (state: ParserState, event: OpenAIChatEvent) =>
Effect.gen(function* () {
const events: LLMEvent[] = []
@@ -408,19 +540,46 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const finishReason = choice?.finish_reason ? mapFinishReason(choice.finish_reason) : state.finishReason
const delta = choice?.delta
const toolDeltas = delta?.tool_calls ?? []
const reasoningDetails = mergeReasoningDetails(state.reasoningDetails, delta?.reasoning_details ?? [])
let tools = state.tools
let lifecycle = state.lifecycle
if (delta?.reasoning_content)
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", delta.reasoning_content)
const reasoning = reasoningDelta(delta)
const reasoningField = state.reasoningField ?? reasoning?.field
const currentReasoningMetadata = reasoningField
? reasoningMetadata(
reasoningField,
reasoningDetails.filter((detail) => detail.type !== "reasoning.encrypted"),
)
: undefined
const completeReasoningMetadata = reasoningField ? reasoningMetadata(reasoningField, reasoningDetails) : undefined
if (reasoning) {
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", currentReasoningMetadata)
events.push(
LLMEvent.reasoningDelta({
id: "reasoning-0",
text: reasoning.text,
providerMetadata: currentReasoningMetadata,
}),
)
}
if (delta?.content) {
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0", completeReasoningMetadata)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
}
if (toolDeltas.length) lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
if (toolDeltas.length)
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0", currentReasoningMetadata)
if (finishReason !== undefined)
lifecycle = Lifecycle.reasoningEnd(
lifecycle,
events,
"reasoning-0",
toolDeltas.length > 0 || Object.keys(tools).length > 0 ? currentReasoningMetadata : completeReasoningMetadata,
)
for (const tool of toolDeltas) {
const result = ToolStream.appendOrStart(
@@ -446,10 +605,14 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
return [
{
tools: finished?.tools ?? tools,
toolCallEvents: finished?.events ?? state.toolCallEvents,
toolCallEvents: finished
? withEncryptedReasoningDetails(finished.events, reasoningDetails)
: state.toolCallEvents,
usage,
finishReason,
lifecycle,
reasoningDetails,
reasoningField,
},
events,
] as const
@@ -482,7 +645,13 @@ export const protocol = Protocol.make({
},
stream: {
event: Protocol.jsonEvent(OpenAIChatEvent),
initial: () => ({ tools: ToolStream.empty<number>(), toolCallEvents: [], lifecycle: Lifecycle.initial() }),
initial: () => ({
tools: ToolStream.empty<number>(),
toolCallEvents: [],
lifecycle: Lifecycle.initial(),
reasoningDetails: [],
reasoningField: undefined,
}),
step,
onHalt: finishEvents,
},
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,110 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, Message, ToolDefinition } from "../../src"
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"
const weather = ToolDefinition.make({
name: "get_weather",
description: "Get the weather for a city.",
inputSchema: {
type: "object",
properties: { city: { type: "string" } },
required: ["city"],
additionalProperties: false,
},
})
const openRouter = OpenRouter.configure({
apiKey: process.env.OPENROUTER_API_KEY ?? "fixture",
providerOptions: { openrouter: { reasoning: { max_tokens: 1024 } } },
}).model("anthropic/claude-sonnet-4.6")
const vercel = OpenAICompatible.configure({
provider: "vercel-ai-gateway",
baseURL: "https://ai-gateway.vercel.sh/v1",
apiKey: process.env.AI_GATEWAY_API_KEY ?? "fixture",
http: { body: { reasoning: { enabled: true, max_tokens: 1024 } } },
}).model("anthropic/claude-sonnet-4.6")
const cases = [
{
name: "OpenRouter",
model: openRouter,
requires: ["OPENROUTER_API_KEY"],
cassette: "openrouter-reasoning-details",
},
{
name: "Vercel AI Gateway",
model: vercel,
requires: ["AI_GATEWAY_API_KEY"],
cassette: "vercel-ai-gateway-reasoning-details",
},
] as const
for (const item of cases) {
const recorded = recordedTests({
prefix: "openai-compatible-chat",
provider: item.model.provider,
protocol: "openai-chat",
requires: item.requires,
tags: ["reasoning", "reasoning-details", "continuation"],
metadata: { model: item.model.id },
})
describe(`${item.name} reasoning details recorded`, () => {
recorded.effect.with(
"streams and preserves reasoning details",
{ cassette: item.cassette },
() =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: item.model,
system: "Think through the arithmetic, then reply with only the final integer.",
prompt: "What is 173 multiplied by 219?",
generation: { maxTokens: 1536, temperature: 0 },
}),
)
expect(response.text.replaceAll(",", "").trim()).toBe("37887")
expect(response.reasoning.length).toBeGreaterThan(0)
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
const reasoning = response.message.content.find((part) => part.type === "reasoning")
expect(reasoning?.providerMetadata?.openai?.reasoningField).toBe("reasoning")
const details = reasoning?.providerMetadata?.openai?.reasoningDetails
expect(Array.isArray(details)).toBe(true)
expect(
Array.isArray(details) &&
details.some(
(detail) =>
typeof detail === "object" &&
detail !== null &&
"type" in detail &&
detail.type === "reasoning.text" &&
"signature" in detail &&
typeof detail.signature === "string" &&
detail.signature.length > 0,
),
).toBe(true)
const tool = yield* LLMClient.generate(
LLM.request({
model: item.model,
system: "Call the requested tool exactly once.",
messages: [
Message.user("What is 173 multiplied by 219?"),
response.message,
Message.user("Call get_weather with city exactly Paris."),
],
tools: [weather],
generation: { maxTokens: 1536, temperature: 0 },
}),
)
expect(tool.toolCalls).toMatchObject([{ name: "get_weather", input: { city: "Paris" } }])
}),
30_000,
)
})
}
+185 -2
View File
@@ -92,6 +92,73 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("replays provider reasoning fields and structured details", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
LLM.request({
model,
messages: [
Message.assistant([
{
type: "reasoning",
text: "thinking",
providerMetadata: {
openai: {
reasoningField: "reasoning_text",
reasoningDetails: [{ type: "reasoning.text", text: "thinking", id: "reasoning-1" }],
},
},
},
ToolCallPart.make({
id: "call_1",
name: "lookup",
input: { query: "weather" },
providerMetadata: {
openai: {
reasoningDetails: [
{
type: "reasoning.encrypted",
id: "call_1",
data: "opaque",
format: "unknown",
provider_field: "preserved",
},
],
},
},
}),
]),
],
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: null,
tool_calls: [
{
id: "call_1",
type: "function",
function: { name: "lookup", arguments: '{"query":"weather"}' },
},
],
reasoning_text: "thinking",
reasoning_details: [
{ type: "reasoning.text", text: "thinking", id: "reasoning-1" },
{
type: "reasoning.encrypted",
id: "call_1",
data: "opaque",
format: "unknown",
provider_field: "preserved",
},
],
},
])
}),
)
it.effect("maps OpenAI provider options to Chat options", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
@@ -540,22 +607,72 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("parses OpenAI-compatible reasoning content deltas", () =>
it.effect("parses OpenAI-compatible reasoning deltas", () =>
Effect.gen(function* () {
const body = sseEvents(
{ choices: [{ delta: { reasoning_content: "thinking" } }] },
{ choices: [{ delta: { reasoning: " more" } }] },
{ choices: [{ delta: { reasoning_text: " deeply" } }] },
{
choices: [
{
delta: {
reasoning_details: [
{ type: "reasoning.text", text: " about", index: 0 },
{ type: "reasoning.summary", summary: " this", index: 1 },
{ type: "reasoning.encrypted", data: "opaque" },
],
},
},
],
},
{
choices: [
{
delta: {
reasoning_details: [
{
type: "reasoning.text",
text: "",
signature: "signature",
format: "anthropic-claude-v1",
index: 0,
},
],
},
},
],
},
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.reasoning).toBe("thinking")
expect(response.reasoning).toBe("thinking more deeply about this")
expect(response.text).toBe("Hello")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toMatchObject({
openai: {
reasoningDetails: [
{
type: "reasoning.text",
text: " about",
signature: "signature",
format: "anthropic-claude-v1",
index: 0,
},
{ type: "reasoning.summary", summary: " this", index: 1 },
{ type: "reasoning.encrypted", data: "opaque" },
],
},
})
expect(response.events).toMatchObject([
{ type: "step-start", index: 0 },
{ type: "reasoning-start", id: "reasoning-0" },
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
{ type: "reasoning-delta", id: "reasoning-0", text: " more" },
{ type: "reasoning-delta", id: "reasoning-0", text: " deeply" },
{ type: "reasoning-delta", id: "reasoning-0", text: " about this" },
{ type: "reasoning-end", id: "reasoning-0" },
{ type: "text-start", id: "text-0" },
{ type: "text-delta", id: "text-0", text: "Hello" },
@@ -566,6 +683,72 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("preserves encrypted reasoning details on the first tool call", () =>
Effect.gen(function* () {
const body = sseEvents(
{
choices: [
{
delta: {
reasoning_details: [{ type: "reasoning.encrypted", data: "opaque", format: "unknown" }],
},
},
],
},
deltaChunk({
role: "assistant",
tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: "{}" } }],
}),
deltaChunk({}, "tool_calls"),
)
const response = yield* LLMClient.generate(
LLM.updateRequest(request, {
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
providerMetadata: {
openai: {
reasoningDetails: [{ type: "reasoning.encrypted", data: "opaque", format: "unknown" }],
},
},
})
}),
)
it.effect("merges identity-less reasoning detail signatures", () =>
Effect.gen(function* () {
const body = sseEvents(
{ choices: [{ delta: { reasoning_details: [{ type: "reasoning.text", text: "think" }] } }] },
{ choices: [{ delta: { reasoning_details: [{ type: "reasoning.text", text: "ing" }] } }] },
{
choices: [
{
delta: {
reasoning_details: [
{ type: "reasoning.text", text: "", signature: "signature", format: "anthropic-claude-v1" },
],
},
},
],
},
{ choices: [{ delta: { content: "Hello" } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.reasoning).toBe("thinking")
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toMatchObject({
openai: {
reasoningDetails: [
{ type: "reasoning.text", text: "thinking", signature: "signature", format: "anthropic-claude-v1" },
],
},
})
}),
)
it.effect("assembles streamed tool call input", () =>
Effect.gen(function* () {
const body = sseEvents(
+2 -1
View File
@@ -35,6 +35,7 @@ type ReasoningOption =
| { readonly type: "budget_tokens"; readonly min?: number; readonly max?: number }
type Modality = "text" | "audio" | "image" | "video" | "pdf"
type InterleavedField = "reasoning" | "reasoning_content" | "reasoning_text" | "reasoning_details" | (string & {})
type SourceModel = {
readonly id: string
@@ -46,7 +47,7 @@ type SourceModel = {
readonly reasoning_options?: readonly ReasoningOption[]
readonly temperature?: boolean
readonly tool_call: boolean
readonly interleaved?: true | { readonly field: "reasoning" | "reasoning_content" | "reasoning_details" }
readonly interleaved?: true | { readonly field: InterleavedField }
readonly cost?: Cost
readonly limit: { readonly context: number; readonly input?: number; readonly output: number }
readonly modalities?: { readonly input: readonly Modality[]; readonly output: readonly Modality[] }
+5 -1
View File
@@ -4,6 +4,10 @@ import { Schema } from "effect"
import { PositiveInt } from "../../schema"
export const ModelStatus = Schema.Literals(["alpha", "beta", "deprecated", "active"])
const InterleavedField = Schema.Union([
Schema.Literals(["reasoning", "reasoning_content", "reasoning_text", "reasoning_details"]),
Schema.String,
])
export const Model = Schema.Struct({
id: Schema.optional(Schema.String),
@@ -18,7 +22,7 @@ export const Model = Schema.Struct({
Schema.Union([
Schema.Literal(true),
Schema.Struct({
field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]),
field: InterleavedField,
}),
]),
),
@@ -0,0 +1,11 @@
import { expect, test } from "bun:test"
import { ConfigProviderV1 } from "@opencode-ai/core/v1/config/provider"
import { Schema } from "effect"
const decode = Schema.decodeUnknownSync(ConfigProviderV1.Model)
test("accepts known and custom interleaved reasoning fields", () => {
const fields = ["reasoning", "reasoning_content", "reasoning_text", "reasoning_details", "vendor_reasoning"]
for (const field of fields) expect(decode({ interleaved: { field } }).interleaved).toEqual({ field })
})