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
6 changes: 6 additions & 0 deletions .changeset/add-openai-prompt-cache-breakpoints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@effect/ai-openai": patch
"effect": patch
---

Support explicit OpenAI prompt-cache breakpoints on Responses API input content and correct message constructor provider option types.
108 changes: 100 additions & 8 deletions packages/ai/openai/src/OpenAiLanguageModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,45 @@ export class Config extends Context.Service<
// =============================================================================

declare module "effect/unstable/ai/Prompt" {
/**
* OpenAI-specific options for system messages.
*
* **Details**
*
* A prompt-cache breakpoint is placed on the system message's input text block.
*
* @category request
* @since 4.0.0
*/
export interface SystemMessageOptions extends ProviderOptions {
readonly openai?: {
/**
* Marks the end of reusable prompt content eligible for caching.
*/
readonly promptCacheBreakpoint?: typeof OpenAiSchema.PromptCacheBreakpoint.Encoded | null
} | null
}

/**
* OpenAI-specific options for user messages.
*
* **Details**
*
* A message-level prompt-cache breakpoint is used as a fallback for the last
* content part when that part has no breakpoint of its own.
*
* @category request
* @since 4.0.0
*/
export interface UserMessageOptions extends ProviderOptions {
readonly openai?: {
/**
* Marks the end of reusable prompt content eligible for caching.
*/
readonly promptCacheBreakpoint?: typeof OpenAiSchema.PromptCacheBreakpoint.Encoded | null
} | null
}

/**
* OpenAI-specific options for file prompt parts.
*
Expand All @@ -139,6 +178,10 @@ declare module "effect/unstable/ai/Prompt" {
* The detail level of the image to be sent to the model. One of `high`, `low`, or `auto`. Defaults to `auto`.
*/
readonly imageDetail?: ImageDetail | null
/**
* Marks the end of reusable prompt content eligible for caching.
*/
readonly promptCacheBreakpoint?: typeof OpenAiSchema.PromptCacheBreakpoint.Encoded | null
} | null
}

