Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e82f68fafb |
@@ -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
+54
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,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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[] }
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
Reference in New Issue
Block a user