@@ -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
4957export 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
389517interface AnthropicRequest {
0 commit comments