Expand Down Expand Up @@ -241,6 +284,10 @@ declare module "effect/unstable/ai/Prompt" {
* A list of annotations that apply to the output text.
*/
readonly annotations?: ReadonlyArray<typeof OpenAiSchema.Annotation.Encoded> | null
/**
* Marks the end of reusable prompt content eligible for caching.
*/
readonly promptCacheBreakpoint?: typeof OpenAiSchema.PromptCacheBreakpoint.Encoded | null
} | null
}
}
Expand Down Expand Up @@ -814,9 +861,15 @@ const prepareMessages = Effect.fnUntraced(
for (const message of prompt.content) {
switch (message.role) {
case "system": {
const prompt_cache_breakpoint = getPromptCacheBreakpoint(message)

messages.push({
role: getSystemMessageMode(config.model as string),
content: [{ type: "input_text", text: message.content }]
content: [{
type: "input_text",
text: message.content,
...(Predicate.isNotNull(prompt_cache_breakpoint) ? { prompt_cache_breakpoint } : undefined)
}]
})
break
}
Expand All @@ -826,10 +879,17 @@ const prepareMessages = Effect.fnUntraced(

for (let index = 0; index < message.content.length; index++) {
const part = message.content[index]
const prompt_cache_breakpoint = getPromptCacheBreakpoint(part) ?? (
index === message.content.length - 1 ? getPromptCacheBreakpoint(message) : null
)

switch (part.type) {
case "text": {
content.push({ type: "input_text", text: part.text })
content.push({
type: "input_text",
text: part.text,
...(Predicate.isNotNull(prompt_cache_breakpoint) ? { prompt_cache_breakpoint } : undefined)
})
break
}

Expand All @@ -839,32 +899,60 @@ const prepareMessages = Effect.fnUntraced(
const mediaType = part.mediaType === "image/*" ? "image/jpeg" : part.mediaType

if (typeof part.data === "string" && isFileId(part.data, config)) {
content.push({ type: "input_image", file_id: part.data, detail })
content.push({
type: "input_image",
file_id: part.data,
detail,
...(Predicate.isNotNull(prompt_cache_breakpoint) ? { prompt_cache_breakpoint } : undefined)
})
}

if (part.data instanceof URL) {
content.push({ type: "input_image", image_url: part.data.toString(), detail })
content.push({
type: "input_image",
image_url: part.data.toString(),
detail,
...(Predicate.isNotNull(prompt_cache_breakpoint) ? { prompt_cache_breakpoint } : undefined)
})
}

if (part.data instanceof Uint8Array) {
const base64 = Encoding.encodeBase64(part.data)
const imageUrl = `data:${mediaType};base64,${base64}`
content.push({ type: "input_image", image_url: imageUrl, detail })
content.push({
type: "input_image",
image_url: imageUrl,
detail,
...(Predicate.isNotNull(prompt_cache_breakpoint) ? { prompt_cache_breakpoint } : undefined)
})
}
} else if (part.mediaType === "application/pdf") {
if (typeof part.data === "string" && isFileId(part.data, config)) {
content.push({ type: "input_file", file_id: part.data })
content.push({
type: "input_file",
file_id: part.data,
...(Predicate.isNotNull(prompt_cache_breakpoint) ? { prompt_cache_breakpoint } : undefined)
})
}

if (part.data instanceof URL) {
content.push({ type: "input_file", file_url: part.data.toString() })
content.push({
type: "input_file",
file_url: part.data.toString(),
...(Predicate.isNotNull(prompt_cache_breakpoint) ? { prompt_cache_breakpoint } : undefined)
})
}

if (part.data instanceof Uint8Array) {
const base64 = Encoding.encodeBase64(part.data)
const fileName = part.fileName ?? `part-${index}.pdf`
const fileData = `data:application/pdf;base64,${base64}`
content.push({ type: "input_file", filename: fileName, file_data: fileData })
content.push({
type: "input_file",
filename: fileName,
file_data: fileData,
...(Predicate.isNotNull(prompt_cache_breakpoint) ? { prompt_cache_breakpoint } : undefined)
})
}
} else {
return yield* AiError.make({
Expand Down Expand Up @@ -2877,6 +2965,10 @@ const getEncryptedContent = (

const getImageDetail = (part: Prompt.FilePart): ImageDetail => part.options.openai?.imageDetail ?? "auto"

const getPromptCacheBreakpoint = (
input: Prompt.SystemMessage | Prompt.UserMessage | Prompt.TextPart | Prompt.FilePart
): typeof OpenAiSchema.PromptCacheBreakpoint.Encoded | null => input.options.openai?.promptCacheBreakpoint ?? null

const makeItemIdMetadata = (itemId: string | undefined) => Predicate.isNotUndefined(itemId) ? { itemId } : {}

const makeEncryptedContentMetadata = (encryptedContent: string | null | undefined) =>
Expand Down
33 changes: 30 additions & 3 deletions packages/ai/openai/src/OpenAiSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,29 @@ const MessageRole = Schema.Literals(["system", "developer", "user", "assistant"]

const ImageDetail = Schema.Literals(["low", "high", "auto"])

/**
* Schema for an explicit prompt-cache breakpoint on an OpenAI input content block.
*
* **Details**
*
* The breakpoint includes the marked block and all preceding prompt content in
* the candidate cached prefix. OpenAI currently accepts only `"explicit"`.
*
* @category schemas
* @since 4.0.0
*/
export const PromptCacheBreakpoint = Schema.Struct({
mode: Schema.Literal("explicit")
})

/**
* Explicit prompt-cache breakpoint attached to an OpenAI input content block.
*
* @category models
* @since 4.0.0
*/
export type PromptCacheBreakpoint = typeof PromptCacheBreakpoint.Type

/**
* Schema for optional `include` values supported by the local handwritten
* Responses client schema.
Expand Down Expand Up @@ -75,22 +98,25 @@ export type MessageStatus = typeof MessageStatus.Type

const InputTextContent = Schema.Struct({
type: Schema.Literal("input_text"),
text: Schema.String
text: Schema.String,
prompt_cache_breakpoint: Schema.optionalKey(PromptCacheBreakpoint)
})

const InputImageContent = Schema.Struct({
type: Schema.Literal("input_image"),
image_url: Schema.optionalKey(Schema.NullOr(Schema.String)),
file_id: Schema.optionalKey(Schema.NullOr(Schema.String)),
detail: Schema.optionalKey(Schema.NullOr(ImageDetail))
detail: Schema.optionalKey(Schema.NullOr(ImageDetail)),
prompt_cache_breakpoint: Schema.optionalKey(PromptCacheBreakpoint)
})

const InputFileContent = Schema.Struct({
type: Schema.Literal("input_file"),
file_id: Schema.optionalKey(Schema.NullOr(Schema.String)),
filename: Schema.optionalKey(Schema.String),
file_url: Schema.optionalKey(Schema.String),
file_data: Schema.optionalKey(Schema.String)
file_data: Schema.optionalKey(Schema.String),
prompt_cache_breakpoint: Schema.optionalKey(PromptCacheBreakpoint)
})

/**
Expand All @@ -99,6 +125,7 @@ const InputFileContent = Schema.Struct({
* **Details**
*
* Accepted block variants are `input_text`, `input_image`, and `input_file`.
* Each variant can carry an explicit prompt-cache breakpoint.
*
* @see {@link InputItem} for request input item shapes that can contain these content blocks
*
Expand Down
Loading
Loading