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/fix-ai-redacted-provider-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@effect/ai-openai": patch
"@effect/ai-openai-compat": patch
---

Keep OpenAI provider-specific headers redacted in AI error contexts.
36 changes: 22 additions & 14 deletions packages/ai/openai-compat/src/OpenAiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,14 @@ const RedactedOpenAiHeaders = {
OpenAiProject: "OpenAI-Project"
}

const redactedOpenAiHeaderNames = Object.values(RedactedOpenAiHeaders)

const withRedactedOpenAiHeaders = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
Effect.updateService(effect, Headers.CurrentRedactedNames, Array.appendAll(redactedOpenAiHeaderNames))

const withRedactedOpenAiClient = <E, R>(client: HttpClient.HttpClient.With<E, R>): HttpClient.HttpClient.With<E, R> =>
client.pipe(HttpClient.transformResponse(withRedactedOpenAiHeaders))

/**
* Constructs an OpenAI-compatible client service from explicit options.
*
Expand Down Expand Up @@ -132,7 +140,7 @@ export const make = Effect.fnUntraced(
function*(options: Options): Effect.fn.Return<Service, never, HttpClient.HttpClient> {
const baseClient = yield* HttpClient.HttpClient

const httpClient = baseClient.pipe(
const configuredHttpClient = baseClient.pipe(
HttpClient.mapRequest((request) =>
request.pipe(
HttpClientRequest.prependUrl(options.apiUrl ?? "https://api.openai.com/v1"),
Expand All @@ -158,13 +166,16 @@ export const make = Effect.fnUntraced(
? options.transformClient
: identity
)
const httpClient = withRedactedOpenAiClient(configuredHttpClient)

const resolveHttpClient = Effect.map(
OpenAiConfig.getOrUndefined,
(config) =>
config?.transformClient !== undefined
? config.transformClient(httpClient)
: httpClient
withRedactedOpenAiClient(
config?.transformClient !== undefined
? config.transformClient(configuredHttpClient)
: configuredHttpClient
)
)

const decodeResponse = HttpClientResponse.schemaBodyJson(ChatCompletionResponse)
Expand All @@ -175,7 +186,7 @@ export const make = Effect.fnUntraced(
[body: CreateResponse200, response: HttpClientResponse.HttpClientResponse],
AiError.AiError
> =>
Effect.flatMap(resolveHttpClient, (client) =>
withRedactedOpenAiHeaders(Effect.flatMap(resolveHttpClient, (client) =>
pipe(
HttpClientRequest.post("/chat/completions"),
HttpClientRequest.bodyJsonUnsafe(payload),
Expand All @@ -192,7 +203,7 @@ export const make = Effect.fnUntraced(
HttpClientError: (error) => Errors.mapHttpClientError(error, "createResponse"),
SchemaError: (error) => Effect.fail(Errors.mapSchemaError(error, "createResponse"))
})
))
)))

const buildResponseStream = (
response: HttpClientResponse.HttpClientResponse
Expand All @@ -217,7 +228,7 @@ export const make = Effect.fnUntraced(
}

const createResponseStream: Service["createResponseStream"] = (payload) =>
Effect.flatMap(resolveHttpClient, (client) =>
withRedactedOpenAiHeaders(Effect.flatMap(resolveHttpClient, (client) =>
pipe(
HttpClientRequest.post("/chat/completions"),
HttpClientRequest.bodyJsonUnsafe({
Expand All @@ -233,14 +244,14 @@ export const make = Effect.fnUntraced(
"HttpClientError",
(error) => Errors.mapHttpClientError(error, "createResponseStream")
)
))
)))

const decodeEmbedding = HttpClientResponse.schemaBodyJson(CreateEmbeddingResponseSchema)

const createEmbedding = (
payload: CreateEmbeddingRequestJson
): Effect.Effect<CreateEmbedding200, AiError.AiError> =>
Effect.flatMap(resolveHttpClient, (client) =>
withRedactedOpenAiHeaders(Effect.flatMap(resolveHttpClient, (client) =>
pipe(
HttpClientRequest.post("/embeddings"),
HttpClientRequest.bodyJsonUnsafe(payload),
Expand All @@ -250,7 +261,7 @@ export const make = Effect.fnUntraced(
HttpClientError: (error) => Errors.mapHttpClientError(error, "createEmbedding"),
SchemaError: (error) => Effect.fail(Errors.mapSchemaError(error, "createEmbedding"))
})
))
)))

return OpenAiClient.of({
client: httpClient,
Expand All @@ -259,10 +270,7 @@ export const make = Effect.fnUntraced(
createEmbedding
})
},
Effect.updateService(
Headers.CurrentRedactedNames,
Array.appendAll(Object.values(RedactedOpenAiHeaders))
)
Effect.updateService(Headers.CurrentRedactedNames, Array.appendAll(redactedOpenAiHeaderNames))
)

/**
Expand Down
38 changes: 37 additions & 1 deletion packages/ai/openai-compat/test/OpenAiClient.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as OpenAiClient from "@effect/ai-openai-compat/OpenAiClient"
import { assert, describe, it } from "@effect/vitest"
import { Effect, Layer, Redacted, Stream } from "effect"
import { HttpClient, type HttpClientError, type HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { HttpClient, HttpClientError, type HttpClientRequest, HttpClientResponse } from "effect/unstable/http"

describe("OpenAiClient", () => {
describe("request behavior", () => {
Expand Down Expand Up @@ -333,6 +333,42 @@ describe("OpenAiClient", () => {
})

describe("error mapping", () => {
it.effect("redacts OpenAI-specific headers in AI error context", () =>
Effect.gen(function*() {
const client = yield* OpenAiClient.make({
apiKey: Redacted.make("sk-test-key"),
organizationId: Redacted.make("org-secret"),
projectId: Redacted.make("proj-secret")
}).pipe(
Effect.provide(Layer.succeed(
HttpClient.HttpClient,
makeHttpClient((request) =>
Effect.fail(
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request,
cause: new Error("Connection refused")
})
})
)
)
))
)

const error = yield* client.createResponse({
model: "gpt-4o-mini",
messages: [{ role: "user", content: "hello" }]
}).pipe(Effect.flip)

assert.strictEqual(error.reason._tag, "NetworkError")
if (error.reason._tag !== "NetworkError") {
return
}
assert.strictEqual(String(error.reason.request.headers["authorization"]), "<redacted>")
assert.strictEqual(String(error.reason.request.headers["openai-organization"]), "<redacted>")
assert.strictEqual(String(error.reason.request.headers["openai-project"]), "<redacted>")
}))

it.effect("maps 400 responses to InvalidRequestError", () =>
Effect.gen(function*() {
const client = yield* OpenAiClient.make({
Expand Down
36 changes: 22 additions & 14 deletions packages/ai/openai/src/OpenAiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,14 @@ const RedactedOpenAiHeaders = {
OpenAiProject: "OpenAI-Project"
}

const redactedOpenAiHeaderNames = Object.values(RedactedOpenAiHeaders)

const withRedactedOpenAiHeaders = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
Effect.updateService(effect, Headers.CurrentRedactedNames, Array.appendAll(redactedOpenAiHeaderNames))

const withRedactedOpenAiClient = <E, R>(client: HttpClient.HttpClient.With<E, R>): HttpClient.HttpClient.With<E, R> =>
client.pipe(HttpClient.transformResponse(withRedactedOpenAiHeaders))

/**
* Creates an OpenAI client service with the given options.
*
Expand Down Expand Up @@ -190,7 +198,7 @@ export const make = Effect.fnUntraced(
const baseClient = yield* HttpClient.HttpClient
const apiUrl = options.apiUrl ?? "https://api.openai.com/v1"

const httpClient = baseClient.pipe(
const configuredHttpClient = baseClient.pipe(
HttpClient.mapRequest(Function.flow(
HttpClientRequest.prependUrl(apiUrl),
options.apiKey
Expand All @@ -215,13 +223,16 @@ export const make = Effect.fnUntraced(
? options.transformClient
: identity
)
const httpClient = withRedactedOpenAiClient(configuredHttpClient)

const resolveHttpClient = Effect.map(
OpenAiConfig.getOrUndefined,
(config) =>
Predicate.isNotUndefined(config?.transformClient)
? config.transformClient(httpClient)
: httpClient
withRedactedOpenAiClient(
Predicate.isNotUndefined(config?.transformClient)
? config.transformClient(configuredHttpClient)
: configuredHttpClient
)
)

const decodeResponse = HttpClientResponse.schemaBodyJson(OpenAiSchema.Response)
Expand All @@ -232,7 +243,7 @@ export const make = Effect.fnUntraced(
[body: typeof OpenAiSchema.Response.Type, response: HttpClientResponse.HttpClientResponse],
AiError.AiError
> =>
Effect.flatMap(resolveHttpClient, (client) =>
withRedactedOpenAiHeaders(Effect.flatMap(resolveHttpClient, (client) =>
client.execute(
HttpClientRequest.post("/responses", {
body: HttpBody.jsonUnsafe(payload)
Expand All @@ -250,7 +261,7 @@ export const make = Effect.fnUntraced(
HttpClientError: (error) => Errors.mapHttpClientError(error, "createResponse"),
SchemaError: (error) => Effect.fail(Errors.mapSchemaError(error, "createResponse"))
})
))
)))

const buildResponseStream = (
response: HttpClientResponse.HttpClientResponse
Expand Down Expand Up @@ -280,7 +291,7 @@ export const make = Effect.fnUntraced(
Effect.contextWith((services) => {
const socket = Context.getOrUndefined(services, OpenAiSocket)
if (socket) return socket.createResponseStream(payload)
return Effect.flatMap(resolveHttpClient, (client) =>
return withRedactedOpenAiHeaders(Effect.flatMap(resolveHttpClient, (client) =>
client.execute(
HttpClientRequest.post("/responses", {
body: HttpBody.jsonUnsafe({ ...payload, stream: true })
Expand All @@ -291,15 +302,15 @@ export const make = Effect.fnUntraced(
"HttpClientError",
(error) => Errors.mapHttpClientError(error, "createResponseStream")
)
))
)))
})

const decodeEmbedding = HttpClientResponse.schemaBodyJson(OpenAiSchema.CreateEmbeddingResponse)

const createEmbedding = (
payload: typeof OpenAiSchema.CreateEmbeddingRequest.Encoded
): Effect.Effect<typeof OpenAiSchema.CreateEmbeddingResponse.Type, AiError.AiError> =>
Effect.flatMap(resolveHttpClient, (client) =>
withRedactedOpenAiHeaders(Effect.flatMap(resolveHttpClient, (client) =>
client.execute(
HttpClientRequest.post("/embeddings", {
body: HttpBody.jsonUnsafe(payload)
Expand All @@ -310,7 +321,7 @@ export const make = Effect.fnUntraced(
HttpClientError: (error) => Errors.mapHttpClientError(error, "createEmbedding"),
SchemaError: (error) => Effect.fail(Errors.mapSchemaError(error, "createEmbedding"))
})
))
)))

return OpenAiClient.of({
client: httpClient,
Expand All @@ -319,10 +330,7 @@ export const make = Effect.fnUntraced(
createEmbedding
})
},
Effect.updateService(
Headers.CurrentRedactedNames,
Array.appendAll(Object.values(RedactedOpenAiHeaders))
)
Effect.updateService(Headers.CurrentRedactedNames, Array.appendAll(redactedOpenAiHeaderNames))
)

// =============================================================================
Expand Down
17 changes: 11 additions & 6 deletions packages/ai/openai/src/OpenAiClientGenerated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ const RedactedOpenAiHeaders = {
OpenAiProject: "OpenAI-Project"
}

const redactedOpenAiHeaderNames = Object.values(RedactedOpenAiHeaders)

const withRedactedOpenAiHeaders = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
Effect.updateService(effect, Headers.CurrentRedactedNames, Array.appendAll(redactedOpenAiHeaderNames))

const withRedactedOpenAiClient = <E, R>(client: HttpClient.HttpClient.With<E, R>): HttpClient.HttpClient.With<E, R> =>
client.pipe(HttpClient.transformResponse(withRedactedOpenAiHeaders))

// =============================================================================
// Constructor
// =============================================================================
Expand Down Expand Up @@ -112,22 +120,19 @@ export const make = Effect.fnUntraced(
options.transformClient
? options.transformClient
: identity
)
).pipe(withRedactedOpenAiClient)

return Generated.make(httpClient, {
transformClient: Effect.fnUntraced(function*(client) {
const config = yield* OpenAiConfig.getOrUndefined
if (Predicate.isNotUndefined(config?.transformClient)) {
return config.transformClient(client)
return withRedactedOpenAiClient(config.transformClient(client))
}
return client
})
})
},
Effect.updateService(
Headers.CurrentRedactedNames,
Array.appendAll(Object.values(RedactedOpenAiHeaders))
)
Effect.updateService(Headers.CurrentRedactedNames, Array.appendAll(redactedOpenAiHeaderNames))
)

// =============================================================================
Expand Down
32 changes: 32 additions & 0 deletions packages/ai/openai/test/OpenAiClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,38 @@ describe("OpenAiClient", () => {
assert.strictEqual(result.reason._tag, "NetworkError")
}))

it.effect("redacts OpenAI-specific headers in AI error context", () =>
Effect.gen(function*() {
const mockClient = makeMockHttpClient((request) =>
Effect.fail(
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request,
cause: new Error("Connection refused")
})
})
)
)

const client = yield* OpenAiClient.make({
apiKey: Redacted.make("test-key"),
organizationId: Redacted.make("org-secret"),
projectId: Redacted.make("proj-secret")
}).pipe(Effect.provide(Layer.succeed(HttpClient.HttpClient, mockClient)))

const result = yield* client.createResponse({ model: "gpt-4o", input: "test" }).pipe(
Effect.flip
)

assert.strictEqual(result.reason._tag, "NetworkError")
if (result.reason._tag !== "NetworkError") {
return
}
assert.strictEqual(String(result.reason.request.headers["authorization"]), "<redacted>")
assert.strictEqual(String(result.reason.request.headers["openai-organization"]), "<redacted>")
assert.strictEqual(String(result.reason.request.headers["openai-project"]), "<redacted>")
}))

it.effect("maps 400 status to InvalidRequestError reason", () =>
Effect.gen(function*() {
const mockClient = makeMockHttpClient((request) =>
Expand Down