LLM 인증 프록시 설정하기
LLM 인증 프록시 설정하기 (Set up the LLM auth proxy)
LangSmith가 서명한 JWT를 검증하고 LLM 요청을 업스트림 프로바이더 또는 게이트웨이로 라우팅하는 Envoy 기반 인증 프록시를 배포해요.
LLM 인증 프록시를 사용하면 LangSmith의 모든 모델 호출에 대해 조직이 자체 인증 흐름을 적용할 수 있어요. 이로써 프로바이더 자격 증명이 최종 사용자에게 노출되지 않게 하고 모든 요청을 특정 액터로 추적할 수 있게 해요.
LLM 인증 프록시는 여러분의 환경에서 실행되며 LangSmith와 업스트림 LLM 프로바이더(OpenAI, Anthropic 같은 곳) 또는 게이트웨이(LiteLLM 같은 내부 LLM 게이트웨이) 사이에 있는 Envoy-기반 구성 요소예요. LangSmith는 모든 요청에 단기 유효 JWT(JSON Web Token)로 서명해요. 프록시는 JWT를 검증하고, 선택적으로 프로바이더 자격 증명을 주입하거나 요청·응답 본문을 변환한 다음 요청을 업스트림으로 전달해요. SaaS와 셀프 호스팅 LangSmith 고객 모두 사용할 수 있어요.
정보: LLM 인증 프록시는 LangSmith Enterprise 플랜이 필요해요. 자세한 내용은 Pricing을 참고하거나 영업팀에 문의하세요.
다음과 같은 경우 LLM 인증 프록시를 사용하세요:
- Playground 또는 LLM-as-judge 평가 요청을 자체 프로바이더 게이트웨이에 대해 인증해야 할 때.
- 프로바이더별 API 키나 인증 헤더를 최종 사용자에게 노출하지 않고 주입해야 할 때.
- 요청 또는 응답 본문을 변환해야 할 때 (예: OpenAI 형식과 커스텀 게이트웨이 형식 간 변환).
특히 OAuth2 client_credentials의 경우, 모델 구성의 OAuth 클라이언트 자격 증명은 인증 프록시를 구축하지 않고 워크스페이스 관리자가 셀프서비스로 설정할 수 있는 구성별 대안이에요. 라우팅은 구성 수준에서 상호 배타적이에요 — OAuth가 활성화된 구성은 인증 프록시를 통과하지 않아요.
동작 방식 (How it works)
LangSmith의 각 요청은 프록시에서 다음 단계를 통과해요:
- JWT 검증 (서명, 발급자, 대상)
- 검증된 JWT를 받아 주입할 프로바이더 자격 증명(헤더)을 반환하는 ext_authz 서비스 호출
- 선택적으로 요청·응답 본문을 재작성할 수 있는 ext_proc 변환기 호출 (예: OpenAI 형식과 커스텀 게이트웨이 형식 간 변환)
- 커스텀 헤더(정적 또는 동적)와 함께 요청을 업스트림 프로바이더로 전달
ext_authz 서비스와 변환기 모두 여러분의 환경에서 프록시와 함께 실행되는 고객 배포 구성 요소예요. 사용 사례에 따라 둘 중 하나 또는 둘 다 활성화할 수 있어요.
사전 준비사항 (Prerequisites)
- LangSmith Enterprise 플랜 (SaaS 또는 버전 0.13.33+ 셀프 호스팅)
- Helm 3이 있는 Kubernetes 클러스터
- Envoy v1.37 이상 (Helm 차트는 기본적으로
envoyproxy/envoy:v1.37-latest를 사용) - 업스트림 LLM 프로바이더 또는 게이트웨이 URL (프록시가 요청을 전달할 대상)
참고: 인증 프록시는 현재 Playground, Evals, Fleet, Chat, Insights 기능을 지원해요. Playground와 Evals는 v0.13.33+에서 사용할 수 있어요. Chat과 Insights는 v0.13.39+에서 사용할 수 있어요.
1. JWT 서명 구성하기 (셀프 호스팅 LangSmith 전용) — Configure JWT signing
LangSmith SaaS에서는 이 단계를 건너뛰세요. JWT 서명은 이미 구성되어 있어요.
step CLI(또는 원한다면 내부 프로세스)를 사용해 Ed25519 키 쌍을 생성하세요. Ed25519는 LangSmith가 JWT를 서명하는 데 사용하는 서명 알고리즘이에요. 비공개 키가 각 요청에 서명하고, 인증 프록시는 공개 키만 사용해 서명을 검증해요.
TMPDIR_KEYS="$(mktemp -d)"
step crypto keypair "$TMPDIR_KEYS/pub.pem" "$TMPDIR_KEYS/priv.pem" \
--kty OKP --crv Ed25519 --no-password --insecure
PRIV_JWK=$(step crypto key format --jwk --no-password --insecure < "$TMPDIR_KEYS/priv.pem")
SIGNING_JWKS=$(echo "$PRIV_JWK" | jq -c '{keys: [. + {use: "sig", alg: "EdDSA"}]}')
echo "$SIGNING_JWKS"
Kubernetes 시크릿에 JWKS를 저장하세요:
kubectl create secret generic langsmith-signing-jwks \
--namespace <namespace> \
--from-literal=LANGSMITH_SIGNING_JWKS="$SIGNING_JWKS"
JWKS(JSON Web Key Set)는 암호화 키를 게시하기 위한 표준 JSON 형식이에요. LANGSMITH_SIGNING_JWKS는 Ed25519 비공개 키를 담고 있으며 Kubernetes 시크릿으로 저장돼요. 절대 노출되지 않아요. LangSmith는 해당 공개 키를 자동으로 추출해 /.well-known/jwks.json에서 서빙해요. 인증 프록시는 이 공개 엔드포인트를 가져와 비공개 키 없이 JWT 서명을 검증해요.
LangSmith values.yaml에서 시크릿을 참조하세요:
platformBackend:
deployment:
extraEnv:
- name: LLM_AUTH_PROXY_ISSUER
value: "langsmith" # must match jwtIssuer in the auth proxy chart
- secretRef:
name: langsmith-signing-jwks
LLM_AUTH_PROXY_ISSUER는 서명된 JWT의 iss 클레임을 설정해요. SaaS 기본값과 일치시키려면 langsmith를 사용하거나, 설치를 구분하려면 langsmith:self-hosted:<short_identifier> 같은 커스텀 식별자를 사용하세요. 값은 4단계)의 인증 프록시 차트 jwtIssuer와 일치해야 해요.
2. 조직에 대해 LLM 인증 프록시 활성화하기 (Enable LLM Auth Proxy for your organization)
셀프 호스팅
옵션 A: 특정 조직에 대해 활성화:
LangSmith UI의 Settings 페이지로 이동해 Organizations 옆 왼쪽 상단에서 조직 ID를 복사하세요.
LangSmith PostgreSQL 데이터베이스에 대해 다음을 실행하세요:
UPDATE organizations
SET config = config || '{"can_use_llm_auth_proxy": true}'
WHERE id = '<organization_id>';
옵션 B: 설치 내 모든 조직에 대해 활성화:
LangSmith values.yaml의 commonEnv에 다음을 추가하세요:
commonEnv:
- name: DEFAULT_ORG_FEATURE_CAN_USE_LLM_AUTH_PROXY
value: "true"
참고: 이 설정은 Personal 조직에는 효과가 없어요.
SaaS
조직에 LLM 인증 프록시를 활성화하려면 Support Portal을 통해 기술 지원에 문의하세요.
3. LangSmith에서 조직 설정 구성하기 (Configure organization settings in LangSmith)
LangSmith UI에서 Settings > General로 이동해 다음을 구성하세요:
-
JWT audience: 프록시가 검증할
aud클레임 값 (예:example-audience). 4단계의 인증 프록시 차트jwtAudiences와 일치해야 해요. -
Enable LLM auth proxy: 조직에 대해 토글을 켜세요.
-
Allowed URLs: 프록시가 JWT를 전달하도록 허용되는 대상 URL을 제어해요. 이는 의도하지 않은 호스트로의 자격 증명 전달을 방지해요. 세 가지 옵션 중 하나를 선택하세요:
- Allow all (기본값): 모든 업스트림 URL로의 JWT 전달을 허용해요. 제한 없음과 동일해요.
- Block all: 모든 URL로의 JWT 전달을 차단해요.
- Custom: 허용된 URL 패턴의 명시적 목록을 지정하세요. 빈 문자열과 단독
*는 허용되지 않아요. 컨트롤은 LLM 인증 프록시 토글이 꺼져 있으면 비활성화돼요.
4. 인증 프록시 Helm 차트 설치하기 (Install the auth proxy Helm chart)
LangChain Helm 저장소를 추가하세요:
helm repo add langchain https://langchain-ai.github.io/helm/
helm repo update
업스트림 URL과 JWT 검증 설정이 있는 values.yaml을 만드세요. JWKS 구성에는 두 가지 옵션이 있어요:
jwksUri(권장): LangSmith 인스턴스의/.well-known/jwks.json엔드포인트를 가리켜요. Envoy가 공개 키를 자동으로 가져와 캐시하므로 원활한 키 회전을 지원해요.jwksJson(인라인): JWKS JSON을 직접values.yaml에 붙여넣어요. 테스트 또는 인증 프록시가 LangSmith에 대한 아웃바운드 네트워크 접근이 없는 air-gapped 환경에 사용하세요. 키 회전에는 차트 업데이트가 필요해요. 공개 키 구성 요소만 포함하고d필드(비공개 키)는 생략하세요.
둘 다 설정하면 jwksUri가 우선해요.
authProxy:
upstream: "https://gateway.example.com"
jwtIssuer: "langsmith" # must match LLM_AUTH_PROXY_ISSUER in LangSmith values.yaml
jwtAudiences:
- "example-audience" # must match the org setting in LangSmith
# Option A: remote JWKS (recommended for production)
# Envoy fetches and caches public keys from LangSmith's /.well-known/jwks.json.
jwksUri: "https://langsmith.example.com/.well-known/jwks.json" # self-hosted
# jwksUri: "https://api.smith.langchain.com/.well-known/jwks.json" # SaaS
jwksCacheDurationSeconds: 300
# Option B: inline JWKS (testing or air-gapped environments only)
# Omit the "d" field (private key); include public key components only.
# jwksJson: '{"keys": [{"kty": "OKP", "crv": "Ed25519", "x": "<base64url-public-key>", "use": "sig", "alg": "EdDSA"}]}'
차트를 설치하세요:
helm install langsmith-auth-proxy langchain/langsmith-auth-proxy \
--namespace <your-namespace> \
-f values.yaml
ext_authz 서비스 작성하기 (Write an ext_authz service)
인가 헤더를 추가·제거·편집해야 할 때 ext_authz를 사용하세요. 예를 들어 JWT의 아이덴티티를 기반으로 프로바이더 API 키를 주입하려는 경우예요. 여러분의 서비스는 검증된 JWT와 선택적으로 요청 본문을 받고, 업스트림에 주입할 헤더를 반환해요. 이는 Envoy의 HTTP ext_authz 필터(gRPC 아님)를 사용해요.
values.yaml에서 활성화하세요:
authProxy:
extAuthz:
enabled: true
serviceUrl: "http://my-auth-service:8080"
timeout: "10s"
동작 방식 (How it works)
각 요청을 전달하기 전에 Envoy는 원래 요청과 동일한 HTTP 메서드를 사용해 여러분의 서비스를 <serviceUrl>/check<original_path>로 호출해요. 여러분의 서비스는 x-langsmith-llm-auth 헤더에서 검증된 JWT를 받아요.
여러분의 서비스는 일반 HTTP 응답을 반환해요:
2xx: 요청을 허용해요.allowedUpstreamHeaders패턴(기본값:authorization및x-*)과 일치하는 모든 헤더가 업스트림 요청에 주입돼요. 전달 전에 JWT를 제거하려면 응답에x-envoy-auth-headers-to-remove: x-langsmith-llm-auth를 포함하세요.- non-
2xx: 요청을 거부해요. 상태 코드와allowedClientHeaders패턴(기본값:www-authenticate및x-*)과 일치하는 모든 헤더가 클라이언트에 반환돼요.
배포 옵션 (Deployment options)
ext_authz 서비스는 두 가지 방식으로 실행할 수 있어요:
- Sidecar: 프록시와 같은 파드에서 서비스를 실행해요.
values.yaml의authProxy.deployment.sidecars아래에 컨테이너를 추가하고 필요한 볼륨은authProxy.deployment.volumes아래에 추가하세요.http://localhost:10002같은localhostURL을 사용해요. - 별도 배포: 서비스를 독립적으로 배포하고
extAuthz.serviceUrl을 그 서비스를 가리키게 해요. 클러스터 내 DNS 이름(예:http://my-auth-service.my-namespace.svc.cluster.local:8080) 또는 서비스에 자체 인그레스가 있는 경우 외부 HTTPS URL을 사용해요.
예시 배포 (Sample deployment)
아래 예시는 OAuth2 client credentials 토큰 교환을 수행하는 최소 Python ext_authz 서비스예요. 각 요청 시 만료 전에 구성된 토큰 엔드포인트에서 새로 고친 캐시된 Authorization 헤더를 액세스 토큰과 함께 반환해요. 전체 예시는 차트 저장소의 e2e/oauth/를 참고하세요.
ext-authz-oauth.py:
"""ext_authz service that performs an OAuth2 client-credentials token exchange.
Runs as a sidecar (or standalone service) alongside the main auth-proxy component.
On each ext_authz check request it returns a cached OAuth access token,
refreshing it from the configured token endpoint when expired.
Environment variables:
OAUTH_TOKEN_URL – Token endpoint (e.g. https://login.example.com/oauth/token)
OAUTH_CLIENT_ID – Client ID for the credentials grant
OAUTH_CLIENT_SECRET– Client secret for the credentials grant
OAUTH_SCOPE – (optional) Space-separated scopes to request
LISTEN_PORT – (optional) Port to listen on, default 10002
"""
from http.server import HTTPServer, BaseHTTPRequestHandler
import json
import os
import sys
import threading
import time
import urllib.request
import urllib.parse
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
TOKEN_URL = os.environ["OAUTH_TOKEN_URL"]
CLIENT_ID = os.environ["OAUTH_CLIENT_ID"]
CLIENT_SECRET = os.environ["OAUTH_CLIENT_SECRET"]
SCOPE = os.environ.get("OAUTH_SCOPE", "")
LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "10002"))
# Refresh the token this many seconds before it actually expires.
EXPIRY_BUFFER_SECONDS = 30
# ---------------------------------------------------------------------------
# Token cache (thread-safe)
# ---------------------------------------------------------------------------
_lock = threading.Lock()
_cached_token: str | None = None
_token_expiry: float = 0 # epoch seconds
def _fetch_token() -> tuple[str, float]:
"""Perform a client_credentials grant and return (access_token, expiry_epoch)."""
data = urllib.parse.urlencode({
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
**({"scope": SCOPE} if SCOPE else {}),
}).encode()
req = urllib.request.Request(
TOKEN_URL,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
with urllib.request.urlopen(req, timeout=10) as resp:
body = json.loads(resp.read())
access_token = body["access_token"]
expires_in = int(body.get("expires_in", 3600))
expiry = time.time() + expires_in - EXPIRY_BUFFER_SECONDS
return access_token, expiry
def get_token() -> str:
"""Return a valid access token, refreshing if necessary."""
global _cached_token, _token_expiry
with _lock:
if _cached_token and time.time() < _token_expiry:
return _cached_token
# Fetch outside the lock so other requests aren't blocked on I/O.
token, expiry = _fetch_token()
with _lock:
_cached_token = token
_token_expiry = expiry
print(f"Refreshed OAuth token (expires in {int(expiry - time.time())}s)", flush=True)
return token
# ---------------------------------------------------------------------------
# ext_authz HTTP handler
# ---------------------------------------------------------------------------
class Handler(BaseHTTPRequestHandler):
def do_any(self):
try:
token = get_token()
except Exception as exc:
print(f"OAuth token fetch failed: {exc}", flush=True)
self.send_response(500)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"OAuth token exchange failed")
return
self.send_response(200)
# Replace the header name as needed - this header will be forwarded to the upstream LLM provider / gateway.
self.send_header("Authorization", f"Bearer {token}")
self.end_headers()
# Handle every method Envoy might send for ext_authz checks.
do_GET = do_POST = do_PUT = do_DELETE = do_PATCH = do_HEAD = do_OPTIONS = do_any
def log_message(self, format, *args):
# Quieter logs — only print errors.
pass
if __name__ == "__main__":
server = HTTPServer(("0.0.0.0", LISTEN_PORT), Handler)
print(f"ext-authz-oauth listening on :{LISTEN_PORT}", flush=True)
print(f" token_url={TOKEN_URL} client_id=<redacted>", flush=True)
server.serve_forever()
전체 extAuthz 파라미터 목록은 Helm 차트 README를 참고하세요.
ext_proc 변환기 작성하기 (Write an ext_proc transformer)
요청 또는 응답 본문을 재작성해야 할 때 ext_proc를 사용하세요. 예를 들어 OpenAI 형식과 커스텀 게이트웨이 형식 간 변환하거나 요청 페이로드에 추가 필드를 주입하려는 경우예요. 이는 Envoy의 ext_proc 필터를 사용해요.
ext_authz(HTTP)와 달리 ext_proc는 양방향 gRPC 스트림을 사용해요. Envoy는 각 처리 단계(요청 헤더, 요청 본문, 응답 헤더, 응답 본문)마다 하나의 메시지를 변환기 서비스에 보내고, 여러분의 서비스는 각 단계에 대한 변경(mutation)으로 응답해요. 여러분의 변환기는 envoy.service.ext_proc.v3.ExternalProcessor gRPC 서비스를 구현해야 해요. 예시 Go 구현은 차트 저장소의 e2e/transformer/를 참고하세요.
ext_proc vs ext_authz 사용 시점 (When to use ext_proc vs ext_authz)
| 기능 | ext_authz |
ext_proc |
|---|---|---|
| 요청 헤더 수정 | 예 | 예 |
| 응답 헤더 수정 | 아니요 | 예 |
| 요청 본문 수정 | 아니요 | 예 |
| 응답 본문 수정 | 아니요 | 예 |
| 프로토콜 | HTTP | gRPC |
인증 헤더만 주입하면 되는 경우(예: API 키) ext_authz를 사용하세요. 본문을 재작성해야 하는 경우 ext_proc를 사용하세요. 둘 다 동시에 활성화할 수 있어요.
values.yaml에서 ext_proc를 활성화하세요:
authProxy:
transformer:
enabled: true
serviceUrl: "grpc://my-transformer:50051"
timeout: "10s"
failureModeAllow: false
processingMode:
requestHeaderMode: "SEND"
requestBodyMode: "BUFFERED"
responseHeaderMode: "SKIP"
responseBodyMode: "NONE"
failureModeAllow: true로 설정하면 변환기를 사용할 수 없어도 요청을 통과시켜요. 기본값(false)은 요청을 거부해요.
처리 모드 (Processing modes)
processingMode를 통해 변환기로 전송되는 단계를 제어해요. 필요할 때만 단계를 활성화하세요. 사용하지 않는 단계를 비활성화하면 지연 시간이 줄어들어요.
| 필드 | 옵션 | 설명 |
|---|---|---|
requestHeaderMode |
SEND, SKIP, DEFAULT |
요청 헤더를 전달할지 여부. |
responseHeaderMode |
SEND, SKIP, DEFAULT |
응답 헤더를 전달할지 여부. |
requestBodyMode |
NONE, BUFFERED, STREAMED, BUFFERED_PARTIAL |
요청 본문을 보내는 방법. |
responseBodyMode |
NONE, BUFFERED, STREAMED, BUFFERED_PARTIAL |
응답 본문을 보내는 방법. |
requestTrailerMode |
SEND, SKIP |
요청 트레일러를 전달할지 여부. |
responseTrailerMode |
SEND, SKIP |
응답 트레일러를 전달할지 여부. |
- 요청 본문 재작성에는
BUFFERED를 사용하세요: 보내기 전에 전체 본문을 버퍼링하며, JSON 재작성에 가장 간단해요. - 스트리밍 LLM 응답 본문 재작성에는
STREAMED를 사용하세요: 도착하는 대로 청크를 보내며, 지연 시간이 낮지만 구현이 더 복잡해요. - 단계를 완전히 건너뛰려면
NONE을 사용하세요.
경고: 본문을 변형할 때
ext_proc서비스는HeaderMutation을 통해 새 본문 크기와 일치하도록content-length헤더도 업데이트해야 해요. Envoy는content-length가 변형된 본문과 일치하지 않는 응답을 거부해요.
요청 흐름 (Request flow)
헤더 주입과 본문 재작성이 활성화된 ext_proc 예시:
curl -H "X-LangSmith-LLM-Auth: <JWT>" -d '{"model":"gpt-4",...}'
-> Envoy(:10000)
-> built-in Envoy JWT filter (validate sig, iss, aud)
-> `ext_proc` filter -> transformer:50051 (gRPC)
<- phase 1: request_headers -> mutate headers (inject Authorization)
<- phase 2: request_body -> mutate body (rewrite JSON) + update content-length
-> upstream LLM provider or gateway
예시 배포 (Sample deployment)
아래 예시는 최소 Go 변환기를 Kubernetes Deployment로 배포해요. 요청 헤더에서 JWT를 읽고, Authorization 헤더를 주입하며, 요청 본문을 OpenAI 형식에서 커스텀 형식으로 재작성해요.
transformer-configmap.yaml:
apiVersion: v1
kind: ConfigMap
metadata:
name: transformer-source
data:
main.go: |
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net"
"strings"
core "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
ext_proc "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3"
"google.golang.org/grpc"
)
type server struct {
ext_proc.UnimplementedExternalProcessorServer
}
func (s *server) Process(stream ext_proc.ExternalProcessor_ProcessServer) error {
for {
req, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
var resp *ext_proc.ProcessingResponse
switch v := req.Request.(type) {
case *ext_proc.ProcessingRequest_RequestHeaders:
resp = handleRequestHeaders(v.RequestHeaders)
case *ext_proc.ProcessingRequest_RequestBody:
resp = handleRequestBody(v.RequestBody)
default:
resp = &ext_proc.ProcessingResponse{}
}
if err := stream.Send(resp); err != nil {
return err
}
}
}
func handleRequestHeaders(headers *ext_proc.HttpHeaders) *ext_proc.ProcessingResponse {
var jwtValue string
for _, h := range headers.Headers.Headers {
if strings.EqualFold(h.Key, "x-langsmith-llm-auth") {
if len(h.RawValue) > 0 {
jwtValue = string(h.RawValue)
} else {
jwtValue = h.Value
}
break
}
}
resp := &ext_proc.ProcessingResponse{
Response: &ext_proc.ProcessingResponse_RequestHeaders{
RequestHeaders: &ext_proc.HeadersResponse{},
},
}
if jwtValue != "" {
// TODO: Replace with your auth logic, e.g. exchange JWT for a
// provider-specific token, call a secrets manager, etc.
providerKey := "Bearer your-provider-key"
headerResp := resp.GetRequestHeaders()
headerResp.Response = &ext_proc.CommonResponse{
HeaderMutation: &ext_proc.HeaderMutation{
SetHeaders: []*core.HeaderValueOption{
{
Header: &core.HeaderValue{
Key: "Authorization",
RawValue: []byte(providerKey),
},
},
},
},
}
}
return resp
}
func handleRequestBody(body *ext_proc.HttpBody) *ext_proc.ProcessingResponse {
resp := &ext_proc.ProcessingResponse{
Response: &ext_proc.ProcessingResponse_RequestBody{
RequestBody: &ext_proc.BodyResponse{},
},
}
var original map[string]interface{}
if err := json.Unmarshal(body.Body, &original); err != nil {
log.Printf("Body parse failed, passing through: %v", err)
return resp
}
// TODO: Replace with your transformation logic.
// This example wraps the OpenAI-format body in a custom envelope.
transformed := map[string]interface{}{
"custom_model": original["model"],
"custom_messages": original["messages"],
"metadata": map[string]string{"source": "langsmith"},
}
newBody, err := json.Marshal(transformed)
if err != nil {
log.Printf("Body marshal failed, passing through: %v", err)
return resp
}
// IMPORTANT: update content-length to match the new body size.
bodyResp := resp.GetRequestBody()
bodyResp.Response = &ext_proc.CommonResponse{
Status: ext_proc.CommonResponse_CONTINUE_AND_REPLACE,
HeaderMutation: &ext_proc.HeaderMutation{
SetHeaders: []*core.HeaderValueOption{
{
Header: &core.HeaderValue{
Key: "content-length",
RawValue: []byte(fmt.Sprintf("%d", len(newBody))),
},
},
},
},
BodyMutation: &ext_proc.BodyMutation{
Mutation: &ext_proc.BodyMutation_Body{
Body: newBody,
},
},
}
return resp
}
func main() {
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
s := grpc.NewServer()
ext_proc.RegisterExternalProcessorServer(s, &server{})
log.Println("transformer listening on :50051")
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
go.mod: |
module transformer
go 1.23
require (
github.com/envoyproxy/go-control-plane/envoy v1.32.4
google.golang.org/grpc v1.72.1
)
transformer-deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: transformer
labels:
app: transformer
spec:
replicas: 1
selector:
matchLabels:
app: transformer
template:
metadata:
labels:
app: transformer
spec:
initContainers:
- name: build
image: golang:1.23
command: ["sh", "-c"]
args:
- |
cp /src/main.go /src/go.mod /build/ &&
cd /build &&
go mod tidy &&
CGO_ENABLED=0 go build -o /build/transformer ./main.go
volumeMounts:
- name: source
mountPath: /src
readOnly: true
- name: binary
mountPath: /build
containers:
- name: transformer
image: gcr.io/distroless/static-debian12:nonroot
command: ["/app/transformer"]
ports:
- containerPort: 50051
volumeMounts:
- name: binary
mountPath: /app
readOnly: true
volumes:
- name: source
configMap:
name: transformer-source
- name: binary
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: transformer
labels:
app: transformer
spec:
selector:
app: transformer
ports:
- port: 50051
targetPort: 50051
protocol: TCP
참고: 프로덕션에서는 init 컨테이너에서 컴파일하는 대신 컨테이너 이미지를 미리 빌드하세요. 예시 멀티 스테이지 빌드는 Helm 차트 저장소의
e2e/transformer/Dockerfile을 참고하세요.
추가 구성 (Additional configuration)
HTTP 프록시 (HTTP proxy)
Envoy는 HTTP_PROXY, HTTPS_PROXY, NO_PROXY 환경 변수를 존중하지 않아요. HTTP 프록시를 명시적으로 구성하세요:
authProxy:
httpProxy:
enabled: true
host: "proxy.example.com"
port: 3128
noProxy:
- "internal.corp"
- ".internal.corp"
공용 인그레스 없이 배포하기 (Deploy without a public ingress)
인증 프록시에 공용 인그레스가 없고 내부 Kubernetes 네트워킹으로만 연결될 수 있는 경우, LangSmith 서비스가 비공개 IP 주소로의 아웃바운드 요청을 허용하도록 구성해야 해요. 이 설정이 없으면 내장 SSRF 보호가 비공개 IP로의 요청을 차단해요.
LangSmith values.yaml에 다음 환경 변수를 추가하세요:
SSRF_ALLOW_K8S_INTERNAL— LLM 호출을 하는 모든 서비스에 필요해요. 지원하는 서비스에는commonEnv에,commonEnv를 지원하지 않는 서비스에는 각 서비스의extraEnv에 추가하세요.SSRF_ALLOW_PRIVATE_IPS_PLAYGROUND—playground서비스에만 필요해요.playground.deployment.extraEnv에 추가하세요.
# Allow all LLM-calling services to reach the auth proxy on private IPs
commonEnv:
- name: SSRF_ALLOW_K8S_INTERNAL
value: "true"
# Allow the playground service to reach the auth proxy on private IPs
playground:
deployment:
extraEnv:
- name: SSRF_ALLOW_K8S_INTERNAL
value: "true"
- name: SSRF_ALLOW_PRIVATE_IPS_PLAYGROUND
value: "true"
배포에서 commonEnv가 모든 필수 서비스에 적용되지 않으면 LLM 호출을 하는 각 서비스의 extraEnv를 통해 SSRF_ALLOW_K8S_INTERNAL을 개별적으로 설정하세요.
기타 옵션 (Other options)
인그레스, 오토스케일링, 리소스 한도 및 기타 구성 옵션은 Helm 차트 README를 참고하세요.
팁: 프로덕션 안정성을 위해
authProxy.autoscaling.hpa.minReplicas를 최소3으로 설정하세요.
전체 구성 예시 (Full configuration example)
authProxy:
upstream: "https://gateway.example.com" # your LLM gateway or provider
jwtIssuer: "langsmith" # must match LLM_AUTH_PROXY_ISSUER on LangSmith
jwtAudiences:
- "example-audience" # must match org setting in LangSmith
# Option A: remote JWKS (recommended for production)
# Envoy fetches and caches public keys from LangSmith's /.well-known/jwks.json endpoint.
jwksUri: "https://langsmith.example.com/.well-known/jwks.json" # self-hosted
# jwksUri: "https://api.smith.langchain.com/.well-known/jwks.json" # SaaS
jwksCacheDurationSeconds: 300 # how long Envoy caches the JWKS (default 5 min)
# Option B: inline JWKS (testing or air-gapped environments only)
# jwksJson: '{"keys": [...]}'
# ext_authz: header-only auth logic (include only if needed)
# Use this to inject, remove, or modify authorization headers.
# Your service receives an HTTP request at /check with the validated JWT
# in the x-langsmith-llm-auth header and responds with headers to inject upstream.
extAuthz:
enabled: true
serviceUrl: "http://localhost:10002" # sidecar URL
# serviceUrl: "http://ext-authz.<namespace>.svc.cluster.local:10002" # separate deployment
sendBody: false # set true to include request body
# transformer: request/response body transformation (include only if needed)
# Use this when you need to rewrite request or response bodies (e.g. OpenAI -> custom format).
# Can be enabled alongside ext_authz.
transformer:
enabled: true
serviceUrl: "grpc://transformer.<namespace>.svc.cluster.local:50051"
timeout: "10s"
failureModeAllow: false # reject if transformer is unavailable
processingMode:
requestHeaderMode: "SEND" # forward request headers (read JWT, inject auth)
responseHeaderMode: "SKIP" # skip response headers
requestBodyMode: "BUFFERED" # buffer full body for JSON rewriting
responseBodyMode: "NONE" # skip response body
requestTrailerMode: "SKIP"
responseTrailerMode: "SKIP"
JWT 클레임 참조 (JWT claims reference)
LangSmith는 Ed25519(EdDSA) 를 사용해 JWT에 서명해요. 공개 키는 /.well-known/jwks.json에서 서빙되며 프록시가 자동으로 가져와요. 인증 프록시는 이 공개 키를 사용해 서명을 검증해요.
| 클레임 | 설명 |
|---|---|
iat, exp, jti, nbf |
표준 JWT 클레임 (발급 시각, 만료, JWT ID, not-before) |
iss |
발급자. SaaS는 langsmith, 셀프 호스팅은 LLM_AUTH_PROXY_ISSUER로 설정 |
aud |
대상(Audience). LangSmith 조직 설정의 JWT audience와 일치 |
sub |
액터 식별자 (사용자 ID, 평가자 ID, 어시스턴트 ID 또는 API 키 ID) |
actor_type |
user, evaluator, agent-builder, insights, polly, api_key:pat(개인 액세스 토큰) 또는 api_key:service(서비스 계정 키) 중 하나 |
workspace_id |
워크스페이스 ID |
workspace_name |
워크스페이스 이름 |
organization_id |
조직 ID |
organization_name |
조직 이름 |
request_id |
요청 상관 ID |
ls_user_id |
LangSmith 사용자 ID (요청에 연결된 사용자가 있을 때마다 존재) |
경고: 최종 사용자를 식별하려면
ls_user_id를 사용하세요. 에이전트 실행에서sub는 어시스턴트의 ID이고actor_type은agent-builder이므로 어느 것도 사람을 식별하지 못해요.
JWT는 x-langsmith-llm-auth 요청 헤더로 ext_authz 또는 변환기 서비스에 전달돼요.
FAQ
인증 프록시가 회사 프록시를 지원하나요? (Does the auth proxy support corporate proxies?)
예. values.yaml의 httpProxy 섹션을 통해 HTTP 프록시를 구성하세요. HTTP 프록시를 참고하세요.
인증 프록시가 커스텀 인증서를 지원하나요? (Does the auth proxy support custom certificates?)
예. 커스텀 CA 인증서용 customCa와 상호 TLS용 mtls를 통해 지원해요.
단일 인증 프록시가 여러 업스트림 LLM 게이트웨이로 라우팅할 수 있나요? (Can a single auth proxy route to multiple upstream LLM gateways?)
아니요. 인증 프록시에는 단일 upstream 필드만 있어요.
인증 프록시가 여러 조직을 서빙할 수 있나요? (Can the auth proxy serve multiple organizations?)
예. 여러 조직이 LangSmith의 모델 구성으로 동일한 인증 프록시 인스턴스를 가리킬 수 있어요.
LangSmith에서 인증 프록시로의 연결이 HTTP를 사용할 수 있나요? (Can the LangSmith to auth proxy connection use HTTP instead of HTTPS?)
네, 셀프 호스팅에서만 가능하며, 일반적으로 인증 프록시를 전용 인그레스 뒤에 두어 HTTPS로 통신하는 것을 권장해요. HTTP를 허용하려면 LangSmith values.yaml의 commonEnv와 playground.deployment.extraEnv에 LLM_AUTH_PROXY_ACCEPT_HTTP를 추가하세요. Chat 및 Insights에 대해 인증 프록시로 HTTP 트래픽을 활성화하려면 각각의 extraEnv 섹션에 이 환경 변수를 설정하세요: config.polly.agent.extraEnv(Chat용, 이전에는 Polly라고 했음)와 config.insights.agent.extraEnv.
인증 프록시가 공용 인그레스 없이 동작하나요? (Does the auth proxy work without a public ingress?)
예. 인증 프록시가 공용 인그레스 없이 내부 Kubernetes 네트워킹으로만 연결될 수 있는 경우, LLM 호출을 하는 모든 서비스에 SSRF_ALLOW_K8S_INTERNAL을, playground 서비스에 SSRF_ALLOW_K8S_INTERNAL과 SSRF_ALLOW_PRIVATE_IPS_PLAYGROUND를 모두 추가하세요. 구성 세부 정보는 공용 인그레스 없이 배포하기를 참고하세요.
모델 구성의 OAuth client credentials 대신 LLM 인증 프록시를 언제 사용해야 하나요? (When should I use the LLM auth proxy versus OAuth client credentials on a model configuration?)
인증에 OAuth2 client_credentials를 넘어서는 커스텀 로직이 필요할 때 LLM 인증 프록시를 사용하세요. 예를 들어 LangSmith JWT를 프로바이더별 토큰으로 교환하거나, GCP 또는 AWS 아이덴티티를 주입하거나, 요청·응답 본문을 재작성하는 경우예요. 각 워크스페이스나 팀이 커스텀 게이트웨이에 대해 자체 OAuth2 client_credentials를 셀프서비스로 제어해야 할 때는 모델 구성의 OAuth client credentials** 를 사용하세요. 둘 다 같은 조직 내에서 공존할 수 있어요; 라우팅은 구성별이에요.
Helm 차트 참조 (Helm chart reference)
구성 가능한 값의 전체 목록은 Helm 차트 README를 참고하세요.
출처: 문서