fix(llm): split OpenAI reasoning summary blocks (#29000)

This commit is contained in:
Aiden Cline 2026-05-23 20:06:45 -05:00 committed by GitHub
commit eb84f461b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 717 additions and 71 deletions

View file

@ -17,6 +17,11 @@ function mimeToModality(mime: string): Modality | undefined {
export const OUTPUT_TOKEN_MAX = 32_000
// OpenAI Responses `include` value that returns the encrypted reasoning state
// needed for stateless multi-turn reasoning (store: false). Hoisted so every
// branch that requests it stays in lockstep.
const INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] as const
export function sanitizeSurrogates(content: string) {
return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD")
}
@ -756,7 +761,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
{
reasoningEffort: effort,
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
include: INCLUDE_ENCRYPTED_REASONING,
},
]),
)
@ -790,7 +795,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
{
reasoningEffort: effort,
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
include: INCLUDE_ENCRYPTED_REASONING,
},
]),
)
@ -803,7 +808,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
{
reasoningEffort: effort,
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
include: INCLUDE_ENCRYPTED_REASONING,
},
]),
)
@ -1134,6 +1139,9 @@ export function options(input: {
if (!input.model.api.id.includes("gpt-5-pro")) {
result["reasoningEffort"] = "medium"
result["reasoningSummary"] = "auto"
if (input.model.api.npm === "@ai-sdk/openai") {
result["include"] = INCLUDE_ENCRYPTED_REASONING
}
}
// Only set textVerbosity for non-chat gpt-5.x models
@ -1149,7 +1157,7 @@ export function options(input: {
if (input.model.providerID.startsWith("opencode")) {
result["promptCacheKey"] = input.sessionID
result["include"] = ["reasoning.encrypted_content"]
result["include"] = INCLUDE_ENCRYPTED_REASONING
result["reasoningSummary"] = "auto"
}
}

View file

@ -70,6 +70,14 @@ export function stream(input: StreamInput): StreamResult {
// Integration point with @opencode-ai/llm: native-request lowers session data
// into an LLMRequest, then LLMClient handles route selection and transport.
//
// ProviderTransform.providerOptions builds AI-SDK-shaped options for the
// selected SDK key (e.g. "openai") and the native LLM SDK reads the same
// keys via OpenAIOptions.* (store, reasoningEffort, reasoningSummary,
// include, textVerbosity, promptCacheKey). Both sides intentionally use
// OpenAI's official wire field names, so this is identity, not translation
// — if a field ever needs to differ between the two surfaces, the
// translation belongs here, not split across both packages.
const stream = input.llmClient.stream({
request: LLMNative.request({
model: input.model,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -271,6 +271,7 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => {
const model = createGpt5Model("gpt-5.2")
const result = ProviderTransform.options({ model, sessionID, providerOptions: {} })
expect(result.textVerbosity).toBe("low")
expect(result.include).toEqual(["reasoning.encrypted_content"])
})
test("gpt-5.1 should have textVerbosity set to low", () => {

View file

@ -336,10 +336,25 @@ const weatherTool = tool({
})
const toolRoundtrip = (
events: ReadonlyArray<LLMEvent>,
call: { readonly id: string; readonly name: string; readonly input: unknown },
result: JSONValue,
): ModelMessage[] => [
{ role: "assistant", content: [{ type: "tool-call", toolCallId: call.id, toolName: call.name, input: call.input }] },
{
role: "assistant",
content: [
...events.filter(LLMEvent.is.reasoningEnd).map((part) => ({
type: "reasoning" as const,
text: events
.filter(LLMEvent.is.reasoningDelta)
.filter((event) => event.id === part.id)
.map((event) => event.text)
.join(""),
providerMetadata: part.providerMetadata,
})),
{ type: "tool-call", toolCallId: call.id, toolName: call.name, input: call.input },
],
},
{
role: "tool",
content: [
@ -395,7 +410,7 @@ const driveToolLoop = (scenario: RecordedScenario) =>
const turn2 = yield* collect({
...base,
messages: [userMessage, ...toolRoundtrip(toolCall!, WEATHER_RESULT)],
messages: [userMessage, ...toolRoundtrip(turn1, toolCall!, WEATHER_RESULT)],
})
expect(LLMResponse.text({ events: turn2 })).toMatch(/Paris is sunny/i)

View file

@ -591,7 +591,7 @@ describe("session.llm-native.request", () => {
]),
storedSession.user("Summarize it."),
],
providerOptions: { openai: { store: false, includeEncryptedReasoning: true } },
providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
expectedBody: {
input: [
openAIResponses.user("What changed?"),
@ -608,6 +608,45 @@ describe("session.llm-native.request", () => {
}),
)
it.effect("preserves empty encrypted OpenAI reasoning items before tool output", () =>
expectOpenAIResponsesRequest({
history: [
storedSession.assistant([
storedSession.openaiReasoning("", {
storedAs: "providerMetadata",
itemId: "rs_1",
encryptedContent: "encrypted-state",
}),
]),
],
providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
expectedBody: {
input: [{ type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" }],
include: ["reasoning.encrypted_content"],
store: false,
},
}),
)
it.effect("references stored OpenAI reasoning items by id", () =>
expectOpenAIResponsesRequest({
history: [
storedSession.assistant([
storedSession.openaiReasoning("Checked the previous diff.", {
storedAs: "providerMetadata",
itemId: "rs_1",
encryptedContent: null,
}),
]),
],
providerOptions: { openai: { store: true } },
expectedBody: {
input: [{ type: "item_reference", id: "rs_1" }],
store: true,
},
}),
)
it.effect("uses provider fetch override for native OpenAI OAuth requests", () =>
Effect.gen(function* () {
const captures: Array<{ url: string; body: unknown }> = []

View file

@ -1166,6 +1166,7 @@ describe("session.llm.stream", () => {
expect(capture.body.model).toBe(model.id)
expect(capture.body.stream).toBe(true)
expect((capture.body.reasoning as { effort?: string } | undefined)?.effort).toBe("high")
expect(capture.body.include).toEqual(["reasoning.encrypted_content"])
expect(JSON.stringify(capture.body.input)).toContain("You are a helpful assistant.")
expect(capture.body.input).toContainEqual({ role: "user", content: [{ type: "input_text", text: "Hello" }] })
}),