Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions packages/ai/src/protocols/open-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ const OpenResponsesReasoningItem = Schema.Struct({
encrypted_content: optionalNull(Schema.String),
})

const OpenResponsesCompactionItem = Schema.Struct({
type: Schema.tag("compaction"),
id: Schema.optionalKey(Schema.String),
encrypted_content: Schema.String,
})
type OpenResponsesCompactionItem = Schema.Schema.Type<typeof OpenResponsesCompactionItem>

const OpenResponsesItemReference = Schema.Struct({
type: Schema.tag("item_reference"),
id: Schema.String,
Expand Down Expand Up @@ -100,6 +107,7 @@ export const InputItem = Schema.Union([
phase: Schema.optionalKey(MessagePhase),
}),
OpenResponsesReasoningItem,
OpenResponsesCompactionItem,
OpenResponsesItemReference,
Schema.Struct({
type: Schema.tag("function_call"),
Expand Down Expand Up @@ -339,6 +347,7 @@ export interface ParserState {
readonly messagePhase: (value: unknown) => MessagePhase | null | undefined
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
readonly compactionItems: ReadonlyArray<OpenResponsesCompactionItem>
readonly store: boolean | undefined
}

Expand Down Expand Up @@ -418,6 +427,12 @@ const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) =>
return itemID(part.providerMetadata, providerMetadataKey)
}

const compactionItems = (message: LLMRequest["messages"][number], providerMetadataKey: string) => {
const native = message.native?.[providerMetadataKey]
if (!ProviderShared.isRecord(native) || !Array.isArray(native.compactionItems)) return []
return native.compactionItems.filter(Schema.is(OpenResponsesCompactionItem))
}

const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
part: MediaPart,
request: LLMRequest,
Expand Down Expand Up @@ -499,6 +514,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
}

if (message.role === "assistant") {
input.push(...compactionItems(message, providerMetadataKey))
const content: TextPart[] = []
const reasoningItems: Record<string, OpenResponsesReasoningInput> = {}
const reasoningReferences = new Set<string>()
Expand Down Expand Up @@ -1029,6 +1045,21 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
] satisfies StepResult
}

if (item.type === "compaction") {
if (!item.id || typeof item.encrypted_content !== "string")
return yield* ProviderShared.eventError(state.id, "Open Responses compaction item is malformed")
return [
{
...state,
compactionItems: [
...state.compactionItems,
{ type: "compaction", id: item.id, encrypted_content: item.encrypted_content },
],
},
NO_EVENTS,
] satisfies StepResult
}

return [state, NO_EVENTS] satisfies StepResult
})

Expand All @@ -1049,10 +1080,11 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
},
usage: mapUsage(event.response?.usage, state.providerMetadataKey),
providerMetadata:
event.response?.id || event.response?.service_tier
event.response?.id || event.response?.service_tier || state.compactionItems.length > 0
? providerMetadata(state, {
responseId: event.response.id,
serviceTier: event.response.service_tier,
responseId: event.response?.id,
serviceTier: event.response?.service_tier,
...(state.compactionItems.length > 0 ? { compactionItems: state.compactionItems } : {}),
})
: undefined,
})
Expand Down Expand Up @@ -1162,6 +1194,7 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
messagePhase: (value) => messagePhase(value, extension),
messagePhases: {},
reasoningItems: {},
compactionItems: [],
store: OpenResponsesOptions.resolve(request).store,
})

Expand Down
14 changes: 14 additions & 0 deletions packages/ai/src/protocols/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { OpenAIImage } from "./utils/openai-image.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { OpenResponsesChannel } from "./open-responses-channel.js"
import { OpenAIResponsesChannel } from "./openai-responses-channel.js"
import { OpenAIOptions } from "./utils/openai-options.js"

const ADAPTER = "openai-responses"
const NAME = "OpenAI Responses"
Expand Down Expand Up @@ -56,6 +57,14 @@ const OpenAIResponsesCoreFields = {
input: Schema.Array(OpenAIResponsesInputItem),
tools: optionalArray(OpenAIResponsesTools),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
context_management: Schema.optional(
Schema.Array(
Schema.Struct({
type: Schema.tag("compaction"),
compact_threshold: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
}),
),
),
}

const OpenAIResponsesBody = Schema.Struct({
Expand Down Expand Up @@ -115,6 +124,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
extension,
)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const contextManagement = OpenAIOptions.resolve(request).contextManagement
return {
...body,
tools:
Expand All @@ -125,6 +135,10 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
),
tool_choice:
body.tool_choice ?? (request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
context_management: contextManagement?.map((item) => ({
type: item.type,
compact_threshold: item.compactThreshold,
})),
} satisfies OpenAIResponsesBody
})

Expand Down
20 changes: 19 additions & 1 deletion packages/ai/src/protocols/utils/openai-options.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Option, Schema } from "effect"
import type { LLMRequest } from "../../schema/index.js"
import { OpenResponsesOptions } from "./open-responses-options.js"

export const OpenAIReasoningEfforts = OpenResponsesOptions.ReasoningEfforts
Expand All @@ -19,6 +21,22 @@ export const OpenAIServiceTier = OpenResponsesOptions.ServiceTierSchema

export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort => typeof effort === "string"

