Claude Code를 Z.ai GLM으로 운영할 때 Login expired 잡기
Claude Code를 Anthropic 구독 그대로 두고, 백엔드만 Z.ai GLM으로 갈아타는 실행 프로파일을 만들면 구독과 로컬 모델 비용을 분리해서 쓸 수 있다. 핵심은 CLAUDE_CONFIG_DIR 하나다. 이 값만 나누면 인증, 세션, MCP 설정이 프로파일별로 완전히 분리되어 두 터미널에서 동시에 실행할 수 있다. 나도 ~/.zshrc에 glm 함수를 추가해 이 구성을 쓰고 있는데, 첫 실행부터 Z.ai 키가 분명히 맞음에도 "Login expired · Please run /login"이 떴다. 이 글은 그 원인을 두 단계에 걸쳐 걷어낸 과정을 정리한다. 요약하면 첫 범인은 zsh env prefix 절단이 만든 keychain 폴백이었고, 진짜 범인은 GLM 모델 오버라이드가 플레인 claude 세션으로 새어 나가던 것이었다.
프로파일 구성: CLAUDE_CONFIG_DIR 분리
claude 바이너리는 하나를 공유하되 환경 변수만 갈아끼우는 셸 함수 형태가 깔끔하다.
# ~/.zshrc
glm() {
local _zai_token
_zai_token="$(cat ~/.config/zai_token 2>/dev/null)"
if [ -z "$_zai_token" ]; then
echo "glm: no Z.ai token at ~/.config/zai_token" >&2
return 1
fi
CLAUDE_CONFIG_DIR="$HOME/.claude-glm" \
ANTHROPIC_BASE_URL="https://api.z.ai/api/anthropic" \
ANTHROPIC_AUTH_TOKEN="$_zai_token" \
ANTHROPIC_DEFAULT_OPUS_MODEL="glm-5.3-flash" \
ANTHROPIC_DEFAULT_SONNET_MODEL="glm-5.3-flash" \
ANTHROPIC_DEFAULT_HAIKU_MODEL="glm-5.3-flash" \
API_TIMEOUT_MS=3000000 \
command claude "$@"
}
포인트는 세 가지다. 토큰은 퍼미션 600 파일에서 읽어 local 변수로 넣는다. OPUS·SONNET·HAIKU 세 슬롯을 모두 같은 GLM 모델로 매핑한다. 그리고 settings.json의 env 블록에는 이 변수들을 절대 넣지 않는다. settings.json의 env 블록은 셸 환경변수를 덮어쓰기 때문에, 기존 Anthropic 세션까지 GLM 엔드포인트로 끌려가는 사고가 난다. GLM 설정은 함수 스코프 안에서만 살아 있어야 한다.
Login expired의 첫 범인: env prefix 절단
초기 구현에서는 env prefix 한 줄 중간에 빈 값 변수가 백슬래시 없이 행 끝에 들어 있었다. zsh는 그 지점에서 prefix를 끊고, 뒤에 적은 ANTHROPIC_DEFAULT_*와 API_TIMEOUT_MS를 별도 명령으로 취급해 claude에 전달하지 않았다. 더 나쁜 것은 빈 ANTHROPIC_API_KEY가 전달되자 Claude Code가 macOS keychain에 저장된 Anthropic OAuth 자격증명으로 폴백한 것이었다. 그 구독 토큰을 Z.ai 엔드포인트에 보냈으니 결과는 당연히 "Login expired"였다.
교훈은 단순하다. VAR=value \로 이어지는 env prefix는 모든 행 끝에 백슬래시를 두고 마지막 command claude "$@"까지 하나의 명령으로 이어져야 한다. 빈 값을 넣고 싶더라도 VAR="" \ 형태로 prefix 안에 유지하거나, 위 함수처럼 local 변수로 재구성하는 편이 실수 여지가 적다.
진짜 범인: 모델 오버라이드가 플레인 claude로 새어 나간다
prefix를 고치고도 [claude-code:unrecognized_model] 모델 에러가 간헐적으로 남았다. 추적해 보니 에러가 기록된 세션은 glm이 아니라 기본 프로필 쪽이었다. 에러 시점에 실행 중이던 claude 프로세스의 환경을 확인하면 ANTHROPIC_DEFAULT_OPUS_MODEL=glm-5.3-flash와 API_TIMEOUT_MS만 남아 있고, ANTHROPIC_BASE_URL과 ANTHROPIC_AUTH_TOKEN, CLAUDE_CONFIG_DIR는 없었다.
메커니즘은 이렇다. Claude Code가 자식 프로세스에 환경을 넘길 때 base URL과 토큰은 걸러내지만 모델 이름 오버라이드는 그대로 통과시킨다. 그래서 glm 세션에서 파생된 팬, 또는 GLM 환경이 남아 있는 셸에서 플레인 claude를 띄우면 모델만 glm-5.3-flash인 채로 Anthropic 구독으로 요청이 나가 거부된다. 즉 "Z.ai가 안 되는 것"이 아니라 "Z.ai 설정이 엉뚱한 세션을 오염시킨 것"이었다.
수정은 가드 래퍼다. 플레인 claude를 호출할 때 GLM 관련 변수를 강제로 제거한다.
# Plain `claude` must never inherit the GLM overrides.
claude() {
env -u ANTHROPIC_DEFAULT_OPUS_MODEL \
-u ANTHROPIC_DEFAULT_SONNET_MODEL \
-u ANTHROPIC_DEFAULT_HAIKU_MODEL \
-u ANTHROPIC_BASE_URL \
-u ANTHROPIC_AUTH_TOKEN \
-u API_TIMEOUT_MS \
command claude "$@"
}
반대로 glm()은 마지막에 command claude로 호출해 이 래퍼를 우회해야 한다. 래퍼를 거치면 방금 넣은 GLM 환경 변수를 래퍼가 다시 제거해 버린다. glm 세션 안에서 claude 모델 전환 슬래시 명령을 쓰지 않는 한 이 구성으로 충분했다.
에러가 어느 프로필에서 났는지부터 확인한다
같은 증상이 재발하면 원인 판별이 빠르다. 에러가 기록된 세션의 위치를 본다.
ls -lt ~/.claude/projects ~/.claude-glm/projects
에러 세션이 ~/.claude/projects(기본 프로필)에 있으면 변수 누출이다. glm 세션에서 파생된 팬에서 플레인 claude를 띄운 것이니 source ~/.zshrc 한 줄로 가드 래퍼를 반영하면 즉시 정상화된다. 반대로 ~/.claude-glm/projects에 기록됐다면 진짜 GLM 문제다. 키, 모델 ID, 엔드포인트 자체를 점검하면 된다.
검증과 남는 노이즈
수정 후에는 문법 검사, 전역 오염 확인, E2E 호출 순서로 검증한다.
zsh -n ~/.zshrc
zsh -c 'source ~/.zshrc >/dev/null 2>&1; env | grep -E "^(ANTHROPIC|CLAUDE_CONFIG)"; echo clean'
zsh -c 'source ~/.zshrc >/dev/null 2>&1; glm -p "reply with just: ok"'
zsh -c 'source ~/.zshrc >/dev/null 2>&1; claude -p "reply with just: ok"'
여기서 셸 함수의 로드 시점 함정도 짚어둔다. 셸 함수는 팬이 열린 시점에 로드되므로 ~/.zshrc를 고쳐도 이미 열려 있던 팬은 옛 정의를 계속 쓴다. "고쳤는데 또 난다"고 느껴지면 새 팬을 열거나 source ~/.zshrc를 실행한다. 한 번 삽질한 자리다.
마지막으로 glm 실행마다 [claude-code:unrecognized_model] {"query_source":"generate_session_title"} 경고가 한 줄 찍히는데, 이것은 무해하다. 세션 제목 생성기가 ANTHROPIC_BASE_URL을 무시하고 Anthropic 쪽으로 호출해 GLM 모델 ID를 인식하지 못할 뿐, 대화와 도구 호출에는 영향이 없다.
정리하면 세 가지다. 에러 메시지가 가리키는 세션이 정말 지금 디버깅 중인 프로필의 것인지부터 확인할 것. env prefix는 백슬래시 하나에 생명이 달려 있으니 하나의 명령으로 이어질 것. 그리고 같은 바이너리를 프로파일로 나눠 쓸 때는 "자식 프로세스로 무엇이 새어 나가는가"를 반드시 점검할 것. 이 세 가지가 이번 삽질의 결론이다.
댓글
아직 댓글이 없습니다.
댓글 남기기