fix(llm): split OpenAI reasoning summary blocks (#29000)
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }> = []
|
||||
|
||||
@@ -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" }] })
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user