Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/api-reference/openai.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ POST /openai/v1/responses
```

<Note>
These endpoints receive PII detection and masking. Other OpenAI endpoints (`/models`, `/embeddings`, `/files`, etc.) are proxied directly without modification.
These endpoints receive PII detection and masking. Other OpenAI endpoints (`/models`, `/embeddings`, `/files`, etc.) pass their request bodies through unchanged.
</Note>

## Chat Completions request
Expand Down
24 changes: 9 additions & 15 deletions docs/configuration/providers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ providers:
| Option | Description |
|--------|-------------|
| `base_url` | API endpoint (any OpenAI-compatible URL) |
| `api_key` | Optional. Used if client doesn't send Authorization header |
| `api_key` | Optional fallback Bearer token |

### Compatible APIs

Expand Down Expand Up @@ -72,7 +72,7 @@ providers:
| Option | Description |
|--------|-------------|
| `base_url` | Anthropic API endpoint |
| `api_key` | Optional. Used if client doesn't send `x-api-key` header |
| `api_key` | Optional fallback `x-api-key` |

## Codex CLI Backend

Expand Down Expand Up @@ -145,20 +145,14 @@ local:
api_key: ${LOCAL_API_KEY} # if required
```

## API Key Handling
## Client Headers

PasteGuard forwards your client's authentication headers to the configured upstream. You can optionally set `api_key` in config as a fallback for OpenAI and Anthropic. Codex CLI traffic uses the CLI's ChatGPT-login authorization header:
OpenAI, Anthropic, and Codex exclude `x-pasteguard-*`, Open WebUI identity headers (`x-openwebui-user-*`, `x-openwebui-chat-id`), and original host/connection headers. Other headers pass through unmasked.

```yaml
providers:
openai:
base_url: https://api.openai.com/v1
api_key: ${OPENAI_API_KEY} # Used if client doesn't send auth
JSON calls rebuild body headers; passthrough preserves content type, encoding, and length.

anthropic:
base_url: https://api.anthropic.com
api_key: ${ANTHROPIC_API_KEY} # Used if client doesn't send x-api-key
## API Key Handling

codex:
base_url: https://chatgpt.com/backend-api/codex
```
Client auth takes priority. Configured keys are fallbacks for protected OpenAI/Anthropic calls. Local reroutes use local credentials.

Anthropic supplies `anthropic-version: 2023-06-01` only when absent.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pasteguard",
"version": "0.9.4",
"version": "0.9.5",
"description": "Privacy proxy for LLMs. Masks personal data and secrets before sending to your provider.",
"type": "module",
"main": "src/index.ts",
Expand Down
36 changes: 5 additions & 31 deletions src/providers/anthropic/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import { type AnthropicProviderConfig, getConfig } from "../../config";
import { ProviderError } from "../errors";
import { buildUpstreamHeaders } from "../forwarded-headers";
import type { AnthropicRequest, AnthropicResponse } from "./types";

export const ANTHROPIC_VERSION = "2023-06-01";
Expand All @@ -24,47 +25,20 @@ export type AnthropicResult =
model: string;
};

/**
* Client headers forwarded from the request
*/
export interface AnthropicClientHeaders {
apiKey?: string;
authorization?: string;
beta?: string;
}

