사내 모니터링 시스템에 LLM 호출 관측은 이미 있었다. 없는 것은 "코드를 못 건드리는 클라이언트"의 호출을 잡는 방법이었다. 그래서 투명 게이트웨이 프록시를 만들었고, Claude Code는 30분 만에 붙었지만 Codex는 반나절이 걸렸다.
0. 결론부터
Go로 단일 바이너리 LLM 게이트웨이 프록시를 만들었다. 클라이언트의 base URL만 바꾸면 요청, 응답, 인증 헤더는 그대로 상류(Anthropic/OpenAI/ChatGPT)로 통과하고, 토큰, 지연, TTFT, 에러, 비용만 뽑아 모니터링 수집기로 보낸다.
Claude Code는 ANTHROPIC_BASE_URL 한 줄로 끝났다. 구독(OAuth) 계정도 그대로 통과한다. Codex CLI(ChatGPT 로그인)는 달랐다. 평문 http 백엔드를 거부했고, base URL에 /backend-api가 없으면 다른 경로 스타일로 호출했고, 디스커버리 후에는 /chatgpt 접두어를 떼고 origin만으로 호출했고, TUI는 키체인 기반 native TLS를 따로 썼다. 네 가지 벽을 전부 Codex 소스를 읽어서 찾았다.
정작 시간을 더 먹은 건 배포 쪽이었다. 미커밋 상태로 재시작해서 404가 났고, systemd 유닛에 EnvironmentFile이 없어서 토큰을 못 읽어 503이 났고, nohup 재시작 스크립트가 .env를 안 읽고 있었다.
1. 배경: 왜 프록시인가
우리 모니터링 시스템에는 LLM 호출 관측이 이미 있다. 구조는 단순하다.
앱(SDK 래퍼) ──POST /ingest/llm──▶ 모니터링 서버 ──▶ 호출 기록 저장 ──▶ 대시보드, 예산 알림, 리포트
앱 코드에 SDK를 import해 HTTP 클라이언트에 래퍼를 끼우면 된다. 문제는 SDK를 끼울 수 없는 클라이언트다.
- Claude Code, Codex CLI 같은 에이전트 도구는 바이너리라 코드를 못 건드린다.
- 서드파티 앱, 사내 툴은 소스가 없거나 손대기 싫다.
- 팀원 각자의 PC에서 도는 것들은 "누가 얼마나 쓰는지"가 궁금한데 SDK를 배포할 수 없다.
이 클라이언트들의 공통점은 base URL을 바꿀 수 있다는 것이다. Claude Code는 ANTHROPIC_BASE_URL, Codex는 chatgpt_base_url, OpenAI SDK는 base_url을 쓴다. 그러면 중간에 뭔가를 끼우면 된다.
설계 원칙은 세 가지로 잡았다.
- 서버, UI 무변경. 기존 수집 엔드포인트 계약을 그대로 쓴다. 프록시가 SDK인 척하면 화면, 예산, 리포트가 공짜로 따라온다.
- 완전 투명. 인증 헤더를 그대로 넘기고 저장하지 않는다. 프롬프트 본문도 기본적으로 저장하지 않는다.
- 한 바이너리. 개발자 PC나 CI 호스트에
curl로 받아서 띄운다. 의존성이 없다.
2. 설계
2.1 접두어 라우팅
한 프록시로 여러 상류를 받기 위해 경로 접두어로 공급자를 고른다.
Claude Code ──ANTHROPIC_BASE_URL──▶ :6610/anthropic/* ──▶ api.anthropic.com
Codex CLI ──chatgpt_base_url────▶ :6610/chatgpt/* ──▶ chatgpt.com
OpenAI SDK ──base_url────────────▶ :6610/openai/* ──▶ api.openai.com
│
└─ 사용량 기록 ─▶ 수집기 /ingest/llm
Go의 httputil.ReverseProxy에 Rewrite 훅을 쓰면 접두어를 떼고 상류 경로에 붙이는 건 몇 줄이면 된다.
rewrite := func(pr *httputil.ProxyRequest) {
pr.SetURL(rt.upstream) // upstream 경로 + 요청 경로 결합
pr.Out.Host = rt.upstream.Host
pr.Out.Header.Del("Accept-Encoding") // 평문 응답을 받아야 파싱이 된다
pr.Out.Header.Del("Sec-WebSocket-Extensions") // permessage-deflate 끄기 (뒤에서 설명)
pr.Out.Header.Del("X-Proxy-App")
pr.Out.Header.Del("X-Proxy-Feature")
// 일부러 SetXForwarded 안 함: 클라이언트 주소를 상류에 흘리지 않는다
}
2.2 무엇을 기록하나
공급자, 모델, 앱, 피처, 입력/출력/캐시읽기/캐시쓰기 토큰, 지연, TTFT, HTTP 상태, 추정 비용, 스트리밍 여부, 툴 호출 이름, 에러를 남긴다.
- app은 User-Agent로 자동 판별한다.
claude-cli/…는claude-code로,codex_cli_rs/…와codex_exec/…는codex로, SDK UA는anthropic-sdk나openai-sdk로 매핑한다. - feature는 기본값으로 호스트명을 쓴다. 팀, 사람 단위 구분이 필요하면 환경변수로 덮어쓴다.
- 직접 지정하고 싶으면
X-Proxy-App,X-Proxy-Feature헤더를 쓴다. 이 헤더는 상류로는 전달되지 않는다.
2.3 세 가지 응답 형식 파서
| 경로 | 형식 |
|---|---|
/v1/messages | Anthropic Messages (JSON, SSE) |
/v1/chat/completions | OpenAI Chat Completions (JSON, SSE) |
/v1/responses, /codex/responses | OpenAI Responses API (JSON, SSE, WebSocket) |
ModifyResponse에서 응답 본문을 기록용 리더로 감싸 클라이언트로 흘려보내면서 동시에 파싱한다. SSE면 이벤트 단위로, JSON이면 끝에서 한 번 처리한다. 스트림이 완료 전에 끊기면 stream_aborted로 남긴다. FlushInterval: -1로 청크가 오는 즉시 내보내야 TTFT가 실제 값과 맞는다.
Codex의 모델 호출은 wss://chatgpt.com/backend-api/codex/responses 웹소켓으로 이뤄진다. 프록시는 업그레이드를 그대로 통과시키면서 프레임을 읽어 response.create부터 response.completed까지를 단위로 기록한다. 이때 Sec-WebSocket-Extensions를 지워 permessage-deflate를 끄지 않으면 프레임이 압축돼 읽을 수 없다.
2.4 단가
Anthropic은 공식 단가를 내장했다. OpenAI는 마지막으로 공개된 목록 기준이라 신형 모델은 0으로 잡히고 로그에 한 번 경고를 남긴다. JSON 파일로 덮어쓸 수 있게 했다. 키는 공급자:모델부분문자열 형식이다.
구독 계정(Claude Max, ChatGPT Team)은 토큰당 과금이 아니므로 비용은 "API로 썼다면"을 기준으로 한 참고치다. 그래도 팀 단위로 "이번 달 에이전트 사용량이 API 환산 얼마"는 꽤 유용한 숫자다.
3. 첫 검증: Claude Code는 30분
ANTHROPIC_BASE_URL=http://127.0.0.1:6610/anthropic claude -p "hi" curl localhost:6610/stats
{"calls":1,"last_call":{"app":"claude-code","model":"claude-haiku-4-5-20251001",
"prompt_tokens":10,"completion_tokens":255,"cache_write_tokens":51660,
"latency_ms":3380,"ttft_ms":471}}
구독(OAuth) 계정이 그대로 통과했다. 캐시 쓰기 51,660 토큰이 눈에 띄는데, Claude Code가 시스템 프롬프트와 도구 정의를 첫 턴에 캐시에 올리는 비용이다. 이런 숫자가 바로 보이는 게 프록시의 가치다.
여기까지는 순조로웠다.
4. 시행착오 1: SDK에 있던 버그를 프록시가 찾다
프록시 파서를 만들다 기존 Go SDK 코드를 참고했는데, 구조체에 json:"usage" 태그가 두 필드에 붙어 있었다. Go의 encoding/json은 태그가 중복되면 둘 다 무시한다. 결과적으로 SDK는 토큰을 항상 0으로 보내고 있었다.
아무도 눈치 못 챈 이유는 화면에 "0 토큰"이 찍혀도 "아직 트래픽이 적나 보다"로 넘어갔기 때문이다. 필드를 하나로 합치고 실제 응답 샘플로 테스트를 붙였다.
파서에는 반드시 실제 응답 샘플로 테스트를 붙여야 한다. 필드 하나가 조용히 0이 되는 버그는 테스트 없이는 잡을 수 없다.
5. 시행착오 2: 수집 포트는 API 서버가 아니라 수집기
프록시는 처음에 API 서버 포트의 /ingest/llm으로 보내게 만들었다. 로컬에서는 됐다. 프로덕션에서는 안 됐다.
프로덕션 API 서버 포트는 외부에 열려 있지 않다. nginx가 /api/*만 프록시한다. 반면 호스트 에이전트가 쓰는 수집기(collector) 포트는 이미 모든 호스트에서 닿는다. 프록시와 SDK는 에이전트처럼 각 환경에 퍼져 있으니 같은 문으로 들어오는 게 맞다.
그래서 /ingest/llm의 실제 저장을 수집기로 옮겼다.
// LLM 수집은 에이전트용 토큰이 아니라 전용 토큰으로 인증하므로
// 에이전트 인증 미들웨어 바깥에 마운트한다.
outer := http.NewServeMux()
outer.Handle("POST /ingest/llm", c.llmIngest)
outer.Handle("/", handler)
API 서버의 동명 엔드포인트는 토큰만 확인하고 수집기로 위임한다. 기존 SDK 호환용이다.
토큰을 왜 따로 두나
이 지점에서 "문자열 토큰을 만든 게 무슨 용도인지 모르겠다"는 질문이 나왔다. 정리하면 이렇다.
- LLM 수집 토큰은 프록시, SDK가
/ingest/llm에 붙일 때 쓰는 공유 비밀이다. 누가 발급하는 게 아니라openssl rand -hex 32로 만들어 서버.env와 프록시 환경에 같은 값을 넣는다. - 에이전트 토큰과 분리한 이유는 배포 위치가 다르기 때문이다. 프록시는 개발자 PC에, SDK는 고객 앱 안에 들어간다. 에이전트 토큰을 거기까지 뿌리면 새는 순간 호스트 메트릭 전체가 위조 가능해진다. LLM 토큰이 새면 피해는
/ingest/llm에 가짜 사용량을 넣는 데 그친다. - 토큰 뒤에는 IP별 분당 600건, 본문 1MiB, 배치 500건 제한이 걸려 있다.
6. 시행착오 3: 배포가 코드보다 오래 걸렸다
여기서부터가 진짜 시간을 먹은 구간이다. 프록시 기록이 서버로 안 갔다. 세 단계로 원인이 벗겨졌다.
6.1 404, 미커밋 상태로 재시작
last_error: "ingest returned 404: 404 page not found"
운영서버 수집기를 직접 찔러도 404였다. 수집기에 이 경로가 없다는 뜻이다. 원인은 수집기 라우트를 추가한 소스가 로컬에 미커밋 상태로 있었기 때문이다.
| 항목 | 시각 |
|---|---|
수집기에 /ingest/llm 붙인 소스 수정 | 14:02 |
| 마지막으로 빌드, 릴리즈된 바이너리 | 12:20 |
"운영서버에 배포됐다"고 생각했지만, 배포된 건 두 시간 전 바이너리였다. 옛 바이너리를 아무리 재시작해도 새 경로는 생기지 않는다. 이 함정은 "재시작했는데 왜 안 되지"로 시작해서 시각 비교로 끝났다.
6.2 503, systemd 유닛에 EnvironmentFile이 없었다
빌드, 배포 후 다시 재시작했다. 이번엔 503이었다.
503 llm ingest disabled
이 응답은 코드상 수집 토큰이 빈 값일 때만 나온다. .env에 넣었는데도 빈 값이라는 건 수집기 프로세스가 그 파일을 안 읽는다는 뜻이다.
$ systemctl cat collector | grep -E "EnvironmentFile|WorkingDirectory" WorkingDirectory=/home/ubuntu/app/server-release
EnvironmentFile 줄이 아예 없었다. 예전 템플릿으로 설치된 유닛이라 .env를 읽을 방법 자체가 없었다. 설치 스크립트를 다시 돌리면 유닛이 새 템플릿으로 덮어써진다.
$ sudo bash scripts/install-collector-service.sh $ sudo systemctl restart collector $ journalctl -u collector | grep llm-ingest [llm-ingest] 활성 (/ingest/llm): IP별 분당 600건, 요청 최대 1048576 bytes
부수 발견도 하나 있었다. 같은 이유로 지금까지 에이전트 토큰도 수집기에 안 들어가고 있었을 가능성이 있다. 코드상 그 값이 비면 에이전트 수집 인증이 꺼진 채로 돈다. 이번 재시작으로 함께 켜졌다.
6.3 nohup 재시작 스크립트도 .env를 안 읽었다
재시작 스크립트는 systemd 유닛이 없으면 nohup으로 띄우는데, 그 분기에는 .env를 export하는 코드가 없었다. API 서버용 시작 스크립트에만 있었다. 이번엔 systemd 경로였지만 어차피 같은 함정이라 함께 고쳤다.
# nohup 경로는 systemd의 EnvironmentFile 대신 여기서 .env를 읽는다. if [ -f "$DIR/.env" ]; then export $(grep -v '^#' "$DIR/.env" | xargs) fi
교훈
"배포했다"는 커밋 해시로 확인해야 한다. 바이너리 시각과 소스 수정 시각을 나란히 놓으면 5초 만에 끝날 일이었다. 503과 404는 다른 이야기다. 404는 코드가 없는 것이고, 503은 코드는 있는데 설정이 빈 것이다. 응답 코드 하나가 다음 질문을 정한다. 환경변수가 프로세스에 들어갔는지는 파일이 아니라 프로세스에서 확인해야 한다. systemctl cat, ps eww, 기동 로그가 그 확인 수단이다.
7. 대장정: Codex
Claude Code가 30분이었다면 Codex는 반나절이었다. 순서대로 정리한다.
7.1 -c 오버라이드는 모델 웹소켓에 안 먹는다
처음 시도는 CLI 플래그였다.
codex exec -c 'chatgpt_base_url="http://127.0.0.1:6610/chatgpt"' "hi"
플러그인, MCP, 분석 이벤트 호출은 프록시를 지났지만 모델 웹소켓은 기본 주소로 직접 나갔다. 내장 openai 공급자의 base URL이 CLI 오버라이드보다 먼저 굳는 것으로 보였다. 그래서 설정 파일로 가야 했다.
7.2 설정 파일은 먹혔다, 그런데 "workspace routing discovery failed"
~/.codex/config.toml 맨 위(첫 […] 헤더보다 앞, TOML은 테이블 아래에 쓰면 그 테이블 키가 된다)에 넣었다.
chatgpt_base_url = "http://127.0.0.1:6610/chatgpt"
$ codex
Error: account/read failed during TUI bootstrap: account/read failed:
workspace routing discovery failed (code -32603)
프록시 /stats의 passthrough는 51에서 103으로 늘었다. Codex가 프록시를 타긴 한다는 뜻이다. 그런데 뭔가에서 거부되고 있었다.
7.3 바이너리 strings로 힌트 찾기
로그가 없었다. ~/.codex/log/에는 로그인 로그뿐이었다. 그래서 바이너리에서 문자열을 뽑았다.
$ strings -n 6 $(which codex) | grep -i "routing" workspace routing discovery failed workspace routing discovery must return an origin workspace backend must use an HTTPS origin without credentials x-codex-routing-hint route_aware_client_pool
"HTTPS origin"이 눈에 들어왔다. 그리고 Codex는 오픈소스다. 소스를 읽는 게 빠르다.
7.4 소스에서 찾은 세 가지 벽
첫 번째 벽은 경로 스타일이었다. backend-client/src/client.rs를 보면 이렇게 돼 있다.
if base_url.contains("/backend-api") {
PathStyle::ChatGptApi // {base}/wham/accounts/check
} else {
PathStyle::CodexApi // {base}/api/codex/accounts/check
}
우리 base URL http://127.0.0.1:6610/chatgpt에는 /backend-api가 없다. 그래서 Codex는 /api/codex/accounts/check를 부르고, 프록시가 그걸 chatgpt.com/backend-api/api/codex/accounts/check로 넘기니 404가 난다. 디스커버리 HTTP 호출 자체가 실패해서 DiscoveryFailed로 이어진다.
실제로 프록시로 찔러보면 이렇다.
프록시 GET /chatgpt/wham/accounts/check → 401 (경로 있음, 인증만 필요) 프록시 GET /chatgpt/api/codex/accounts/check → 404
두 번째 벽은 HTTPS 필수였다. app-server/.../workspace_routing.rs에는 이런 코드가 있다.
fn parse_backend_url(value: &str) -> Result<Url, AccountReadError> {
let url = Url::parse(value)?;
if url.scheme() != "https" || url.host_str().is_none() || ... {
return Err(WorkspaceRoutingError::InvalidBackendOrigin.into());
}
Ok(url)
}
디스커버리 응답의 workspace_backend_origin이 NO_CONSTRAINT면 chatgpt_base_url을 이 함수에 넣는다. 평문 http는 여기서 죽는다. localhost 예외도 없다. 프록시가 TLS를 서빙해야 한다는 뜻이다.
세 번째 벽은 origin만 남는다는 점이었다. 디스커버리가 끝나면 Codex는 backend_origin = origin.ascii_serialization(), 즉 https://localhost:6610만 들고 모델 호출을 {origin}/backend-api/codex/responses로 보낸다. /chatgpt 접두어가 사라지는 것이다. 이건 나중에 실제로 확인됐다.
7.5 프록시에 TLS 붙이기
Go에서는 ListenAndServeTLS 한 줄이면 된다. 함정은 하나였다. TLS를 켜면 Go가 HTTP/2를 자동 협상하는데, 웹소켓 업그레이드는 Hijack이 필요하고 h2에는 없다. TLSNextProto를 빈 맵으로 두면 HTTP/1.1만 쓴다.
if useTLS {
// HTTP/1.1 only: WebSocket upgrades need Hijack, which h2 lacks.
srv.TLSNextProto = map[string]func(*http.Server, *tls.Conn, http.Handler){}
}
인증서는 스크립트로 만들었다. 그리고 여기서 또 하나가 걸렸다.
7.6 자체서명 leaf는 rustls가 안 받는다
처음엔 openssl req -x509로 자체서명 인증서 하나만 만들었다. Codex를 돌리니 프록시 로그에 이런 게 찍혔다.
http: TLS handshake error from 127.0.0.1:59782: remote error: tls: unknown certificate
CODEX_CA_CERTIFICATE에 그 인증서를 넣었는데도 거부됐다. rustls(webpki)는 자체서명 leaf 인증서를 트러스트 앵커로 쓰는 걸 지원하지 않는다. 로컬 CA를 만들고 그 CA가 서명한 leaf 구조로 바꿨다.
# CA openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 3650 \ -subj "/CN=llm-proxy local CA" \ -addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign" \ -keyout ca.key -out ca.crt # leaf openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -subj "/CN=localhost" -keyout proxy.key -out proxy.csr openssl x509 -req -in proxy.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 3650 \ -extfile <(printf "subjectAltName=DNS:localhost,IP:127.0.0.1\nextendedKeyUsage=serverAuth\n") -out proxy.crt
7.7 CODEX_CA_CERTIFICATE의 함정: 내장 루트가 꺼질 수 있다
http-client/src/custom_ca.rs를 보면 이 환경변수가 설정될 때 add_root_certificate로 인증서를 추가한다. 경로에 따라 tls_built_in_root_certs(false)가 걸리는 클라이언트도 있다. 그러면 우리 CA만 믿고 auth.openai.com이나 플러그인 같은 다른 HTTPS가 전부 깨진다. 그래서 번들은 로컬 CA와 시스템 루트(/etc/ssl/cert.pem)를 이어 붙였다. 129개 인증서가 됐다.
7.8 codex exec가 멈추는 두 가지 이유
검증하려고 codex exec를 돌리는데 두 번 멈췄다.
첫째는 stdin이다. codex exec는 TTY가 아니면 "Reading additional input from stdin..." 하고 stdin을 읽는다. 백그라운드에서 돌리면 영원히 기다린다. < /dev/null로 막았다.
둘째는 신뢰 디렉토리다. "Not inside a trusted directory and --skip-git-repo-check was not specified."라는 메시지가 뜨면, 신뢰된 디렉토리에서 --skip-git-repo-check를 붙여 돌리면 된다.
7.9 벽 셋이 실제로 나타났다
TLS, CA 번들, /backend-api base URL로 돌리자 디스커버리가 통과했다. 그리고 이런 에러가 나왔다.
ERROR: unexpected status 404 Not Found: unknown route: use /anthropic/..., /openai/... or /chatgpt/...,
url: https://localhost:6611/backend-api/codex/responses
예상대로 /chatgpt가 빠진 채 왔다. 프록시가 /backend-api/*를 chatgpt로 자동 라우팅하게 했다. 이 경로는 chatgpt.com에만 있으니 오판할 여지가 없다.
// Codex (ChatGPT login): after workspace-routing discovery it keeps only the *origin* of
// chatgpt_base_url and sends model calls to {origin}/backend-api/codex/responses (the
// /chatgpt prefix is gone). /backend-api/ is unique to chatgpt.com, so route it there.
if strings.HasPrefix(path, "/backend-api/") {
if rt := p.byPrefix["/chatgpt"]; rt != nil {
return rt, path
}
}
이때 같이 넣은 게 unknown route 로깅이다. 그전까지는 모르는 경로가 조용히 404를 돌려줘서 위 404가 어느 경로에서 났는지 볼 수 없었다. 로그 한 줄이 벽 셋을 눈에 보이게 만들었다.
14:50:36 codex chatgpt/gpt-5.6-sol in=11436 cache_r=0 out=0 ttft=471ms total=847ms cost=$0.0143 status=200 14:50:43 codex chatgpt/gpt-5.6-sol in=12764 cache_r=0 out=5 ttft=580ms total=3214ms cost=$0.0160 status=200
codex exec "reply with just the word pong"을 돌리면 pong이 나왔다. 첫 줄의 out=0 호출은 Codex의 warmup 요청이다.
7.10 벽 넷: TUI만 실패한다
codex exec는 통과하는데 codex(TUI)는 여전히 "workspace routing discovery failed"였다. 프록시 로그에는 이렇게 찍혔다.
http: TLS handshake error from 127.0.0.1:60817: EOF http: TLS handshake error from 127.0.0.1:60819: EOF (8건 묶음, TUI 시도 시각과 일치)
"unknown certificate" 알림이 아니라 EOF였다. rustls는 검증 실패 시 alert를 보낸다. 그냥 끊는 건 다른 TLS 스택을 쓴다는 뜻이다. http-client/src/tls_backend_fallback.rs에는 이런 설명이 있다.
//! Native TLS remains the default. A recognized connection-time protocol negotiation failure can //! select rustls for one HTTPS origin and outbound route without changing other destinations.
TUI가 쓰는 라우트별 클라이언트 풀은 native TLS(macOS Security framework, 즉 키체인)가 기본이다. CODEX_CA_CERTIFICATE는 rustls 경로에만 적용된다. codex exec는 rustls 경로라 환경변수만으로 됐고, TUI는 키체인을 보니 우리 CA를 몰랐던 것이다.
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ~/.llm-proxy/ca.crt
키체인에 등록하자 TUI가 떴다.
calls 8 | export {'buffered': 0, 'sent': 8, 'dropped': 0}
last: codex gpt-5.6-luna prompt 5116 (cache_read 4864) completion 17 ttft 473ms
8. 최종 설정 요약
프록시
./local-tls.sh # ~/.llm-proxy/{ca.crt,proxy.crt,proxy.key,codex-ca-bundle.pem}
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ~/.llm-proxy/ca.crt
PROXY_TLS_CERT=~/.llm-proxy/proxy.crt PROXY_TLS_KEY=~/.llm-proxy/proxy.key \
INGEST_URL=http://<collector>:<port> INGEST_TOKEN=<토큰> ./llm-proxy
Codex, ~/.codex/config.toml 맨 위에 이렇게 넣는다.
chatgpt_base_url = "https://localhost:6610/chatgpt/backend-api"
export CODEX_CA_CERTIFICATE=~/.llm-proxy/codex-ca-bundle.pem
Claude Code, ~/.claude/settings.json에는 이렇게 넣는다.
{ "env": {
"ANTHROPIC_BASE_URL": "https://localhost:6610/anthropic",
"NODE_EXTRA_CA_CERTS": "/Users/<me>/.llm-proxy/ca.crt"
} }
9. 시행착오에서 남은 것
오늘 가장 크게 도움이 된 건 Codex 소스를 직접 읽은 것이었다. 바이너리 strings에서 "HTTPS origin" 한 단어를 건진 뒤 소스로 가니 30분 만에 세 가지 벽이 다 보였다. 추측만으로 프록시를 고쳤다면 하루가 더 걸렸을 것이다.
반대로 발목을 잡은 건 조용한 실패였다. unknown route 404를 로그 없이 돌려주던 탓에 벽 셋이 한참 가려져 있었는데, 로그 한 줄을 넣자 다음 실행에서 바로 드러났다. 프록시 같은 중간자는 "내가 모르는 요청이 왔다"는 걸 반드시 남겨야 한다.
원인을 좁혀 간 순서도 그대로 응답 코드를 따라갔다. 404(코드 없음), 503(설정 없음), 401(인증만 필요, 즉 정상) 순으로 벗겨졌고, 각 단계에서 코드 하나가 다음에 어디를 봐야 할지를 정확히 알려줬다.
codex exec는 되는데 codex TUI는 안 되는 증상도 처음엔 이해가 안 됐다. 알고 보니 같은 프로그램 안에서도 경로마다 쓰는 TLS 스택이 달랐다(rustls와 native 키체인). 소스를 보기 전엔 상상하기 어려운 구조다.
가장 허무했던 건 두 시간 전 바이너리를 계속 재시작하며 "왜 안 되지"를 반복한 구간이었다. 배포됐다는 말을 커밋 해시로 확인했으면 5초 만에 끝났을 일이다. 마찬가지로 .env에 값이 있는 것과 프로세스가 실제로 그 값을 읽고 있는 것도 서로 다른 이야기라, systemd 유닛의 EnvironmentFile이나 ps eww 같은 걸로 프로세스 쪽을 직접 확인해야 했다.
10. 남은 과제
- OpenAI 신형 모델(gpt-5.6 계열) 단가표. 지금은 비용이 비어 있다.
- 프록시 상시 실행(launchd). 지금은 터미널에서
go run .으로 띄운다. - OTLP 수신기. 프록시를 못 끼우는 환경용은 별도 단계로 남겨뒀다.
- Codex warmup 호출(out=0)을 별도로 태깅할지 고민 중이다. 입력 토큰은 실제로 소모되니 기록은 맞지만 "호출 수"를 부풀린다.
- 이 글에서 찾은 Codex 동작(
/backend-api경로 스타일, origin-only 호출, TUI native TLS)은 버전에 따라 바뀔 수 있다. 0.158 기준이다.
2026-09-29. 프록시와 서버 변경 전부는 하루 안에 이뤄졌고, 코드보다 배포와 클라이언트 동작 파악에 시간이 더 들었다. 그게 이 글을 쓴 이유다.