Skip to content

Commit eb84f46

Browse files
authored
fix(llm): split OpenAI reasoning summary blocks (#29000)
1 parent 0b3a1c2 commit eb84f46

22 files changed

Lines changed: 717 additions & 71 deletions

packages/llm/src/protocols/openai-responses.ts

Lines changed: 217 additions & 21 deletions
Large diffs are not rendered by default.

packages/llm/src/protocols/utils/lifecycle.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,24 @@ export const textDelta = (state: State, events: LLMEvent[], id: string, text: st
2424
return { ...stepped, text: new Set([...stepped.text, id]) }
2525
}
2626

27-
export const reasoningDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
27+
export const reasoningStart = (
28+
state: State,
29+
events: LLMEvent[],
30+
id: string,
31+
providerMetadata?: ProviderMetadata,
32+
): State => {
33+
if (state.reasoning.has(id)) return state
2834
const stepped = stepStart(state, events)
29-
if (stepped.reasoning.has(id)) {
30-
events.push(LLMEvent.reasoningDelta({ id, text }))
31-
return stepped
32-
}
33-
events.push(LLMEvent.reasoningStart({ id }), LLMEvent.reasoningDelta({ id, text }))
35+
events.push(LLMEvent.reasoningStart({ id, providerMetadata }))
3436
return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) }
3537
}
3638

39+
export const reasoningDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
40+
const started = reasoningStart(state, events, id)
41+
events.push(LLMEvent.reasoningDelta({ id, text }))
42+
return started
43+
}
44+
3745
export const reasoningEnd = (
3846
state: State,
3947
events: LLMEvent[],

packages/llm/src/protocols/utils/openai-options.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,28 @@ export const OpenAIReasoningEfforts = ReasoningEfforts.filter(
77
)
88
export type OpenAIReasoningEffort = (typeof OpenAIReasoningEfforts)[number]
99

10+
// Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this
11+
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
12+
export const OpenAIResponseIncludables = [
13+
"file_search_call.results",
14+
"web_search_call.results",
15+
"web_search_call.action.sources",
16+
"message.input_image.image_url",
17+
"computer_call_output.output.image_url",
18+
"code_interpreter_call.outputs",
19+
"reasoning.encrypted_content",
20+
"message.output_text.logprobs",
21+
] as const
22+
export type OpenAIResponseIncludable = (typeof OpenAIResponseIncludables)[number]
23+
1024
const REASONING_EFFORTS = new Set<string>(ReasoningEfforts)
1125
const OPENAI_REASONING_EFFORTS = new Set<string>(OpenAIReasoningEfforts)
1226
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
27+
const INCLUDABLES = new Set<string>(OpenAIResponseIncludables)
1328

1429
export const OpenAIReasoningEffort = Schema.Literals(OpenAIReasoningEfforts)
1530
export const OpenAITextVerbosity = TextVerbosity
31+
export const OpenAIResponseIncludable = Schema.Literals(OpenAIResponseIncludables)
1632

1733
const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort =>
1834
typeof effort === "string" && REASONING_EFFORTS.has(effort)
@@ -35,12 +51,20 @@ export const reasoningEffort = (request: LLMRequest): ReasoningEffort | undefine
3551
return isAnyReasoningEffort(value) ? value : undefined
3652
}
3753

38-
export const reasoningSummary = (request: LLMRequest): "auto" | undefined => {
39-
return options(request)?.reasoningSummary === "auto" ? "auto" : undefined
40-
}
54+
export const reasoningSummary = (request: LLMRequest): "auto" | undefined =>
55+
options(request)?.reasoningSummary === "auto" ? "auto" : undefined
4156

42-
export const encryptedReasoning = (request: LLMRequest) =>
43-
options(request)?.includeEncryptedReasoning === true ? true : undefined
57+
// Resolve the OpenAI Responses `include` field. Filters out unknown
58+
// includable values defensively so a typo in upstream config drops the
59+
// invalid entry instead of poisoning the wire body. An empty array (either
60+
// passed directly or produced by filtering) is treated as "no include" and
61+
// returns undefined so the request body omits the field entirely.
62+
export const include = (request: LLMRequest): ReadonlyArray<OpenAIResponseIncludable> | undefined => {
63+
const value = options(request)?.include
64+
if (!Array.isArray(value)) return undefined
65+
const filtered = value.filter((entry): entry is OpenAIResponseIncludable => INCLUDABLES.has(entry))
66+
return filtered.length > 0 ? filtered : undefined
67+
}
4468

