CLIProxyAPI는 구독 계정들을 라운드 로빈으로 순회하는 OpenAI/Claude/Gemini 호환 프록시로 소개된다. 코드(v7.2.47)에서 실제 모습은 계정별 반응형 쿨다운 상태 머신이다. 할당량을 예측하지 않는다. 429가 올 때까지 기다렸다가 해당 계정을 벤치로 보내며, 얼마나 오래 제외할지는 어느 프로바이더가 거절했느냐에 전적으로 달려 있다.
마지막 부분이 흥미로운 지점이라, 그 실마리를 끝까지 따라가 보자. 나는 v7.2.47, 커밋 00114be 기준으로 읽었고, 아래의 모든 주장은 해당 코드 라인을 가리킨다.
이용 및 제공자 약관 안내. 이 글은 구현 동작을 설명하는 분석이며, 제공자의 제한이나 서비스 약관 우회를 권장하지 않는다. OAuth 구독 계정을 풀링하기 전에는 사용 방식이 각 제공자의 적용 약관에서 허용되는지 확인하고, 본인이 접근 권한을 가진 계정만 사용해야 한다.
무엇이며, 어디에 자리하는가?
CLIProxyAPI는 셀프 호스팅 프록시다. OAuth를 통해 Claude, ChatGPT/Codex, Gemini 계정에 로그인하면, 이 계정들을 하나의 OpenAI 호환 엔드포인트로 노출한다. 도구가 이 엔드포인트를 바라보게 하면, 현재 사용할 수 있는 구독 로그인들에 요청을 분산한다.
눈에 띄는 기능은 계정 스케줄러다. 로그인된 계정 중 어느 것이 다음 요청을 처리할지 고르고, 언제 그 계정 사용을 멈출지 결정하는 부분이다. 내가 이 지점을 들여다본 이유는 “구독 간 라운드 로빈 로드 밸런싱”이라는 표현이 마케팅에서 꽤 많은 일을 하고 있었고, 코드에서 실제로 무엇을 뜻하는지 보고 싶었기 때문이다.
짧게 말하면, 할당량 미터는 없다. 상한선을 넘지 않도록 요청 수를 세는 로직도 없다. 풀은 순전히 실패에 대한 반응으로 제어된다. 프로바이더가 429를 반환할 때까지 계정을 사용하고, 이후 타이머가 만료될 때까지 쿨다운에 넣는다.
스케줄러는 실제로 어떻게 작동하는가?
루프는 다음과 같다. 요청 → 계정 선택 → 실행 → MarkResult → 필요하면 쿨다운 → 반복. 중요한 건 두 부분이다. 선택기가 계정을 고르는 방식과 쿨다운 머신이 계정을 벤치로 보내는 방식이다.
선택: 같은 가용 풀을 대상으로 한 세 가지 전략
세 선택기는 모두 먼저 같은 질문을 던진다. 현재 차단되지 않은 auth는 무엇인가? 그다음 동률을 푸는 방식만 다르다.
| 전략 | 동률 해소 규칙 | 소스 |
|---|---|---|
| 라운드 로빈(기본값) | 가용 auth 사이에서 커서를 순환한다 | selector.go:257-283 |
| 선입 채우기 | ID 정렬 후 available[0]을 반환한다 |
selector.go:294-303 |
| 세션 어피니티 | 기본 선택기를 감싸고, TTL에 따라 세션을 하나의 auth에 고정한다 | selector.go:417-470 |
전체 설계는 “가용”이라는 단어 안에 있다. 어떤 auth가 특정 모델에 대해 unavailable로 표시되어 있고 동시에 재시도 타이머가 아직 미래라면, 그 auth는 차단된다. selector.go:305-362의 isAuthBlockedForModel은 정확히 Unavailable && NextRetryAfter.After(now)를 확인하고, 이를 (auth, model) 쌍마다 수행한다. 따라서 claude-sonnet 때문에 벤치로 간 Claude 계정도 다른 모델은 계속 처리할 수 있다.
무엇이 없는지도 무엇이 있는지만큼 많은 걸 말해준다. 결과를 기록하는 recentRequestRing이 있지만(types.go:146-159), MarkResult에서 쓰고 사용량/관리 엔드포인트에서만 읽는다. 선택을 제한하는 데는 전혀 쓰이지 않는다. 어디에도 max_request, message_limit, daily_limit 설정은 없다. 선택기는 계정을 얼마나 사용했는지 모른다. 최근에 그 계정이 실패했는지만 안다.

