fix(ai): preserve compatible reasoning details (#37708)
This commit is contained in:
@@ -77,6 +77,7 @@ const OpenAIChatMessage = Schema.Union([
|
|||||||
reasoning_content: Schema.optional(Schema.String),
|
reasoning_content: Schema.optional(Schema.String),
|
||||||
reasoning: Schema.optional(Schema.String),
|
reasoning: Schema.optional(Schema.String),
|
||||||
reasoning_text: 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 }),
|
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
|
||||||
]).pipe(Schema.toTaggedUnion("role"))
|
]).pipe(Schema.toTaggedUnion("role"))
|
||||||
@@ -149,6 +150,7 @@ const OpenAIChatDelta = Schema.Struct({
|
|||||||
reasoning_content: optionalNull(Schema.String),
|
reasoning_content: optionalNull(Schema.String),
|
||||||
reasoning: optionalNull(Schema.String),
|
reasoning: optionalNull(Schema.String),
|
||||||
reasoning_text: optionalNull(Schema.String),
|
reasoning_text: optionalNull(Schema.String),
|
||||||
|
reasoning_details: optionalNull(Schema.Array(Schema.Unknown)),
|
||||||
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
|
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -178,6 +180,9 @@ export interface ParserState {
|
|||||||
readonly finishReason?: FinishReason
|
readonly finishReason?: FinishReason
|
||||||
readonly lifecycle: Lifecycle.State
|
readonly lifecycle: Lifecycle.State
|
||||||
readonly reasoningField?: "reasoning" | "reasoning_content" | "reasoning_text"
|
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 reasoningField = (part: ReasoningPart) => {
|
||||||
const field = part.providerMetadata?.openai?.reasoningField
|
const field = part.providerMetadata?.openai?.reasoningField
|
||||||
if (field === "reasoning" || field === "reasoning_content" || field === "reasoning_text") return field
|
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) {
|
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 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 {
|
return {
|
||||||
role: "assistant" as const,
|
role: "assistant" as const,
|
||||||
content: content.length === 0 ? null : ProviderShared.joinText(content),
|
content: content.length === 0 ? null : ProviderShared.joinText(content),
|
||||||
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
|
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
|
||||||
reasoning_content:
|
reasoning_content: reasoningContent,
|
||||||
reasoning.length === 0
|
|
||||||
? openAICompatibleReasoningContent(message.native?.openaiCompatible)
|
|
||||||
: field === "reasoning_content"
|
|
||||||
? text
|
|
||||||
: undefined,
|
|
||||||
reasoning: reasoning.length > 0 && field === "reasoning" ? text : undefined,
|
reasoning: reasoning.length > 0 && field === "reasoning" ? text : undefined,
|
||||||
reasoning_text: reasoning.length > 0 && field === "reasoning_text" ? 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
|
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) =>
|
const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
@@ -444,19 +519,32 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
let lifecycle = state.lifecycle
|
let lifecycle = state.lifecycle
|
||||||
|
|
||||||
const reasoning = reasoningDelta(delta)
|
const reasoning = reasoningDelta(delta)
|
||||||
const reasoningField = state.reasoningField ?? reasoning?.field
|
const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined)
|
||||||
if (reasoning)
|
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
|
||||||
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", reasoning.text, {
|
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
|
||||||
openai: { reasoningField: reasoningField ?? reasoning.field },
|
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) {
|
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)
|
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toolDeltas.length) lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
|
|
||||||
|
|
||||||
for (const tool of toolDeltas) {
|
for (const tool of toolDeltas) {
|
||||||
const current = tools[tool.index]
|
const current = tools[tool.index]
|
||||||
const pending = pendingTools[tool.index]
|
const pending = pendingTools[tool.index]
|
||||||
@@ -503,6 +591,9 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
finishReason,
|
finishReason,
|
||||||
lifecycle,
|
lifecycle,
|
||||||
reasoningField,
|
reasoningField,
|
||||||
|
reasoningDetails: state.reasoningDetails,
|
||||||
|
reasoningDetailsObserved,
|
||||||
|
reasoningEmitted,
|
||||||
},
|
},
|
||||||
events,
|
events,
|
||||||
] as const
|
] as const
|
||||||
@@ -512,7 +603,16 @@ const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
|||||||
const events: LLMEvent[] = []
|
const events: LLMEvent[] = []
|
||||||
const hasToolCalls = state.toolCallEvents.length > 0
|
const hasToolCalls = state.toolCallEvents.length > 0
|
||||||
const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
|
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)
|
events.push(...state.toolCallEvents)
|
||||||
if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
|
if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
|
||||||
return events
|
return events
|
||||||
@@ -541,6 +641,9 @@ export const protocol = Protocol.make({
|
|||||||
toolCallEvents: [],
|
toolCallEvents: [],
|
||||||
lifecycle: Lifecycle.initial(),
|
lifecycle: Lifecycle.initial(),
|
||||||
reasoningField: undefined,
|
reasoningField: undefined,
|
||||||
|
reasoningDetails: [],
|
||||||
|
reasoningDetailsObserved: false,
|
||||||
|
reasoningEmitted: false,
|
||||||
}),
|
}),
|
||||||
step,
|
step,
|
||||||
onHalt: finishEvents,
|
onHalt: finishEvents,
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export const reasoningDelta = (
|
|||||||
providerMetadata?: ProviderMetadata,
|
providerMetadata?: ProviderMetadata,
|
||||||
): State => {
|
): State => {
|
||||||
const started = reasoningStart(state, events, id, providerMetadata)
|
const started = reasoningStart(state, events, id, providerMetadata)
|
||||||
events.push(LLMEvent.reasoningDelta({ id, text }))
|
events.push(LLMEvent.reasoningDelta({ id, text, providerMetadata }))
|
||||||
return started
|
return started
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,13 +41,31 @@ export const protocol = Protocol.make({
|
|||||||
schema: OpenRouterBody,
|
schema: OpenRouterBody,
|
||||||
from: (request) =>
|
from: (request) =>
|
||||||
OpenAIChat.protocol.body.from(request).pipe(
|
OpenAIChat.protocol.body.from(request).pipe(
|
||||||
Effect.map(
|
Effect.map((body) => {
|
||||||
(body) =>
|
const sourceAssistants = request.messages.filter((message) => message.role === "assistant")
|
||||||
({
|
let assistantIndex = 0
|
||||||
...body,
|
const messages = body.messages.map((message) => {
|
||||||
...bodyOptions(request.providerOptions?.openrouter),
|
if (message.role !== "assistant") return message
|
||||||
}) as OpenRouterBody,
|
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,
|
stream: OpenAIChat.protocol.stream,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+55
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { ConfigProvider, Effect, Schema } from "effect"
|
import { ConfigProvider, Effect, Schema } from "effect"
|
||||||
import { HttpClientRequest } from "effect/unstable/http"
|
import { HttpClientRequest } from "effect/unstable/http"
|
||||||
import { LLM } from "../../src"
|
import { LLM, LLMEvent } from "../../src"
|
||||||
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare"
|
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare"
|
||||||
import { LLMClient } from "../../src/route"
|
import { LLMClient } from "../../src/route"
|
||||||
import { it } from "../lib/effect"
|
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", () =>
|
it.effect("defaults AI Gateway id to default when omitted or blank", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
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 OpenAICompatible from "../../src/providers/openai-compatible"
|
||||||
import * as OpenRouter from "../../src/providers/openrouter"
|
import * as OpenRouter from "../../src/providers/openrouter"
|
||||||
import { LLMClient } from "../../src/route"
|
import { LLMClient } from "../../src/route"
|
||||||
import { recordedTests } from "../recorded-test"
|
import { recordedTests } from "../recorded-test"
|
||||||
|
import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop } from "../recorded-scenarios"
|
||||||
|
|
||||||
const cases = [
|
const cases = [
|
||||||
{
|
{
|
||||||
@@ -15,6 +17,7 @@ const cases = [
|
|||||||
}).model("anthropic/claude-sonnet-4.6"),
|
}).model("anthropic/claude-sonnet-4.6"),
|
||||||
requires: ["OPENROUTER_API_KEY"],
|
requires: ["OPENROUTER_API_KEY"],
|
||||||
cassette: "openrouter-reasoning",
|
cassette: "openrouter-reasoning",
|
||||||
|
structured: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Vercel AI Gateway",
|
name: "Vercel AI Gateway",
|
||||||
@@ -26,6 +29,7 @@ const cases = [
|
|||||||
}).model("anthropic/claude-sonnet-4.6"),
|
}).model("anthropic/claude-sonnet-4.6"),
|
||||||
requires: ["AI_GATEWAY_API_KEY"],
|
requires: ["AI_GATEWAY_API_KEY"],
|
||||||
cassette: "vercel-ai-gateway-reasoning",
|
cassette: "vercel-ai-gateway-reasoning",
|
||||||
|
structured: true,
|
||||||
},
|
},
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
@@ -57,11 +61,82 @@ for (const item of cases) {
|
|||||||
expect(response.text.replaceAll(",", "").trim()).toBe("37887")
|
expect(response.text.replaceAll(",", "").trim()).toBe("37887")
|
||||||
expect(response.reasoning.length).toBeGreaterThan(0)
|
expect(response.reasoning.length).toBeGreaterThan(0)
|
||||||
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
|
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
|
||||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
const metadata = response.message.content.find((part) => part.type === "reasoning")?.providerMetadata
|
||||||
openai: { reasoningField: "reasoning" },
|
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,
|
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,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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", () =>
|
it.effect("assembles streamed tool call input", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = sseEvents(
|
const body = sseEvents(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { LLM } from "../../src"
|
import { LLM, Message } from "../../src"
|
||||||
import { LLMClient } from "../../src/route"
|
import { LLMClient } from "../../src/route"
|
||||||
import * as OpenRouter from "../../src/providers/openrouter"
|
import * as OpenRouter from "../../src/providers/openrouter"
|
||||||
import { it } from "../lib/effect"
|
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 }])
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -120,29 +120,8 @@ export const runWeatherToolLoop = (request: LLMRequest) =>
|
|||||||
throw new Error("Weather tool loop exceeded 10 steps")
|
throw new Error("Weather tool loop exceeded 10 steps")
|
||||||
})
|
})
|
||||||
|
|
||||||
const assistantContent = (events: ReadonlyArray<LLMEvent>) => {
|
const assistantContent = (events: ReadonlyArray<LLMEvent>) =>
|
||||||
const content: ContentPart[] = []
|
events.reduce(LLMResponse.reduce, LLMResponse.empty()).message.content
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
export const expectFinish = (
|
export const expectFinish = (
|
||||||
events: ReadonlyArray<LLMEvent>,
|
events: ReadonlyArray<LLMEvent>,
|
||||||
|
|||||||
Reference in New Issue
Block a user