지난 글은 순서만 정하고 끝났다. OpenRouter 어댑터 하나만 TanStack AI로 갈아 끼우는 spike를 브랜치에서 돌리고, 골든셋 dry run이 그대로 통과해야 하고, 무료 모델로 세 건만 실제로 돌려 확인 항목 다섯 개를 판별한다. 그 글을 올린 날 저녁에 했다. 커밋 셋, 첫 커밋에서 마지막 커밋까지 30분, 그 사이에 리뷰와 반영이 두 번. 세 건 실행은 다음 날 아침이었다. 이 글은 그 결과다.

갈아 끼운 것과 남은 것

바꾼 것은 OpenRouter 요청 경로 하나다. OpenAI Responses 경로, 프롬프트, 생성 계약, 정책과 검색과 인용 검증기, MCP 도구와 위젯은 그대로다. @tanstack/ai-openrouter가 안에서 @openrouter/sdk를 쓰니 의존성은 둘이 늘었고, 직접 만든 chat/completions 호출 코드는 빠졌다.

그런데 diff가 +463, −45다. 줄 줄 알았는데 늘었다. 이유가 둘이다.

하나는 고수준 chat()이 구조화 출력에서 스트리밍 경로를 먼저 고른다는 것이다. 내 앱은 작업 하나에 비스트리밍 HTTP 요청 하나를 원한다. 그래서 어댑터의 structuredOutput()을 직접 부른다. 다른 하나는 그 함수가 돌려주는 것이 데이터, 원문 텍스트, 토큰 사용량뿐이라는 것이다. 어느 모델이 답했는지, OpenRouter 뒤의 어느 엔드포인트가 실제로 처리했는지, 요청 ID가 무엇인지, finish_reason이 무엇인지가 없다. 이 넷은 무료 모델 편에서 하나씩 배운 것들이라 포기할 수 없었다. 그래서 SDK에 요청별 HTTP 클라이언트를 넘기고, 그 안의 fetcher에서 응답을 먼저 읽어 메타데이터를 기록하고 finish를 검사한 뒤 SDK에 다시 건네는 경계가 남았다. 어댑터를 갈아 끼웠는데 경계 코드는 그대로다. PR 본문에 “아직 코드 감소의 이점은 없다”고 적었다.

다섯 항목의 답

지난 글에서 “지원된다”, “문서가 없다”, “안 된다” 중 하나로 갈릴 것이라고 썼다. 갈렸다.

확인 항목
호출별 abort signal이 응답 본문까지 끊는가 된다. 단, 내가 fetcher를 쥐고 있어서다. request.signal과 호출 deadline을 합친 signal을 fetch에 넘기고 본문을 다 읽을 때까지 유지한다. 루프백 서버로 느린 본문을 흘려 두 방향(deadline, 호출자 취소) 모두 끊기는 테스트를 두었다.
실제로 답한 상위 엔드포인트를 결과에 남기는가 안 남긴다. structuredOutput은 model·provider·request ID·finish를 돌려주지 않는다. 위의 경계가 남은 이유이고, 이슈 후보다.
제공자 라우팅 옵션을 넘길 수 있는가 넘어간다. modelOptions.provider.requireParametersreasoning.effort가 wire에서 provider.require_parameters, reasoning.effort로 나가는 것을 fetch 스텁으로 캡처했다. 다만 문서가 models 목록과 샘플링 옵션만 다루고 있어서, 라우팅 절을 채우는 문서 PR을 열어 두었다.
length 잘림과 스키마 불일치를 구분하는가 구분하지 않는다. 어댑터는 JSON이 파싱되면 finish가 lengtherror든 그대로 돌려준다. 그래서 finish 검사는 경계에서 먼저 한다. 그리고 어댑터의 파싱 오류 메시지에 모델 원문이 실릴 수 있어, SDK에서 온 오류는 메시지를 버리고 고정 문구로 바꾼다.
openai-compatible 문서에 미지원 파라미터를 400으로 거절하는 제공자 주의가 있는가 이번엔 못 봤다. 이 spike는 OpenRouter 경로만 건드렸다. 그 표에 Upstage와 Liner를 추가하는 PR은 병합됐고, 400 주의는 Upstage 어댑터를 붙일 때 확인할 항목으로 넘긴다.

둘은 된다, 둘은 안 된다, 하나는 다음 spike로 넘긴다. 안 되는 둘은 그대로 업스트림 확인 항목이고, 문서에 소스 파일과 버전을 적어 두었다.

리뷰가 잡은 여섯 번째

다섯 개를 다 봤다고 생각했는데, 리뷰가 하나를 더 잡았다. 리뷰어는 PR을 그대로 받아 실행하는 방식을 쓴다. 검증 체인을 다시 돌리고, wire 요청을 캡처하고, 그 다음에 SDK가 응답을 어떻게 검사하는지를 봉투 필드 하나씩 빼 가며 bisect했다.

내 경계의 응답 스키마는 느슨했다. id, model, provider가 없어도 통과하고 choices[].finish_reason은 null이어도 된다. 그런데 @openrouter/sdk는 speakeasy로 생성된 SDK라 인바운드 스키마가 엄격하다. id, object: "chat.completion", created, model, system_fingerprint(null이어도 키는 있어야 한다), choices[].index, message.role이 전부 필수다. 키 하나가 빠진 봉투는 내 경계와 finish 검사를 통과해 텔레메트리에 ok로 찍힌 뒤, SDK에서 거절돼 provider_error로 분류된다. 성공으로 기록하고 실패로 끝나는 경로다. 테스트 픽스처가 SDK가 요구하는 필드를 다 갖춘 봉투라 이 간극이 보이지 않았다.

