diff --git a/src/client.ts b/src/client.ts index 3d30cb4..a6616fa 100644 --- a/src/client.ts +++ b/src/client.ts @@ -18,8 +18,41 @@ export interface InterfazeOptions extends ClientOptions { adminKey?: string; } +function envVar(name: string): string | undefined { + return typeof process !== "undefined" && process.env ? process.env[name] : undefined; +} + +/** + * The openai client merges `OPENAI_CUSTOM_HEADERS` into every request. Those headers + * are for api.openai.com (or a gateway in front of it), so null them out here; a + * null default header tells the openai client to drop it. + */ +function openaiEnvHeaders(): Record { + const out: Record = {}; + for (const line of envVar("OPENAI_CUSTOM_HEADERS")?.split("\n") ?? []) { + const colon = line.indexOf(":"); + if (colon > 0) out[line.slice(0, colon).trim()] = null; + } + return out; +} + +/** + * `defaultHeaders` is openai's `HeadersLike`, so it may be a `Headers` instance or an + * array of tuples. Spreading either gives `{}` or `{"0": [name, value]}`, which silently + * drops every header the caller set. + */ +function headerRecord(given: ClientOptions["defaultHeaders"]): Record { + if (!given) return {}; + if ((typeof Headers !== "undefined" && given instanceof Headers) || Array.isArray(given)) { + const out: Record = {}; + new Headers(given as HeadersInit).forEach((value, name) => (out[name] = value)); + return out; + } + return { ...given }; +} + function envKey(): string | undefined { - return typeof process !== "undefined" && process.env ? process.env["INTERFAZE_API_KEY"] : undefined; + return envVar("INTERFAZE_API_KEY"); } /** Interfaze API client — an OpenAI-compatible client wired to the Interfaze endpoint. */ @@ -37,7 +70,7 @@ export class Interfaze { throw new InterfazeError("Missing API key. Pass `new Interfaze({ apiKey })` or set the INTERFAZE_API_KEY environment variable."); } - const headers: Record = { ...(defaultHeaders as Record | undefined) }; + const headers = { ...openaiEnvHeaders(), ...headerRecord(defaultHeaders) } as Record; if (showAdditionalInfo) headers[HEADERS.showAdditionalInfo] = "true"; if (bypassMoA) headers[HEADERS.bypassMoA] = "true"; if (bypassCache) headers[HEADERS.bypassCache] = "true"; @@ -47,6 +80,10 @@ export class Interfaze { ...rest, apiKey: resolvedKey, baseURL: baseURL ?? INTERFAZE_BASE_URL, + // Left undefined, these fall back to OPENAI_ORG_ID / OPENAI_PROJECT_ID and get + // sent to Interfaze as OpenAI-Organization / OpenAI-Project headers. + organization: rest.organization ?? null, + project: rest.project ?? null, defaultHeaders: headers, timeout: rest.timeout ?? DEFAULT_TIMEOUT_MS, }); diff --git a/test/client.test.ts b/test/client.test.ts index 7889538..0ffa0c7 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { Interfaze, InterfazeError } from "../src/index.js"; +import { Interfaze, InterfazeError, type InterfazeOptions } from "../src/index.js"; +import { completion, jsonResponse, mockInterfaze } from "./helpers.js"; describe("client construction", () => { const saved = process.env["INTERFAZE_API_KEY"]; @@ -54,3 +55,60 @@ describe("client construction", () => { expect((i.openai as unknown as { timeout: number }).timeout).toBe(30_000); }); }); + +describe("OPENAI_* environment isolation", () => { + const vars = ["OPENAI_ORG_ID", "OPENAI_PROJECT_ID", "OPENAI_CUSTOM_HEADERS"] as const; + const saved = Object.fromEntries(vars.map((v) => [v, process.env[v]])); + beforeEach(() => { + process.env["OPENAI_ORG_ID"] = "org-from-env"; + process.env["OPENAI_PROJECT_ID"] = "proj-from-env"; + process.env["OPENAI_CUSTOM_HEADERS"] = "Helicone-Auth: Bearer sk-gateway\nx-team: platform"; + }); + afterEach(() => { + for (const v of vars) { + if (saved[v] === undefined) delete process.env[v]; + else process.env[v] = saved[v]; + } + }); + + it("does not send OpenAI org, project or custom env headers to Interfaze", async () => { + const { interfaze, calls } = mockInterfaze(() => jsonResponse(completion())); + await interfaze.chat.completions.create({ messages: [{ role: "user", content: "hi" }] }); + + const headers = calls[0]!.headers; + expect(headers.get("openai-organization")).toBeNull(); + expect(headers.get("openai-project")).toBeNull(); + expect(headers.get("helicone-auth")).toBeNull(); + expect(headers.get("x-team")).toBeNull(); + expect(headers.get("authorization")).toBe("Bearer test-key"); + }); + + it("still sends values the caller passes explicitly", async () => { + const { interfaze, calls } = mockInterfaze(() => jsonResponse(completion()), { + organization: "org-explicit", + defaultHeaders: { "x-team": "explicit" }, + }); + await interfaze.chat.completions.create({ messages: [{ role: "user", content: "hi" }] }); + + const headers = calls[0]!.headers; + expect(headers.get("openai-organization")).toBe("org-explicit"); + expect(headers.get("x-team")).toBe("explicit"); + }); +}); + +describe("defaultHeaders", () => { + it.each([ + ["a plain object", { "x-trace": "abc" }], + ["a Headers instance", new Headers({ "x-trace": "abc" })], + ["an array of tuples", [["x-trace", "abc"]]], + ])("forwards headers given as %s", async (_label, defaultHeaders) => { + const { interfaze, calls } = mockInterfaze(() => jsonResponse(completion()), { + defaultHeaders: defaultHeaders as InterfazeOptions["defaultHeaders"], + bypassCache: true, + }); + await interfaze.chat.completions.create({ messages: [{ role: "user", content: "hi" }] }); + + expect(calls[0]!.headers.get("x-trace")).toBe("abc"); + expect(calls[0]!.headers.get("x-interfaze-bypass-cache")).toBe("true"); + }); +});