스케줄러는 숨겨진 구독 할당량을 계측하지 않는다. 가용 계정을 선택하고 결과를 관찰한 뒤, 실패한 계정은 재시도 타이머가 만료될 때까지 벤치로 보낸다.
쿨다운 머신: 하나의 중앙 수렴점
모든 실행 결과는 하나의 함수로 모인다. conductor.go:3496의 Manager.MarkResult다. 상태 코드가 429이면(conductor.go:3468), 다음과 같이 재시도 시각을 계산한다.
next = now + *RetryAfter // if the executor surfaced a hint
next = now + nextQuotaCooldown(level) // otherwise
힌트가 있는가, 애초에 존재하기는 하는가가 이 글 전체의 핵심이다. 잠시 뒤 다시 돌아오겠다. 힌트가 없으면 conductor.go:4153-4169의 nextQuotaCooldown은 단순한 지수 백오프다. 1s * 2^level로 늘어나며 상한은 30m이다. 상태에 Quota{Exceeded, Reason:"quota", NextRecoverAt, BackoffLevel}을 설정하고, 이후 성공하면 이를 지운다. 이 모든 상태는 auth별 .cds 파일에 저장되며, 부팅 시 아직 만료되지 않았다면 복원된다(conductor.go:557-611).
상태 → 쿨다운 시간, 한눈에 보기
MVP는 텍스트와 표 중심이어서(별도 맞춤형 다이어그램은 없다), 상태 머신을 표로 정리했다. HTTP 결과에 따라 계정이 벤치에 머무는 시간은 크게 달라진다.
| 결과 | 적용되는 쿨다운 | 사유 플래그 |
|---|---|---|
프로바이더 힌트가 있는 429 |
now + hint(정확한 초기화 시각) |
쿨다운(할당량) |
힌트가 없는 429 |
1s → 2s → 4s → … → 30m 지수 백오프 |
쿨다운(할당량) |
401(인증) |
+30m |
기타 |
402 / 403(결제) |
+30m |
기타 |
404 |
+12h |
기타 |
400/422 모델 미지원 |
+12h |
기타 |
일시적 5xx(408/500/502/503/504) |
+1m(설정 가능) |
기타(할당량 아님) |
| Cloudflare challenge | 할당량 곡선, 최솟값 10s |
쿨다운(할당량) |
일시적 5xx 경로는 Quota 플래그가 아니라 재시도 타이머만 설정한다(conductor.go:3629-3634). 따라서 다시 읽어보면 할당량 쿨다운이 아니라 일반적인 “기타” 차단으로 나타난다. 이 구분은 계정이 왜 벤치로 갔는지 보고하는 관리 엔드포인트에서 주로 의미가 있다.
설계 결정: 보여줄 만한 두 가지 간극
아키텍처는 일관적이다. 하지만 두 지점에서 소개 문구와 실제 메커니즘 사이의 간극이 드러난다. 평가를 덧붙이기보다 코드를 직접 보여주겠다.
간극 1 — “선입 채우기가 구독 상한 도달 시점을 분산한다”는 로직이 아니라 주석이다
선입 채우기의 주석(selector.go:32-35, blame b078be46)은 “하나의 계정을 소진해서 … 롤링 윈도우 구독 상한(예: 채팅 메시지 제한)의 도달 시점을 분산한다”고 말한다. 읽다 보면 그 아래에 상한을 인식하는 로직이 있을 것 같다.
그런 로직은 없다. FillFirstSelector.Pick은 ID로 정렬한 뒤 available[0]을 반환한다(selector.go:294-303). 롤링 윈도우 계산도, 상한 추적도 없다. “분산”은 결정론적 순서에서 자연스럽게 나타나는 효과다. 풀이 항상 계정 #1부터 소진하기 때문에 각 계정은 서로 다른 실제 시각에 윈도우 한도에 도달한다. 솔직하게 표현하면 이렇다. 선입 채우기는 계정 #1이 429를 반환할 때까지 밀어붙인 다음 #2로 넘어간다. 이 동작이 원하는 것일 수도 있다. 다만 상한 로직이 강제하는 동작은 아니다. 단순히 순서에서 비롯된다.
간극 2 — 복구 시점은 프로바이더마다 다르고, 이 경로에서는 아무도 Retry-After를 읽지 않는다
계정 풀링 전에 내가 알고 싶은 부분은 바로 이것이다. 중앙 머신이 실제 초기화 시각을 얻으려면 실행기가 error.RetryAfter()를 통해 힌트를 넘겨야 한다(conductor.go:3908-3925). 그러면 질문은 이렇게 바뀐다. 요청에서 429가 발생했을 때 실제로 그 힌트를 채우는 실행기는 무엇인가? 답은 프로바이더에 따라 선명하게 갈린다.
| 프로바이더 | 429 복구 시각의 출처 | 실제 초기화 시각을 아는가? |
|---|---|---|
| Codex / OpenAI | JSON error.resets_at / error.resets_in_seconds. 단, error.type == "usage_limit_reached"일 때만 해당(codex_executor.go:1834-1852) |
예 |
| Claude | 요청 429에서 아무것도 얻지 못함 → 지수 백오프(claude_executor.go:324) |
아니요(추측) |
| Gemini (+Vertex) | 아무것도 얻지 못함 → 지수 백오프(gemini_executor.go:197) |
아니요(추측) |
| Antigravity | Google RetryInfo.retryDelay/quotaResetDelay + regex, 짧은 쿨다운 KV 및 3s 미만 즉시 재시도(json_retry_helpers.go:27-80, antigravity_executor.go:2621-2648) |
예 |
핵심은 이렇다. 구독이 언제 초기화되는지 실제로 아는 건 Codex뿐이다. ChatGPT가 오류 본문에 resets_at을 반환하고 실행기가 이를 파싱하기 때문이다. (분당 rate_limit_* 필드는 codex_executor.go의 파싱 대상에서 의도적으로 제외되므로, 이 경우에는 여전히 백오프로 넘어간다.) Claude와 Gemini의 경우 프록시는 윈도우가 언제 다시 열리는지 전혀 모른다. 그저 1s, 2s, 4s 식으로 30분까지 맹목적으로 백오프한다. Claude 계정들을 풀링하면서 실제 초기화 윈도우를 프록시가 존중할 것이라 기대한다면 그렇지 않다. 프록시는 추측하며, Anthropic이 실제로 제한을 해제하는 시점과 무관한 일정에 따라 다시 찔러본다.1
반응형이고 예측형이 아닌 설계가 나쁜가? 그렇지 않다. 프로바이더는 “N개의 요청이 남았다”는 명확한 신호를 제공하지 않으므로 429를 기다리는 건 합리적인 태도다. 내가 아쉬운 건 “로드 밸런싱”이라는 표현뿐이다. 로드 밸런서는 모두가 용량 이내에 머물도록 분산한다. 이 시스템은 먼저 분산하고 그다음 초과에 반응한다. 로드 밸런서라는 이름을 입은 장애 조치 쿨다운 스케줄러다. 그리고 전면에 내세운 세 프로바이더 중 둘에서는 쿨다운 시점이 맹목적인 추측이다.
이 시스템은 어디에 자리하는가?
LLM 게이트웨이 지도에서 CLIProxyAPI를 배치할 때 내가 쓸 축은 이것이다. 무엇을 라우팅하는가?
LiteLLM, OpenRouter, one-api 같은 범용 게이트웨이는 가격, 지연 시간 또는 폴백 순서에 따라 선택한 API 키를 라우팅한다. 단위는 측정 가능한 잔액이 있는 과금형 키다. CLIProxyAPI는 불투명한 할당량 윈도우에 묶인 OAuth 구독 계정을 라우팅한다. 단위는 이미 매달 정액으로 비용을 내는 로그인이고, 제약은 토큰당 비용이 아니라 “롤링 윈도우가 멈춰 세우기 전까지 몇 개의 메시지를 보낼 수 있는가”다.
이 한 가지 차이가 이후의 모든 것을 바꾼다. 키 라우터는 가격을 볼 수 있으므로 비용을 최적화할 수 있다. 구독 프록시는 할당량을 볼 수 없기에 유일한 수단은 반응이다. 바로 이 때문에 CLIProxyAPI는 비용 최적화기가 아니라 쿨다운 상태 머신이다. llm-gateways 비교 허브가 공개되면 CLIProxyAPI는 이 진영에 놓인다. API 키 라우터와 구별되는 구독 계정 프록시다.
언제 이 시스템을 선택할까?
적용되는 제공자 약관이 구독 풀링을 허용하고 관련 계정을 사용할 권한이 있다면, 여러 ChatGPT/Codex 구독을 하나의 OpenAI 호환 엔드포인트 뒤에 두는 구성은 기술적으로 잘 맞는다. Codex는 스케줄러가 실제 초기화 시각을 아는 유일한 프로바이더이므로 쿨다운이 현실과 맞아떨어진다. Claude와 Gemini에서도 작동하고 여러 계정에 부하를 분산한다. 다만 쿨다운이 실제 윈도우에 대한 예약이 아니라 맹목적인 지수 백오프라는 점은 알고 들어가야 한다.
실제로 조정할 수 있는 항목은 다음과 같다(설정).
routing.strategy—round-robin(기본값) 또는fill-first. 핫 리로드할 수 있다(service.go:1275-1317).routing.session-affinity— 기본값은 꺼짐, TTL은 1h이다. 클라이언트가 세션 ID를 보내지 않으면selector.go:585-750에서 콘텐츠 해시(system 메시지 + 첫 user 메시지의 FNV-64a, 100자로 잘림)로 세션 ID를 재구성한다. 엄격한 고정을 기대한다면 알아둘 만하다.- auth별 우선순위 버킷 — 가장 높은 우선순위에서만 선택한다(
selector.go:115-128). disable-cooling(전역 / auth별 / 프로바이더별) 및transient-error-cooldown-seconds(0= 60s,<0= 꺼짐)(conductor.go:94-146).
방법론과 범위
나는 커밋 00114be의 CLIProxyAPI v7.2.47을 읽었고, translator, auth, runtime/executor, sdk/cliproxy/auth 패키지에 집중했다. 핵심 주장 두 가지, 즉 프로바이더별 429 복구 시점의 차이와 선입 채우기에 상한 로직이 없다는 주장은 각각 반증을 시도하는 주장 검증을 거쳐 확인했다. 라인 번호는 해당 커밋에서 정확하며, 코드 인용은 한두 줄로 제한했다.
범위의 한계를 명확히 밝힌다.
- xAI (grok) 및 Kimi 실행기는 한 줄씩 읽지 않았다. 다른 실행기와 같은
statusErr타입을 공유하므로 “힌트 없음 → 백오프” 그룹처럼 동작할 것으로 예상하지만, 프로바이더에 관한 주장은 직접 읽은 Codex, Claude, Gemini, Antigravity로 한정한다. - “아무도
Retry-After를 읽지 않는다”는 주장은 요청 / 할당량 쿨다운 경로로 한정되며, 이는 의도적인 범위 설정이다(각주 참조).
Footnotes
-
과장하지 않기 위해 정확한 예외 하나를 짚는다. Claude는
Retry-After/Retry-After-Ms를 읽기는 한다. 다만anthropic_auth.go:89의 OAuth 토큰 갱신 경로(parseClaudeRetryAfter→setClaudeRefreshBlockedUntil, 갱신 속도 제한)에서만 읽으며, 이 글에서 다루는 요청/할당량 쿨다운 경로에서는 읽지 않는다. 따라서 요청/할당량 쿨다운 경로에서 어떤 프로바이더도 HTTPRetry-After헤더를 읽지 않는다고 말하는 것은 정확하다. Claude가 이를 전혀 읽지 않는다는 뜻은 아니다. ↩