export const resolve = OpenResponsesOptions.resolve
export const ContextManagement = Schema.Array(
Schema.Struct({
type: Schema.tag("compaction"),
compactThreshold: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
}),
)
export type ContextManagement = typeof ContextManagement.Type

const Options = Schema.Struct({
contextManagement: Schema.optional(ContextManagement),
})
const decodeOptions = Schema.decodeUnknownOption(Options)

export const resolve = (request: LLMRequest) => ({
...OpenResponsesOptions.resolve(request),
...Option.getOrElse(decodeOptions(request.providerOptions), () => ({})),
})

export * as OpenAIOptions from "./openai-options.js"
5 changes: 4 additions & 1 deletion packages/ai/src/providers/openai-options.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { mergeProviderOptions, type ProviderOptions } from "../schema/index.js"
import type { OpenResponsesOptionsInput } from "./open-responses-options.js"
import type { ContextManagement } from "../protocols/utils/openai-options.js"

export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options.js"

export type OpenAIOptionsInput = OpenResponsesOptionsInput
export type OpenAIOptionsInput = OpenResponsesOptionsInput & {
readonly contextManagement?: ContextManagement
}

export type OpenAIProviderOptionsInput = OpenAIOptionsInput

Expand Down
5 changes: 5 additions & 0 deletions packages/ai/test/provider-options/openai.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } })
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "max" } })
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
LLM.request({
model: selected,
prompt: "Hello",
providerOptions: { contextManagement: [{ type: "compaction", compactThreshold: 100_000 }] },
})

LLM.request({
model: selected,
Expand Down
83 changes: 83 additions & 0 deletions packages/ai/test/provider/openai-responses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,50 @@ describe("OpenAI Responses route", () => {
}),
)

it.effect("enables server-side compaction", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLMRequest.update(request, {
providerOptions: {
contextManagement: [{ type: "compaction", compactThreshold: 100_000 }],
},
}),
)

expect(prepared.body.context_management).toEqual([{ type: "compaction", compact_threshold: 100_000 }])
}),
)

it.effect("replays durable server-side compaction items", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.make({
role: "assistant",
content: "After compaction",
native: {
openai: {
compactionItems: [{ type: "compaction", id: "cmp_1", encrypted_content: "opaque-state" }],
},
},
}),
],
}),
)

expect(prepared.body.input).toEqual([
{ type: "compaction", id: "cmp_1", encrypted_content: "opaque-state" },
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "After compaction" }],
},
])
}),
)

it.effect("passes through custom OpenAI reasoning effort strings", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
Expand Down Expand Up @@ -1506,6 +1550,45 @@ describe("OpenAI Responses route", () => {
}),
)

it.effect("retains server-side compaction output for continuation", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: {
type: "compaction",
id: "cmp_1",
encrypted_content: "opaque-state",
status: "completed",
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)

expect(response.events.filter(LLMEvent.is.stepFinish)).toEqual([
{
type: "step-finish",
index: 0,
reason: { normalized: "stop", raw: undefined },
providerMetadata: {
openai: {
responseId: "resp_1",
serviceTier: undefined,
compactionItems: [{ type: "compaction", id: "cmp_1", encrypted_content: "opaque-state" }],
},
},
usage: undefined,
},
])
}),
)

it.effect("preserves standard refusal content as ordinary assistant text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Expand Down
14 changes: 12 additions & 2 deletions packages/core/src/session/runner/to-llm-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
const sameProvider = String(message.model.providerID) === String(model.providerID)
const sameModel = sameProvider && String(message.model.id) === String(model.id)
const reuseProviderMetadata = sameModel && message.error === undefined
const native =
reuseProviderMetadata && Array.isArray(message.providerState?.compactionItems)
? { [providerMetadataKey]: message.providerState }
: undefined
const content = message.content.flatMap((item): ContentPart[] => {
if (item.type === "text")
return [
Expand Down Expand Up @@ -204,9 +208,15 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
)
.filter((message) => message !== undefined)
.map(Message.tool)
if (meaningful.length === 0) return results
if (meaningful.length === 0 && native === undefined) return results
return [
Message.make({ id: message.id, role: "assistant", content: meaningful, metadata: message.metadata }),
Message.make({
id: message.id,
role: "assistant",
content: meaningful,
metadata: message.metadata,
native,
}),
...results,
]
}
Expand Down
34 changes: 34 additions & 0 deletions packages/core/test/session-runner-message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1019,6 +1019,40 @@ Recent work
])
})

test("carries same-model server compaction state as native message data", () => {
const messages = toLLMMessages(
[
SessionMessage.Assistant.make({
id: id("assistant-compaction"),
type: "assistant",
agent: build,
model,
content: [],
providerState: {
responseId: "resp_1",
compactionItems: [{ type: "compaction", id: "cmp_1", encrypted_content: "opaque-state" }],
},
time: { created, completed: created },
}),
],
model,
)

expect(messages).toEqual([
Message.make({
id: id("assistant-compaction"),
role: "assistant",
content: [],
native: {
provider: {
responseId: "resp_1",
compactionItems: [{ type: "compaction", id: "cmp_1", encrypted_content: "opaque-state" }],
},
},
}),
])
})

test("preserves assistant text provider state across same-provider model changes and failures", () => {
const messages = toLLMMessages(
[
Expand Down
Loading