Skip to content

Commit d91e5e2

Browse files
committed
fix(llm): replay OpenAI reasoning continuation state
1 parent 3129ab9 commit d91e5e2

6 files changed

Lines changed: 155 additions & 15 deletions

File tree

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

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,16 @@ const OpenAIResponsesReasoningSummaryText = Schema.Struct({
5252

5353
const OpenAIResponsesReasoningItem = Schema.Struct({
5454
type: Schema.tag("reasoning"),
55-
id: Schema.String,
55+
id: Schema.optional(Schema.String),
5656
summary: Schema.Array(OpenAIResponsesReasoningSummaryText),
5757
encrypted_content: optionalNull(Schema.String),
5858
})
5959

60+
const OpenAIResponsesItemReference = Schema.Struct({
61+
type: Schema.tag("item_reference"),
62+
id: Schema.String,
63+
})
64+
6065
// `function_call_output.output` accepts either a plain string or an ordered
6166
// array of content items so tools can return images in addition to text.
6267
// https://platform.openai.com/docs/api-reference/responses/object
@@ -72,6 +77,7 @@ const OpenAIResponsesInputItem = Schema.Union([
7277
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }),
7378
Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }),
7479
OpenAIResponsesReasoningItem,
80+
OpenAIResponsesItemReference,
7581
Schema.Struct({
7682
type: Schema.tag("function_call"),
7783
call_id: Schema.String,
@@ -88,7 +94,7 @@ type OpenAIResponsesInputItem = Schema.Schema.Type<typeof OpenAIResponsesInputIt
8894

8995
type OpenAIResponsesReasoningInput = {
9096
type: "reasoning"
91-
id: string
97+
id?: string
9298
summary: Array<{ type: "summary_text"; text: string }>
9399
encrypted_content?: string | null
94100
}
@@ -264,17 +270,20 @@ const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({
264270

265271
const lowerReasoning = (part: ReasoningPart): OpenAIResponsesReasoningInput | undefined => {
266272
const openai = part.providerMetadata?.openai
267-
if (!ProviderShared.isRecord(openai) || typeof openai.itemId !== "string") return undefined
273+
if (!ProviderShared.isRecord(openai)) return undefined
274+
const id = typeof openai.itemId === "string" ? openai.itemId : undefined
275+
const encryptedContent =
276+
typeof openai.reasoningEncryptedContent === "string"
277+
? openai.reasoningEncryptedContent
278+
: openai.reasoningEncryptedContent === null
279+
? null
280+
: undefined
281+
if (!id && typeof encryptedContent !== "string") return undefined
268282
return {
269283
type: "reasoning",
270-
id: openai.itemId,
284+
id,
271285
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
272-
encrypted_content:
273-
typeof openai.reasoningEncryptedContent === "string"
274-
? openai.reasoningEncryptedContent
275-
: openai.reasoningEncryptedContent === null
276-
? null
277-
: undefined,
286+
encrypted_content: encryptedContent,
278287
}
279288
}
280289

@@ -325,6 +334,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
325334
if (message.role === "assistant") {
326335
const content: TextPart[] = []
327336
const reasoningItems: Record<string, OpenAIResponsesReasoningInput> = {}
337+
const reasoningReferences = new Set<string>()
328338
const flushText = () => {
329339
if (content.length === 0) return
330340
input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) })
@@ -339,6 +349,15 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
339349
flushText()
340350
const reasoning = lowerReasoning(part)
341351
if (!reasoning) continue
352+
if (store !== false && reasoning.id) {
353+
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
354+
reasoningReferences.add(reasoning.id)
355+
continue
356+
}
357+
if (!reasoning.id) {
358+
input.push(reasoning)
359+
continue
360+
}
342361
const existing = reasoningItems[reasoning.id]
343362
if (existing) {
344363
existing.summary.push(...reasoning.summary)
@@ -393,13 +412,14 @@ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (reques
393412
return yield* invalid(`OpenAI Responses does not support reasoning effort ${effort}`)
394413
const summary = OpenAIOptions.reasoningSummary(request)
395414
const encryptedState = OpenAIOptions.encryptedReasoning(request)
415+
const includeEncryptedReasoning = encryptedState || (store === false && OpenAIOptions.isReasoningModel(request))
396416
const verbosity = OpenAIOptions.textVerbosity(request)
397417
const instructions = OpenAIOptions.instructions(request)
398418
return {
399419
...(instructions ? { instructions } : {}),
400420
...(store !== undefined ? { store } : {}),
401421
...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}),
402-
...(encryptedState ? { include: ["reasoning.encrypted_content"] as const } : {}),
422+
...(includeEncryptedReasoning ? { include: ["reasoning.encrypted_content"] as const } : {}),
403423
...(effort || summary ? { reasoning: { effort, summary } } : {}),
404424
...(verbosity ? { text: { verbosity } } : {}),
405425
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,16 @@ export const reasoningSummary = (request: LLMRequest): "auto" | undefined => {
4242
export const encryptedReasoning = (request: LLMRequest) =>
4343
options(request)?.includeEncryptedReasoning === true ? true : undefined
4444

45+
export const isReasoningModel = (request: LLMRequest) => {
46+
const id = request.model.id.toLowerCase()
47+
return (
48+
id.startsWith("o1") ||
49+
id.startsWith("o3") ||
50+
id.startsWith("o4-mini") ||
51+
(id.startsWith("gpt-5") && !id.startsWith("gpt-5-chat"))
52+
)
53+
}
54+
4555
export const promptCacheKey = (request: LLMRequest) => {
4656
const value = options(request)?.promptCacheKey
4757
return typeof value === "string" ? value : undefined

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

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"headers": {
1919
"content-type": "application/json"
2020
},
21-
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Show concise reasoning when the provider supports visible reasoning summaries.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"store\":false,\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true}"
21+
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Show concise reasoning when the provider supports visible reasoning summaries.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true}"
2222
},
2323
"response": {
2424
"status": 200,

packages/llm/test/provider/openai-responses.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,7 @@ describe("OpenAI Responses route", () => {
660660
]),
661661
Message.user("Summarize it."),
662662
],
663+
providerOptions: { openai: { store: false } },
663664
}),
664665
).pipe(
665666
Effect.provide(
@@ -717,6 +718,7 @@ describe("OpenAI Responses route", () => {
717718
{ type: "text", text: "After." },
718719
]),
719720
],
721+
providerOptions: { openai: { store: false } },
720722
}),
721723
)
722724