실제 OpenRouter 응답에 그 키들이 항상 있는지는 그 시점에 아무도 몰랐다. 이전 라이브 실행은 전부 OpenAI SDK 경로였다. 리뷰어의 제안은 둘이었다. SDK의 스키마를 경계에 채택하거나, 세 건을 실제로 돌려 확인하거나. 둘 다 했다.

첫 번째 반영에서 SDK가 export하는 ChatResult$inboundSchema를 경계에서 그대로 검사했다. SDK의 chatSend가 JSON 응답을 정확히 그 스키마로 파싱한다는 것을 리뷰어가 SDK 소스에서 확인해 줬으니, “경계는 통과, SDK는 거절”은 구조적으로 사라졌다. 관측 범위도 넓혔다. 예전에는 fetcher 안의 fetch만 관측했는데, 이제 SDK 준비부터 스키마 검증까지 한 번에 감싸서 어디서 실패하든 오류 이벤트가 정확히 한 번 남고, 응답을 받은 뒤 거절된 경우엔 토큰 사용량이 보존된다.

두 번째 재리뷰에서 하나가 더 나왔다. 봉투 필드 누락을 나는 기존의 schemaMismatch로 던지고 있었고, 그건 schema_failure로 집계된다. 메시지도 “라우팅된 엔드포인트가 구조화 출력을 강제하지 않을 수 있다”였다. system_fingerprint 키가 없는 것은 모델이 스키마를 못 지킨 것도, 엔드포인트가 구조화 출력을 못 하는 것도 아니다. 그대로 두면 모델 평가 보고서에서 전송 계약 문제가 모델 품질 문제로 읽힌다. envelopeMismatch를 따로 두고 provider_error로 분류했다. 봉투 오류와 모델 출력 오류를 나란히 놓는 대조군 테스트 둘을 추가했다. 테스트는 468에서 476, 478이 됐다.

여섯 번째 항목을 한 줄로 적으면 이렇다. 생성된 SDK 앞에 우회 경계를 두면 경계의 스키마는 SDK의 스키마보다 느슨해서는 안 되고, 픽스처가 SDK-완전 봉투면 그 간극은 테스트에 나타나지 않는다.

세 건을 실제로 돌렸다

다음 날 아침 하루짜리 키로 세 건을 돌렸다. 호출 아홉 번이 전부 ok였고 envelopeMismatch는 0이었다. 실제 응답 봉투가 SDK의 엄격한 스키마를 통과했다는 뜻이다. 적어도 이 무료 모델, 이 상위 엔드포인트(AtlasCloud)에서는 그 키들이 다 온다. 텔레메트리에는 응답 모델, 상위 제공자, 토큰(입력 4,134 / 출력 20,446 / 합계 24,580, 그중 추론 17,675)이 그대로 찍혔다. 지연 중앙값은 프로필 추출 6.7초, 공고 추출 12.9초, 판정 48.7초.

판정은 같은 세 건의 이전 기준선과 나란히 놓았다. 최종 판정은 셋 다 같고, 정책이 초안을 바꾼 것이 둘이다. 인용 유효율은 10건 중 1건으로, 기준선의 같은 세 건(17건 중 10건)보다 낮다. 같은 무료 모델, 같은 상위 엔드포인트라 비결정성일 수 있고, wire 스키마 이름이 structured_output으로 바뀐 것 같은 차이가 영향을 줬을 수도 있다. 세 건으로는 어느 쪽도 가릴 수 없다. 서른세 건 전체를 돌릴 때 기준선의 190건 중 70건과 비교할 항목으로 적어 두고, 병합을 막는 항목으로는 두지 않았다.

사고 하나

숨기지 않고 적는다. 첫 통합 테스트에서 fetch 스텁이 URL 문자열만 처리하고 있었는데, 새 SDK는 Request 객체를 넘긴다. 스텁이 그걸 놓쳤고, 가짜 키와 합성 이력서를 실은 요청 한 건이 실제 OpenRouter에 도달해 401을 받았다. 실제 키도 개인정보도 아니었지만, “테스트는 외부에 요청하지 않는다”는 전제가 한 번 깨진 것이다. 스텁을 고치고 vitest 기본 fetch가 루프백 밖 목적지를 거절하도록 setup을 추가했다. 그래서 PR 본문에는 “외부 요청 0회”라고 쓰지 않았다. 열흘 동안 “전송하지 않는다”는 표를 그렇게 정성껏 그려 놓고 테스트 스텁 하나에서 새는 것을 보니, 표는 코드가 아니라는 당연한 사실을 다시 배웠다.

그래서

지난 글에서 “결과가 이슈 목록이 되면 붙이는 쪽으로 간다”고 했다. 이슈 목록이 됐다. 어댑터 결과에 응답 메타데이터가 없는 것, finish를 검사하지 않고 파싱 오류에 원문이 실리는 것, 라우팅 문서가 비어 있던 것. 셋 중 하나는 이미 PR이고 둘은 소스 위치와 버전을 적어 둔 이슈 후보다. 붙이는 쪽으로 간다. PR은 병합됐다.

코드는 줄지 않았고, 경계는 남았고, 대신 그 경계가 무엇을 막고 있는지가 항목별로 분명해졌다. 지난 글에서 “앱은 증명의 대상이 아니라 재현 도구가 된다”고 썼는데, 이번 spike가 그 첫 재현이었다. 다음은 저쪽에 없는 Upstage 어댑터다. 저장소 안으로 갈지 커뮤니티 어댑터로 시작할지는 이슈 하나로 먼저 묻는다.