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

Expose Responses API safety identifier and request-level prompt cache options through `OpenAiLanguageModel.Config`.
10 changes: 9 additions & 1 deletion packages/ai/openai/src/OpenAiSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -639,7 +639,8 @@ export type TextResponseFormatConfiguration = typeof TextResponseFormatConfigura
* Validates the Responses API request payload, including input content, model
* selection, instructions, reasoning options, text output format, tools,
* `tool_choice`, streaming, storage, response continuation, sampling options,
* and optional response fields requested through `include`.
* safety identification, prompt caching, and optional response fields requested
* through `include`.
*
* **Gotchas**
*
Expand All @@ -658,6 +659,13 @@ export const CreateResponse = Schema.Struct({
temperature: Schema.optional(Schema.Finite),
top_p: Schema.optional(Schema.Finite),
user: Schema.optional(Schema.String),
safety_identifier: Schema.optional(Schema.String.check(Schema.isMaxLength(64))),
prompt_cache_key: Schema.optional(Schema.String),
prompt_cache_retention: Schema.optional(Schema.Literals(["in_memory", "24h"])),
prompt_cache_options: Schema.optional(Schema.Struct({
mode: Schema.optional(Schema.Literals(["implicit", "explicit"])),
ttl: Schema.optional(Schema.Literal("30m"))
})),
service_tier: Schema.optional(Schema.String),
previous_response_id: Schema.optional(Schema.String),
model: Schema.optional(Schema.String),
Expand Down
78 changes: 72 additions & 6 deletions packages/ai/openai/test/OpenAiLanguageModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,24 @@ describe("OpenAiLanguageModel", () => {
})

describe("generateText", () => {
it.effect("forwards safety and legacy prompt-cache config", () =>
Effect.gen(function*() {
yield* LanguageModel.generateText({ prompt: "test" }).pipe(
Effect.provide(OpenAiLanguageModel.model("gpt-5.5", {
safety_identifier: "user-hash-123",
prompt_cache_key: "reviewer:v1",
prompt_cache_retention: "24h"
}))
)

const requests = yield* MockHttpClient.requests
const body = yield* getRequestBody(requests[0])

strictEqual(body.safety_identifier, "user-hash-123")
strictEqual(body.prompt_cache_key, "reviewer:v1")
strictEqual(body.prompt_cache_retention, "24h")
}).pipe(Effect.provide(makeTestLayer({ body: { model: "gpt-5.5" as any } }))))

describe("message preparation", () => {
describe("system messages", () => {
it.effect("uses system role for standard models", () =>
Expand Down Expand Up @@ -913,6 +931,36 @@ describe("OpenAiLanguageModel", () => {
})

describe("streamText", () => {
it.effect("forwards safety and GPT-5.6 prompt-cache config", () =>
Effect.gen(function*() {
const streamEvents = [{
type: "response.completed",
sequence_number: 1,
response: makeDefaultResponse({ model: "gpt-5.6" as any })
}] as unknown as ReadonlyArray<typeof Generated.ResponseStreamEvent.Type>

const requests = yield* Effect.gen(function*() {
yield* LanguageModel.streamText({ prompt: "test" }).pipe(Stream.runCollect)
return yield* MockHttpClient.requests
}).pipe(
Effect.provide(OpenAiLanguageModel.model("gpt-5.6", {
safety_identifier: "user-hash-456",
prompt_cache_key: "reviewer:v2",
prompt_cache_options: {
mode: "implicit",
ttl: "30m"
}
})),
Effect.provide(makeStreamHttpTestLayer(streamEvents))
)
const body = yield* getRequestBody(requests[0])

strictEqual(body.safety_identifier, "user-hash-456")
strictEqual(body.prompt_cache_key, "reviewer:v2")
deepStrictEqual(body.prompt_cache_options, { mode: "implicit", ttl: "30m" })
strictEqual(body.stream, true)
}))

it.effect("extracts usage information", () =>
Effect.gen(function*() {
const streamEvents = [
Expand Down Expand Up @@ -1363,7 +1411,8 @@ describe("OpenAiLanguageModel", () => {

class MockOpenAiResponse extends Context.Service<MockOpenAiResponse, {
readonly status: number
readonly body: Generated.Response
readonly body?: Generated.Response | undefined
readonly events?: ReadonlyArray<typeof Generated.ResponseStreamEvent.Type> | undefined
readonly headers?: Record<string, string> | undefined
}>()("MockOpenAiResponse") {}

Expand All @@ -1380,18 +1429,26 @@ const encodeResponse = Schema.encodeUnknownEffect(OpenAiSchema.Response)
const makeHttpClient = Effect.gen(function*() {
const capturedRequests = yield* Ref.make<ReadonlyArray<HttpClientRequest.HttpClientRequest>>([])
const response = yield* MockOpenAiResponse
const body = yield* Effect.orDie(encodeResponse(response.body))
const body = response.body === undefined ? undefined : yield* Effect.orDie(encodeResponse(response.body))

const httpClient = HttpClient.makeWith(
Effect.fnUntraced(function*(requestEffect) {
const request = yield* requestEffect
yield* Ref.update(capturedRequests, Array.append(request))
return HttpClientResponse.fromWeb(
request,
new Response(JSON.stringify(body), {
headers: response.headers ?? {},
status: response.status
})
new Response(
response.events === undefined
? JSON.stringify(body)
: response.events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""),
{
headers: {
"content-type": response.events === undefined ? "application/json" : "text/event-stream",
...response.headers
},
status: response.status
}
)
)
}),
Effect.succeed as HttpClient.HttpClient.Preprocess<HttpClientError.HttpClientError, never>
Expand Down Expand Up @@ -1424,6 +1481,15 @@ const makeStreamTestLayer = (events: ReadonlyArray<typeof Generated.ResponseStre
)
}

const makeStreamHttpTestLayer = (events: ReadonlyArray<typeof Generated.ResponseStreamEvent.Type>) =>
OpenAiClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe(
Layer.provideMerge(HttpClientLayer),
Layer.provide(Layer.succeed(MockOpenAiResponse, {
events,
status: 200
}))
)

const makeDefaultResponse = (
overrides: Partial<Generated.Response> = {}
): Generated.Response => ({
Expand Down
19 changes: 19 additions & 0 deletions packages/ai/openai/test/OpenAiSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,25 @@ const makeResponse = (overrides: Record<string, unknown> = {}) => ({
})

describe("OpenAiSchema", () => {
it("validates Responses safety and prompt-cache request options", () => {
const decoded = Schema.decodeUnknownSync(OpenAiSchema.CreateResponse)({
safety_identifier: "user-hash-123",
prompt_cache_key: "reviewer:v1",
prompt_cache_retention: "24h",
prompt_cache_options: {
mode: "explicit",
ttl: "30m"
}
})

assert.strictEqual(decoded.safety_identifier, "user-hash-123")
assert.throws(() =>
Schema.decodeUnknownSync(OpenAiSchema.CreateResponse)({
safety_identifier: "x".repeat(65)
})
)
})

it("decodes a representative response payload", () => {
const decoded = Schema.decodeUnknownSync(OpenAiSchema.Response)({
...makeResponse(),
Expand Down
Loading