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
41 changes: 39 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, null> {
const out: Record<string, null> = {};
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<string, unknown> {
if (!given) return {};
if ((typeof Headers !== "undefined" && given instanceof Headers) || Array.isArray(given)) {
const out: Record<string, string> = {};
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. */
Expand All @@ -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<string, string> = { ...(defaultHeaders as Record<string, string> | undefined) };
const headers = { ...openaiEnvHeaders(), ...headerRecord(defaultHeaders) } as Record<string, string | null>;
if (showAdditionalInfo) headers[HEADERS.showAdditionalInfo] = "true";
if (bypassMoA) headers[HEADERS.bypassMoA] = "true";
if (bypassCache) headers[HEADERS.bypassCache] = "true";
Expand All @@ -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,
});
Expand Down
60 changes: 59 additions & 1 deletion test/client.test.ts
Original file line number Diff line number Diff line change
@@ -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"];
Expand Down Expand Up @@ -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");
});
});