Skip to content

Commit 8a87d86

Browse files
patel-lyzrclaude
andcommitted
feat(gateway): native Anthropic passthrough for redacted requests
Policy-bound (redacting) requests now forward the redacted Anthropic Messages body straight to api.anthropic.com — keeping claude as the model — instead of translating to the OpenAI/Lyzr upstream. The caller's own x-api-key / anthropic-version headers are forwarded verbatim (proxy holds no token), and the response is deanonymized (streaming text_delta included). Fixes the 403 from routing claude agents through the Lyzr upstream: PII redaction now works WITHOUT changing the model backend or depending on the Lyzr token. The OpenAI/Lyzr translation path remains for non-policy requests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b2a0b30 commit 8a87d86

1 file changed

Lines changed: 128 additions & 0 deletions

File tree

packages/llm-proxy-openai/src/proxy.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ export interface RedactionConfig {
4444
* un-redacted (availability over privacy); `false` fails the request closed.
4545
*/
4646
readonly failOpen?: boolean;
47+
/**
48+
* Native Anthropic backend for the redacting path. When set (default
49+
* `https://api.anthropic.com`), a policy-scoped request (`/rai/<id>`) is
50+
* redacted and then forwarded **as Anthropic Messages** to this base —
51+
* keeping the model's own backend (claude) instead of translating to the
52+
* OpenAI/Lyzr upstream. The caller's `x-api-key` is forwarded verbatim.
53+
*/
54+
readonly anthropicBase?: string;
4755
}
4856

4957
export interface ProxyOptions {
@@ -187,6 +195,11 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse, ctx: Han
187195
if (Object.keys(redactionMap).length) {
188196
ctx.log(`🔒 redacted ${Object.keys(redactionMap).length} PII span(s) via policy ${ctx.policyId}`);
189197
}
198+
// Native Anthropic backend: forward the redacted Anthropic body straight to
199+
// api.anthropic.com (keeping claude as the model), rather than translating
200+
// to the OpenAI/Lyzr upstream. The caller's own x-api-key is forwarded.
201+
await forwardAnthropic(req, res, ctx, body, redactionMap);
202+
return;
190203
}
191204

192205
const wantStream = Boolean(body.stream);
@@ -384,6 +397,121 @@ function blockedAnthropicResponse(model: string, reason?: string): AnthropicResp
384397
};
385398
}
386399

400+
// ── Native Anthropic passthrough (redact → forward to api.anthropic.com) ────
401+
402+
/**
403+
* Forward an (already redacted) Anthropic Messages request straight to the
404+
* Anthropic API — no OpenAI/Lyzr translation, so the model stays claude. The
405+
* caller's own `x-api-key` / `anthropic-version` headers are forwarded verbatim
406+
* (the proxy holds no Anthropic token of its own). The response is deanonymized
407+
* so the agent sees the original PII values restored.
408+
*/
409+
async function forwardAnthropic(
410+
req: IncomingMessage,
411+
res: ServerResponse,
412+
ctx: HandlerCtx,
413+
body: AnthropicRequest,
414+
redactionMap: Record<string, string>,
415+
): Promise<void> {
416+
const base = (ctx.redaction?.anthropicBase ?? "https://api.anthropic.com").replace(/\/+$/, "");
417+
const url = `${base}/v1/messages`;
418+
const wantStream = Boolean(body.stream);
419+
420+
const headers: Record<string, string> = {
421+
"content-type": "application/json",
422+
"accept": wantStream ? "text/event-stream" : "application/json",
423+
};
424+
for (const h of ["x-api-key", "authorization", "anthropic-version", "anthropic-beta"]) {
425+
const v = req.headers[h];
426+
if (typeof v === "string") headers[h] = v;
427+
}
428+
if (!headers["anthropic-version"]) headers["anthropic-version"] = "2023-06-01";
429+
430+
ctx.log(`→ anthropic passthrough model=${body.model} stream=${wantStream} msgs=${body.messages.length}`);
431+
432+
let up: Response;
433+
try {
434+
up = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) });
435+
} catch (e) {
436+
ctx.log("anthropic fetch failed:", (e as Error).message);
437+
res.writeHead(502, { "content-type": "application/json" });
438+
res.end(JSON.stringify({ error: { type: "api_error", message: `anthropic unreachable: ${(e as Error).message}` } }));
439+
return;
440+
}
441+
442+
if (!up.ok) {
443+
const text = await up.text();
444+
ctx.log(`anthropic ${up.status}:`, text.slice(0, 200));
445+
res.writeHead(up.status, { "content-type": "application/json" });
446+
res.end(text || JSON.stringify({ error: { type: "api_error", message: `anthropic ${up.status}` } }));
447+
return;
448+
}
449+
450+
if (wantStream) {
451+
res.writeHead(200, {
452+
"content-type": "text/event-stream",
453+
"cache-control": "no-cache",
454+
"connection": "keep-alive",
455+
});
456+
await pipeAnthropicPassthrough(up, res, redactionMap);
457+
return;
458+
}
459+
460+
const j = (await up.json()) as AnthropicResponse;
461+
deanonymizeAnthropicResponse(j, redactionMap);
462+
res.writeHead(200, { "content-type": "application/json" });
463+
res.end(JSON.stringify(j));
464+
}
465+
466+
/** Stream Anthropic SSE through unchanged, deanonymizing `text_delta` events. */
467+
async function pipeAnthropicPassthrough(
468+
up: Response,
469+
out: ServerResponse,
470+
map: Record<string, string>,
471+
): Promise<void> {
472+
const body = up.body;
473+
if (!body) { out.end(); return; }
474+
const hasMap = Object.keys(map).length > 0;
475+
const reader = body.getReader();
476+
const decoder = new TextDecoder("utf-8");
477+
let buf = "";
478+
while (true) {
479+
const { done, value } = await reader.read();
480+
if (done) break;
481+
buf += decoder.decode(value, { stream: true });
482+
let idx: number;
483+
while ((idx = buf.indexOf("\n\n")) !== -1) {
484+
const frame = buf.slice(0, idx);
485+
buf = buf.slice(idx + 2);
486+
out.write(transformAnthropicFrame(frame, map, hasMap) + "\n\n");
487+
}
488+
}
489+
if (buf) out.write(buf);
490+
out.end();
491+
}
492+
493+
/** Rewrite the `text_delta` text of a content_block_delta frame, deanonymized. */
494+
function transformAnthropicFrame(frame: string, map: Record<string, string>, hasMap: boolean): string {
495+
if (!hasMap || !frame.includes("text_delta")) return frame;
496+
return frame
497+
.split("\n")
498+
.map((line) => {
499+
if (!line.startsWith("data: ")) return line;
500+
try {
501+
const d = JSON.parse(line.slice(6)) as {
502+
type?: string;
503+
delta?: { type?: string; text?: string };
504+
};
505+
if (d?.type === "content_block_delta" && d.delta?.type === "text_delta" && typeof d.delta.text === "string") {
506+
d.delta.text = applyMapping(d.delta.text, map);
507+
return "data: " + JSON.stringify(d);
508+
}
509+
} catch { /* not JSON — pass through */ }
510+
return line;
511+
})
512+
.join("\n");
513+
}
514+
387515
// ── REQUEST: Anthropic /v1/messages → OpenAI chat completions ─────────────
388516

389517
interface AnthropicRequest {

0 commit comments

Comments
 (0)