4569
export const promptCacheKey = (request: LLMRequest) => {
4670
const value = options(request)?.promptCacheKey

packages/llm/src/providers/openai-options.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema"
22
import { mergeProviderOptions } from "../schema"
3+
import type { OpenAIResponseIncludable } from "../protocols/utils/openai-options"
4+
5+
export type { OpenAIResponseIncludable } from "../protocols/utils/openai-options"
36

47
export interface OpenAIOptionsInput {
58
readonly [key: string]: unknown
69
readonly store?: boolean
710
readonly promptCacheKey?: string
811
readonly reasoningEffort?: ReasoningEffort
912
readonly reasoningSummary?: "auto"
10-
readonly includeEncryptedReasoning?: boolean
13+
// OpenAI Responses `include` wire field. Mirrors the official SDK's
14+
// `ResponseIncludable[]` union exactly so AI SDK callers and direct
15+
// native-SDK callers share one shape and no translation is required.
16+
readonly include?: ReadonlyArray<OpenAIResponseIncludable>
1117
readonly textVerbosity?: TextVerbosity
1218
}
1319

@@ -25,7 +31,7 @@ const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): Provide
2531
promptCacheKey: options?.promptCacheKey,
2632
reasoningEffort: options?.reasoningEffort,
2733
reasoningSummary: options?.reasoningSummary,
28-
includeEncryptedReasoning: options?.includeEncryptedReasoning,
34+
include: options?.include,
2935
textVerbosity: options?.textVerbosity,
3036
}),
3137
)
@@ -42,6 +48,12 @@ export const gpt5DefaultOptions = (
4248
return openAIProviderOptions({
4349
reasoningEffort: "medium",
4450
reasoningSummary: "auto",
51+
// GPT-5 reasoning models are configured stateless (`store: false`) by
52+
// `openAIDefaultOptions` below, so the only way a follow-up turn can
53+
// carry reasoning state is via the encrypted reasoning include. Without
54+
// this, callers using the default model facade get reasoning summaries
55+
// they cannot replay statelessly.
56+
include: ["reasoning.encrypted_content"],
4557
textVerbosity:
4658
options.textVerbosity === true && id.includes("gpt-5.") && !id.includes("codex") && !id.includes("-chat")
4759
? "low"

packages/llm/src/providers/openai.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as OpenAIChat from "../protocols/openai-chat"
55
import * as OpenAIResponses from "../protocols/openai-responses"
66
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
77

8-
export type { OpenAIOptionsInput } from "./openai-options"
8+
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
99

1010
export const id = ProviderID.make("openai")
1111

packages/llm/src/route/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
283283
)
284284
return events.pipe(
285285
Stream.mapAccumEffect(
286-
protocol.stream.initial,
286+
() => protocol.stream.initial(request),
287287
protocol.stream.step,
288288
protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
289289
),

packages/llm/src/route/protocol.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@ export interface ProtocolBody<Body> {
5252
export interface ProtocolStream<Frame, Event, State> {
5353
/** Schema for one decoded streaming event, decoded from a transport frame. */
5454
readonly event: Schema.Codec<Event, Frame>
55-
/** Initial parser state. Called once per response. */
56-
readonly initial: () => State
55+
/** Initial parser state. Called once per response with the resolved request. */
56+
readonly initial: (request: LLMRequest) => State
5757
/** Translate one event into emitted `LLMEvent`s plus the next state. */
5858
readonly step: (state: State, event: Event) => Effect.Effect<readonly [State, ReadonlyArray<LLMEvent>], LLMError>
5959
/** Optional request-completion signal for transports that do not end naturally. */

packages/llm/test/continuation-scenarios.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ export function continuationRequest(input: {
9797
tools: features.has("tool-call") ? [continuationTool] : [],
9898
cache: "none",
9999
providerOptions: features.has("encrypted-reasoning")
100-
? { openai: { store: false, includeEncryptedReasoning: true, reasoningSummary: "auto" } }
100+
? { openai: { store: false, include: ["reasoning.encrypted_content"], reasoningSummary: "auto" } }
101101
: undefined,
102102
generation: { maxTokens: 80, temperature: 0 },
103103
})

packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-image-tool-result.json

Lines changed: 3 additions & 3 deletions
Large diffs are not rendered by default.

packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json

Lines changed: 4 additions & 4 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)