From 0fa4795e02b1646b63c82673eb1107b162cc83e7 Mon Sep 17 00:00:00 2001 From: MarkXian Date: Thu, 23 Jul 2026 12:31:40 +0800 Subject: [PATCH] fix(ai): keep provider headers redacted --- .../fix-ai-redacted-provider-headers.md | 6 +++ packages/ai/openai-compat/src/OpenAiClient.ts | 36 +++++++++++------- .../openai-compat/test/OpenAiClient.test.ts | 38 ++++++++++++++++++- packages/ai/openai/src/OpenAiClient.ts | 36 +++++++++++------- .../ai/openai/src/OpenAiClientGenerated.ts | 17 ++++++--- packages/ai/openai/test/OpenAiClient.test.ts | 32 ++++++++++++++++ 6 files changed, 130 insertions(+), 35 deletions(-) create mode 100644 .changeset/fix-ai-redacted-provider-headers.md 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) =>