..

CodePro 403 에러와 400 에러

CodePro AI 역량평가 시작 전에 studio 앱으로 테스트를 하다가 403 에러를 만났고, 고치고 나니 전혀 관계없어 보이는 400 에러가 또 떠서 이게 뭔가 싶어 하루종일 파다가 정리하게 되었다.

첫번째 에러 (403)

에러 메시지는 이랬다.

403 key not allowed to access model. This key can only access models=['devel-gpt-5-2', 'devel-claude-sonnet-4-5']. Tried to access devel-claude-sonnet-4-6

처음엔 그냥 권한 문제겠거니 했다. 근데 우리가 요청한 적도 없는 devel-claude-sonnet-4-6을 왜 호출하려고 한 건지가 이상했다. 찾아보니 LiteLLM 쪽 fallback 설정에 devel-claude-sonnet-4-5가 실패하면 devel-claude-sonnet-4-6으로 자동 fallback 하도록 되어있었는데, 정작 CodePro용 API 키의 허용 모델 목록에는 4-6이 빠져있었다. fallback 체인에 있는 모델이 키 권한 목록에는 없는, 그냥 설정 불일치였다.

SRE팀에 4-6 모델 접근 권한을 추가해달라고 요청했고 (11:28 요청 → 11:32 완료, 생각보다 빨리 끝났다) 403은 그렇게 해결됐다. 여기서 끝인 줄 알았다.

근데 다른 데서 400 에러가 떴다

403을 고치고 나서 다시 테스트를 하는데 이번엔 완전히 다른 에러가 떴다.

400 Invalid value: 'input_json_delta'. Supported values are: 'text', 'image_url', 'input_audio', 'refusal', 'audio', and 'file'.

이건 또 뭔가 싶었다. 403이랑 400이 무슨 관계가 있나 싶어서 처음엔 완전히 별개의 버그로 생각하고 접근했다. 근데 재현 조건을 좁혀보니 이상한 패턴이 나왔다. 같은 대화방(roomId)에서 Claude 모델로 tool call을 한번 성공시킨 다음에 GPT 모델로 전환하면 무조건 재현이 됐다. 심지어 요청할 때 chatHistory: []로 비워서 보내도 재현이 됐다.

chatHistory를 비웠는데도 이전 대화가 영향을 준다는 게 이상해서, 어딘가에 이전 대화 상태가 남아있다는 뜻이었다.

추적해보니 403이 남긴 흔적이었다

Langfuse trace를 하나씩 따라가보니 두 에러가 사실 하나의 연쇄였다.

403이 나던 시점 → devel-claude-sonnet-4-6 요청이 실패하면 ModelFallbackMiddleware가 LiteLLM을 아예 우회해서 native ChatAnthropic으로 직접 fallback을 탄다 → native Claude가 tool call을 스트리밍으로 응답하면서 Anthropic 전용 input_json_delta 컨텐츠 블록을 생성한다 → 스트리밍이 끝나면 이 delta 조각들이 정상적인 tool_use 블록으로 정리(rebase)되어야 하는데, 이 과정에서 필요한 name이 없어서 정리가 실패한다 → 정리 안 된 채로 그대로 LangGraph checkpoint(MongoDB)에 저장된다.

여기까지는 조용히 지나간다. 에러가 안 나니까. 근데 같은 room에서 나중에 GPT-5.2로 모델을 전환하면, checkpoint에서 이전 대화를 로드하는 과정에 정리 안 된 input_json_delta가 그대로 딸려온다. 그걸 OpenAI 쪽으로 넘기면 OpenAI가 지원하지 않는 content type이라 400이 나는 거였다.

즉 403이 발생하는 상황에서 tool call까지 한번 성공하면, 그 순간 이미 두번째 400의 씨앗이 심어지는 구조였다. 403을 고쳤다고 끝난 게 아니라, 403이 나던 그 짧은 시간 동안 이미 오염된 대화가 checkpoint에 남아있으면 그 room은 계속 400이 났다.

exam_user_configs 라는 변수

여기에 하나 더 헷갈리게 만든 요인이 있었는데, exam_user_configs가 비어있는(시험유저로 설정 안 된) 계정은 애초에 LiteLLM을 거치지 않고 곧바로 native fallback 로직을 타고 있었다. 시험유저로 제대로 설정된 계정은 LiteLLM을 정상적으로 경유해서 이 문제가 재현이 안 됐다.

그날 오전에 시험유저 계정에서도 문제가 똑같이 재현됐던 이유는, 애초에 모델 권한 이슈 때문에 시험유저 계정조차 LiteLLM을 못 타고 fallback 로직으로 빠져버렸기 때문이었다. 결국

const liteLLMConfig = this.configService.getLiteLLMConfig();
if (!liteLLMConfig) {
    return { model: nativeModel };  // LiteLLM 미설정 → native만 사용
}
return { model: liteLLMModel, fallbackModel: nativeModel };

이 분기 하나 때문에 어떤 유저는 LiteLLM을 타고 어떤 유저는 안 타는 상황이 겹쳐서, 디버깅하는 동안 똑같은 증상이 서로 다른 원인처럼 보였다.

왜 이런 구조가 생겼나

돌이켜보면 fallback과 체크포인트가 서로 다른 책임을 가정하고 설계되어 있었던 게 문제였던 것 같다. fallback 미들웨어는 “이번 요청만 다른 모델로 대신 처리한다”는 걸 목표로 만들어졌을 텐데, 실제로는 그 요청의 스트리밍 결과가 대화 상태(checkpoint)에 그대로 저장되면서 다음 요청, 심지어 다음 모델에까지 영향을 주는 구조였다. fallback은 일시적인 우회여야 하는데 그 흔적은 영구적으로 남아버린 셈이다.

그리고 모델별로 스트리밍 응답 포맷이 다르다는 것도 문제를 키웠다. Anthropic은 input_json_delta로 tool call을 스트리밍하고 OpenAI는 다른 방식을 쓰는데, 체크포인트는 이걸 모델 중립적으로 저장한다고 가정하고 있었던 것 같다. 근데 실제로는 스트리밍 전용 포맷이 그대로 낀 채로 저장될 수 있었고, 그게 모델을 넘나드는 순간 터진 거다.

조치

1차로는 SRE팀에 요청해서 devel-claude-sonnet-4-6에 대한 CodePro용 키 권한을 추가했다. 이건 403을 막는 조치.

근본 조치로는 summary 미들웨어에 stripStreamingArtifactBlocks라는 로직을 추가해서, 대화 메시지를 처리하기 전에 스트리밍 전용 잔여 블록(input_json_delta, tool_call_chunk)을 AIMessage content에서 제거하도록 했다. 완성된 tool call 정보는 tool_calls 필드에 별도로 보존되어 있어서 content에서 이 잔여 블록들을 지워도 안전하다. 모델 종류와 무관하게 항상 제거하는 방식이라 이후에 어떤 모델 조합으로 전환하든 같은 문제가 재발하지 않는다. 잔재 블록을 걸러낸 순간은 console.warn으로 남겨서 나중에 세션/메시지가 교차 오염되는 케이스를 추적할 수 있게 해뒀다.

fallback 체인 쪽도 손볼 필요가 있다고 생각한다. 지금은 fallback 대상 모델이 API 키의 허용 모델 목록에 포함되어 있는지 검증하는 절차가 없어서, 이번처럼 fallback 체인과 키 권한이 어긋나는 설정 실수가 또 생길 수 있다. fallback 대상 모델은 항상 키 허용 목록에 포함되도록 설정 시점에 검증하는 게 맞을 것 같다.