/**
* Call Anthropic Messages API
*
* Transparent header forwarding - all auth headers from client are passed through.
* Config api_key is only used as fallback when no client auth headers present.
*/
export async function callAnthropic(
request: AnthropicRequest,
config: AnthropicProviderConfig,
clientHeaders?: AnthropicClientHeaders,
clientHeaders: Record<string, string> = {},
): Promise<AnthropicResult> {
const isStreaming = request.stream ?? false;
const baseUrl = (config.base_url || DEFAULT_ANTHROPIC_URL).replace(/\/$/, "");

const headers: Record<string, string> = {
"Content-Type": "application/json",
"anthropic-version": ANTHROPIC_VERSION,
};

// Transparent auth forwarding - client headers take priority
if (clientHeaders?.apiKey) {
headers["x-api-key"] = clientHeaders.apiKey;
} else if (clientHeaders?.authorization) {
headers.Authorization = clientHeaders.authorization;
} else if (config.api_key) {
// Fallback to config only if no client auth
headers["x-api-key"] = config.api_key;
}

// Forward client's beta header unchanged
if (clientHeaders?.beta) {
headers["anthropic-beta"] = clientHeaders.beta;
const headers = buildUpstreamHeaders(clientHeaders, config.api_key, "x-api-key");
if (!headers.has("anthropic-version")) {
headers.set("anthropic-version", ANTHROPIC_VERSION);
}

const timeoutMs = getConfig().server.request_timeout * 1000;
Expand Down
57 changes: 57 additions & 0 deletions src/providers/forwarded-headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Strip identifying metadata and headers tied to the original connection.
const EXCLUDED_HEADERS = new Set([
"connection",
"host",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"x-openwebui-chat-id",
]);

export function forwardClientHeaders(clientHeaders: Record<string, string>): Headers {
const headers = new Headers(clientHeaders);
const connectionHeaders = new Set(
(headers.get("connection") ?? "").split(",").map((name) => name.trim().toLowerCase()),
);

for (const name of Object.keys(clientHeaders)) {
const lower = name.toLowerCase();
if (
lower.startsWith("x-pasteguard-") ||
lower.startsWith("x-openwebui-user-") ||
EXCLUDED_HEADERS.has(lower) ||
connectionHeaders.has(lower)
) {
headers.delete(name);
}
}

return headers;
}

export function buildUpstreamHeaders(
clientHeaders: Record<string, string>,
providerApiKey?: string,
authHeader: "authorization" | "x-api-key" = "authorization",
): Headers {
const headers = forwardClientHeaders(clientHeaders);
headers.delete("content-length");
headers.delete("content-encoding");
headers.set("content-type", "application/json");
if (
providerApiKey &&
!headers.has("authorization") &&
!headers.has("api-key") &&
!headers.has("x-api-key")
) {
headers.set(
authHeader,
authHeader === "authorization" ? `Bearer ${providerApiKey}` : providerApiKey,
);
}
return headers;
}
17 changes: 3 additions & 14 deletions src/providers/local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getConfig, type LocalProviderConfig } from "../config";
import { HEALTH_CHECK_TIMEOUT_MS } from "../constants/timeouts";
import type { AnthropicResult } from "./anthropic/client";
import type { AnthropicRequest, AnthropicResponse } from "./anthropic/types";
import { buildUpstreamHeaders } from "./forwarded-headers";
import { ProviderError, type ProviderResult } from "./openai/client";
import type { OpenAIRequest } from "./openai/types";

Expand All @@ -21,17 +22,12 @@ export async function callLocal(
const endpoint =
config.type === "ollama" ? `${baseUrl}/v1/chat/completions` : `${baseUrl}/chat/completions`;

const headers: Record<string, string> = { "Content-Type": "application/json" };
if (config.api_key) {
headers.Authorization = `Bearer ${config.api_key}`;
}

const isStreaming = request.stream ?? false;
const timeoutMs = getConfig().server.request_timeout * 1000;

const response = await fetch(endpoint, {
method: "POST",
headers,
headers: buildUpstreamHeaders({}, config.api_key),
body: JSON.stringify({ ...request, model: config.model, stream: isStreaming }),
signal: timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined,
});
Expand Down Expand Up @@ -63,19 +59,12 @@ export async function callLocalAnthropic(
// Ollama's Anthropic-compatible endpoint
const endpoint = `${baseUrl}/v1/messages`;

const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (config.api_key) {
headers.Authorization = `Bearer ${config.api_key}`;
}

const isStreaming = request.stream ?? false;
const timeoutMs = getConfig().server.request_timeout * 1000;

const response = await fetch(endpoint, {
method: "POST",
headers,
headers: buildUpstreamHeaders({}, config.api_key),
body: JSON.stringify({ ...request, model: config.model, stream: isStreaming }),
signal: timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined,
});
Expand Down
16 changes: 3 additions & 13 deletions src/providers/openai/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import { getConfig, type OpenAIProviderConfig } from "../../config";
import { ProviderError } from "../errors";
import { buildUpstreamHeaders } from "../forwarded-headers";
import type { OpenAIRequest, OpenAIResponse } from "./types";

export { ProviderError } from "../errors";
Expand All @@ -29,7 +30,7 @@ export type ProviderResult =
export async function callOpenAI(
request: OpenAIRequest,
config: OpenAIProviderConfig,
authHeader?: string,
clientHeaders: Record<string, string> = {},
): Promise<ProviderResult> {
const model = request.model;
const isStreaming = request.stream ?? false;
Expand All @@ -41,17 +42,6 @@ export async function callOpenAI(
const baseUrl = config.base_url.replace(/\/$/, "");
const endpoint = `${baseUrl}/chat/completions`;

const headers: Record<string, string> = {
"Content-Type": "application/json",
};

// Use client's auth header if provided, otherwise fall back to config
if (authHeader) {
headers.Authorization = authHeader;
} else if (config.api_key) {
headers.Authorization = `Bearer ${config.api_key}`;
}

// Build request body
const body: Record<string, unknown> = {
...request,
Expand All @@ -68,7 +58,7 @@ export async function callOpenAI(
const timeoutMs = getConfig().server.request_timeout * 1000;
const response = await fetch(endpoint, {
method: "POST",
headers,
headers: buildUpstreamHeaders(clientHeaders, config.api_key),
body: JSON.stringify(body),
signal: timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined,
});
Expand Down
Loading
Loading