diff --git a/.changeset/fix-ai-redacted-provider-headers.md b/.changeset/fix-ai-redacted-provider-headers.md
new file mode 100644
index 00000000000..b4a25d0e433
--- /dev/null
+++ b/.changeset/fix-ai-redacted-provider-headers.md
@@ -0,0 +1,6 @@
+---
+"@effect/ai-openai": patch
+"@effect/ai-openai-compat": patch
+---
+
+Keep OpenAI provider-specific headers redacted in AI error contexts.
diff --git a/packages/ai/openai-compat/src/OpenAiClient.ts b/packages/ai/openai-compat/src/OpenAiClient.ts
index c1fe575a9a3..0d2604f9e08 100644
--- a/packages/ai/openai-compat/src/OpenAiClient.ts
+++ b/packages/ai/openai-compat/src/OpenAiClient.ts
@@ -103,6 +103,14 @@ const RedactedOpenAiHeaders = {
OpenAiProject: "OpenAI-Project"
}
+const redactedOpenAiHeaderNames = Object.values(RedactedOpenAiHeaders)
+
+const withRedactedOpenAiHeaders = (effect: Effect.Effect): Effect.Effect =>
+ Effect.updateService(effect, Headers.CurrentRedactedNames, Array.appendAll(redactedOpenAiHeaderNames))
+
+const withRedactedOpenAiClient = (client: HttpClient.HttpClient.With): HttpClient.HttpClient.With =>
+ client.pipe(HttpClient.transformResponse(withRedactedOpenAiHeaders))
+
/**
* Constructs an OpenAI-compatible client service from explicit options.
*
@@ -132,7 +140,7 @@ export const make = Effect.fnUntraced(
function*(options: Options): Effect.fn.Return {
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"),
@@ -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)
@@ -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),
@@ -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
@@ -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({
@@ -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 =>
- Effect.flatMap(resolveHttpClient, (client) =>
+ withRedactedOpenAiHeaders(Effect.flatMap(resolveHttpClient, (client) =>
pipe(
HttpClientRequest.post("/embeddings"),
HttpClientRequest.bodyJsonUnsafe(payload),
@@ -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,
@@ -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))
)
/**
diff --git a/packages/ai/openai-compat/test/OpenAiClient.test.ts b/packages/ai/openai-compat/test/OpenAiClient.test.ts
index 2d89ffb26b7..93e0e7644ae 100644
--- a/packages/ai/openai-compat/test/OpenAiClient.test.ts
+++ b/packages/ai/openai-compat/test/OpenAiClient.test.ts
@@ -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", () => {
@@ -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"]), "")
+ assert.strictEqual(String(error.reason.request.headers["openai-organization"]), "")
+ assert.strictEqual(String(error.reason.request.headers["openai-project"]), "")
+ }))
+
it.effect("maps 400 responses to InvalidRequestError", () =>
Effect.gen(function*() {
const client = yield* OpenAiClient.make({
diff --git a/packages/ai/openai/src/OpenAiClient.ts b/packages/ai/openai/src/OpenAiClient.ts
index a1774b6d389..79e0f7ec3c7 100644
--- a/packages/ai/openai/src/OpenAiClient.ts
+++ b/packages/ai/openai/src/OpenAiClient.ts
@@ -158,6 +158,14 @@ const RedactedOpenAiHeaders = {
OpenAiProject: "OpenAI-Project"
}
+const redactedOpenAiHeaderNames = Object.values(RedactedOpenAiHeaders)
+
+const withRedactedOpenAiHeaders = (effect: Effect.Effect): Effect.Effect =>
+ Effect.updateService(effect, Headers.CurrentRedactedNames, Array.appendAll(redactedOpenAiHeaderNames))
+
+const withRedactedOpenAiClient = (client: HttpClient.HttpClient.With): HttpClient.HttpClient.With =>
+ client.pipe(HttpClient.transformResponse(withRedactedOpenAiHeaders))
+
/**
* Creates an OpenAI client service with the given options.
*
@@ -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
@@ -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)
@@ -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)
@@ -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
@@ -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 })
@@ -291,7 +302,7 @@ export const make = Effect.fnUntraced(
"HttpClientError",
(error) => Errors.mapHttpClientError(error, "createResponseStream")
)
- ))
+ )))
})
const decodeEmbedding = HttpClientResponse.schemaBodyJson(OpenAiSchema.CreateEmbeddingResponse)
@@ -299,7 +310,7 @@ export const make = Effect.fnUntraced(
const createEmbedding = (
payload: typeof OpenAiSchema.CreateEmbeddingRequest.Encoded
): Effect.Effect =>
- Effect.flatMap(resolveHttpClient, (client) =>
+ withRedactedOpenAiHeaders(Effect.flatMap(resolveHttpClient, (client) =>
client.execute(
HttpClientRequest.post("/embeddings", {
body: HttpBody.jsonUnsafe(payload)
@@ -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,
@@ -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))
)
// =============================================================================
diff --git a/packages/ai/openai/src/OpenAiClientGenerated.ts b/packages/ai/openai/src/OpenAiClientGenerated.ts
index 1b2c9b01f56..53d436295bb 100644
--- a/packages/ai/openai/src/OpenAiClientGenerated.ts
+++ b/packages/ai/openai/src/OpenAiClientGenerated.ts
@@ -74,6 +74,14 @@ const RedactedOpenAiHeaders = {
OpenAiProject: "OpenAI-Project"
}
+const redactedOpenAiHeaderNames = Object.values(RedactedOpenAiHeaders)
+
+const withRedactedOpenAiHeaders = (effect: Effect.Effect): Effect.Effect =>
+ Effect.updateService(effect, Headers.CurrentRedactedNames, Array.appendAll(redactedOpenAiHeaderNames))
+
+const withRedactedOpenAiClient = (client: HttpClient.HttpClient.With): HttpClient.HttpClient.With =>
+ client.pipe(HttpClient.transformResponse(withRedactedOpenAiHeaders))
+
// =============================================================================
// Constructor
// =============================================================================
@@ -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))
)
// =============================================================================
diff --git a/packages/ai/openai/test/OpenAiClient.test.ts b/packages/ai/openai/test/OpenAiClient.test.ts
index d9069fd5af7..409c494b59e 100644
--- a/packages/ai/openai/test/OpenAiClient.test.ts
+++ b/packages/ai/openai/test/OpenAiClient.test.ts
@@ -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"]), "")
+ assert.strictEqual(String(result.reason.request.headers["openai-organization"]), "")
+ assert.strictEqual(String(result.reason.request.headers["openai-project"]), "")
+ }))
+
it.effect("maps 400 status to InvalidRequestError reason", () =>
Effect.gen(function*() {
const mockClient = makeMockHttpClient((request) =>