@@ -733,6 +735,56 @@ describe("OpenAI Responses route", () => {
733735
}),
734736
)
735737

738+
it.effect("references stored reasoning items by id", () =>
739+
Effect.gen(function* () {
740+
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
741+
LLM.request({
742+
model,
743+
messages: [
744+
Message.assistant([
745+
{
746+
type: "reasoning",
747+
text: "Checked the previous diff.",
748+
providerMetadata: { openai: { itemId: "rs_1" } },
749+
},
750+
]),
751+
],
752+
providerOptions: { openai: { store: true } },
753+
}),
754+
)
755+
756+
expect(prepared.body.input).toEqual([{ type: "item_reference", id: "rs_1" }])
757+
}),
758+
)
759+
760+
it.effect("continues stateless reasoning from encrypted content without an item id", () =>
761+
Effect.gen(function* () {
762+
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
763+
LLM.request({
764+
model,
765+
messages: [
766+
Message.assistant([
767+
{
768+
type: "reasoning",
769+
text: "Checked the previous diff.",
770+
providerMetadata: { openai: { reasoningEncryptedContent: "encrypted-state" } },
771+
},
772+
]),
773+
],
774+
providerOptions: { openai: { store: false } },
775+
}),
776+
)
777+
778+
expect(prepared.body.input).toEqual([
779+
{
780+
type: "reasoning",
781+
encrypted_content: "encrypted-state",
782+
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
783+
},
784+
])
785+
}),
786+
)
787+
736788
it.effect("joins streamed summary blocks into one continuation reasoning item", () =>
737789
Effect.gen(function* () {
738790
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(

packages/opencode/test/session/llm-native.test.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,12 @@ const sessionOpenAIReasoning = (
8989
text: string,
9090
options: {
9191
readonly storedAs: "providerMetadata" | "providerOptions"
92-
readonly itemId: string
92+
readonly itemId?: string
9393
readonly encryptedContent: string | null
9494
},
9595
) => {
9696
const metadata = {
97-
openai: { itemId: options.itemId, reasoningEncryptedContent: options.encryptedContent },
97+
openai: { ...(options.itemId ? { itemId: options.itemId } : {}), reasoningEncryptedContent: options.encryptedContent },
9898
}
9999
if (options.storedAs === "providerMetadata")
100100
return Object.assign({ type: "reasoning" as const, text }, { providerMetadata: metadata })
@@ -544,13 +544,26 @@ describe("session.llm-native.request", () => {
544544
model: "gpt-5-mini",
545545
instructions: "You are concise.",
546546
input: [openAIResponses.user("hello")],
547+
include: ["reasoning.encrypted_content"],
547548
max_output_tokens: 512,
548549
store: false,
549550
stream: true,
550551
},
551552
}),
552553
)
553554

555+
it.effect("requests encrypted OpenAI reasoning state for stateless reasoning models", () =>
556+
expectOpenAIResponsesRequest({
557+
history: [storedSession.user("hello")],
558+
providerOptions: { openai: { store: false, reasoningSummary: "auto" } },
559+
expectedBody: {
560+
input: [openAIResponses.user("hello")],
561+
include: ["reasoning.encrypted_content"],
562+
store: false,
563+
},
564+
}),
565+
)
566+
554567
it.effect("omits non-persisted OpenAI reasoning ids without encrypted state", () =>
555568
expectOpenAIResponsesRequest({
556569
history: [
@@ -572,6 +585,7 @@ describe("session.llm-native.request", () => {
572585
openAIResponses.assistant("The parser changed."),
573586
openAIResponses.user("Summarize it."),
574587
],
588+
include: ["reasoning.encrypted_content"],
575589
store: false,
576590
},
577591
}),
@@ -608,6 +622,50 @@ describe("session.llm-native.request", () => {
608622
}),
609623
)
610624

625+
it.effect("preserves encrypted OpenAI reasoning state without a stored item id", () =>
626+
expectOpenAIResponsesRequest({
627+
history: [
628+
storedSession.assistant([
629+
storedSession.openaiReasoning("Checked the previous diff.", {
630+
storedAs: "providerMetadata",
631+
encryptedContent: "encrypted-state",
632+
}),
633+
]),
634+
],
635+
providerOptions: { openai: { store: false } },
636+
expectedBody: {
637+
input: [
638+
{
639+
type: "reasoning",
640+
encrypted_content: "encrypted-state",
641+
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
642+
},
643+
],
644+
include: ["reasoning.encrypted_content"],
645+
store: false,
646+
},
647+
}),
648+
)
649+
650+
it.effect("references stored OpenAI reasoning items by id", () =>
651+
expectOpenAIResponsesRequest({
652+
history: [
653+
storedSession.assistant([
654+
storedSession.openaiReasoning("Checked the previous diff.", {
655+
storedAs: "providerMetadata",
656+
itemId: "rs_1",
657+
encryptedContent: null,
658+
}),
659+
]),
660+
],
661+
providerOptions: { openai: { store: true } },
662+
expectedBody: {
663+
input: [{ type: "item_reference", id: "rs_1" }],
664+
store: true,
665+
},
666+
}),
667+
)
668+
611669
it.effect("uses provider fetch override for native OpenAI OAuth requests", () =>
612670
Effect.gen(function* () {
613671
const captures: Array<{ url: string; body: unknown }> = []

0 commit comments

Comments
 (0)