콘텐츠로 이동

변경 내역

Continuum Router의 모든 주요 변경 사항은 이 파일에 문서화되어 있습니다.

형식은 Keep a Changelog를 기반으로 하며, 이 프로젝트는 Semantic Versioning을 따릅니다.

v2.0.0 - 2026-11-03

Added

  • observability.callbacks를 추가했습니다 (#2376). 요청마다 trace 하나를 LLM 관측성 플랫폼의 네이티브 스키마로 내보내는 opt-in 1차 연동입니다. Langfuse를 OTLP/HTTP 엔드포인트로 제공하며 프롬프트 로그 내보내기 매퍼를 재사용하고, Braintrust, Arize, Langsmith는 후속 작업입니다. 섹션은 기본적으로 꺼져 있고 tracing.enabled와 독립적입니다. 프롬프트, 완성, 도구 호출 텍스트는 include_content: true이고 프롬프트 로그 캡처가 마스킹 단계를 거쳐 요청을 받아들였을 때만 내보내며, 자격 증명 값은 환경 변수를 떠나지 않고 HTTP Basic 인증에만 쓰입니다. 전달은 유한 큐와 전용 워커, 싱크별 상한 재시도로 실패를 허용하므로 느린 플랫폼이 응답을 지연시키거나 실패시키지 않으며, observability_callback_events_total{sink,result}와 observability_callback_queue_depth가 전달 상태를 보고합니다. 구성 도우미, 생성된 IDE 규칙 파일, 설계 기록(영문과 국문), 예시 설정, 템플릿, 관리자 설정 표면을 함께 갱신했습니다.

  • 선택적 notifications: 섹션을 추가했습니다 (#2416). 이 섹션은 클라이언트 대상 속도 제한 및 할당량 이벤트를 애플리케이션 웹훅으로 전송합니다. enabled: true이면 라우터가 rate_limit_exceeded(로컬 요청 속도, 토큰 예산, 인플라이트, 용량 거부와 허브 티어의 분당 한도), quota_low(허브 월 예산 비율을 처음 넘는 시점), quota_exhausted(허브 월 예산, 허브 코스트 센터 및 모델 예산, 로컬 범위 예산, 레거시 키별 max_tokens_budget)에 대해 서명된, 크기가 제한된 JSON 이벤트를 POST합니다. 프로듀서는 게이트, 병합, 대기열 추가만 수행하므로 느리거나 실패하는 수신자가 요청을 지연시키거나 실패시키지 않습니다. 대상마다 크기가 제한된 대기열과 전송 태스크 하나를 가지며, 재시도는 retry.max_attempts로 제한되고 백오프는 retry.max_delay로 상한을 둡니다. 페이로드는 키 ID, 고정된 이벤트 및 한도 이름, 범위가 제한된 수치만 담고, 키 자료, 프롬프트나 완성 텍스트, 요청 헤더는 담지 않습니다. signing_secret이 있으면 모든 요청에 X-Continuum-Timestamp와 실제 전송 본문에 대한 X-Continuum-Signature HMAC이 붙으며, 이 시크릿은 설정 출력에서 마스킹되고 ${ENV_VAR}를 지원합니다. 엔드포인트는 로드 시와 적용 시에 엄격한 SSRF 검증기로 확인하고, 확인한 주소는 연결에 고정되며, 리다이렉트는 비활성화되고, 사설 수신자는 allow_private_network: true가 필요합니다. 새 메트릭은 notification_deliveries_total, notification_retries_total, notification_queue_depth, notification_dropped_total, notification_coalesced_total입니다. 예시 설정, 구성 도우미, 생성된 IDE 규칙 파일, 영문과 국문 웹훅 알림 문서를 함께 갱신했습니다.

  • /v1/chat/completions에 선택적으로 켜는 모델 변형 접미사를 추가했습니다 (#2405). proxy.variant_suffixes.enabled: true이면 정확히 해석되지 않는 모델 이름 끝에 알려진 접미사 하나를 붙일 수 있습니다. :nitro는 정상이고 승인 가능한 후보 중 최신 측정 생성 처리량이 가장 높은 후보를, :floor는 least_cost와 같은 가격과 비용 추정으로 가장 저렴한 후보를 우선하며, :thinking은 메타데이터가 thinking 지원을 선언한 모델에서 클라이언트가 지정하지 않은 reasoning_effort를 high로 올리고, :online은 라우터 관리 web_search 루프를 실행합니다. 요청한 이름 전체를 먼저 해석하므로 콜론을 포함해 서빙되는 ID(Ollama name:tag)와 알 수 없는 접미사 단어는 기존 동작을 유지하며, 다른 인그레스에서는 문법을 해석하지 않습니다. :nitro와 :floor는 거부하지 않습니다. 순위를 매길 데이터가 없으면 일반 전략이 요청을 처리하고 응답에 x-continuum-model-variant-applied: false가 붙습니다. thinking 지원을 선언하지 않은 모델의 :thinking과 사용할 수 있는 실행기가 없는 :online(스트리밍 요청 포함)은 404 unsupported_model_variant로 응답합니다. 키별 allowed_models, 모델별 레이트 리밋, 모델별 토큰 예산은 업스트림으로 전달되는 베이스 ID를 기준으로 하며, 요청한 이름은 응답의 model에 반영되고, x-continuum-model-variant는 변형 단어를 담으며, 요청은 continuum_model_variant_requests_total{variant, applied}로 집계되고, 폴백 체인 홉에는 물려주지 않습니다. 구성 도우미, 예제 설정, 영문과 국문의 설정·설계 기록 페이지를 함께 갱신했습니다.

  • api_keys.jwt 아래에 선택적 인바운드 JWT/OIDC bearer 검증을 추가했습니다 (#2375). 기본값은 꺼짐이며 default/full 밖의 jwt-auth Cargo 기능 뒤에 있습니다. 정적 키가 항상 먼저이고, 키가 일치하지 않을 때만 JWT로 해석하며, 검증 전 iss가 설정된 issuer와 다른 토큰은 기존 키 불일치 경로로 넘어갑니다. 설정된 issuer를 가리키는 토큰은 https 또는 루프백 JWKS(또는 인라인 static_jwks)로 검증하고, 설정된 비대칭 알고리즘만 사용해 제한된 시계 오차 안에서 iss, aud, exp, nbf를 통과해야 합니다. alg: none, HMAC, kty: oct, 토큰이 제공한 jku/jwk/x5u는 거부합니다. JWKS 가져오기에는 고정 제한 시간과 리디렉션 금지, 스트리밍 크기 상한이 적용되며, 캐시는 TTL과 함께 알 수 없는 kid에서 속도 제한된 단일 실행 갱신을 사용합니다. 유효한 토큰은 설정 가능한 subject, user, team, scope, model, backend 클레임 매핑을 거쳐 평소의 AuthContext를 만듭니다. JWT의 scope 목록은 최종 기준이므로 매핑된 목록이 비어 있으면 scope를 부여하지 않습니다. 거부와 JWKS 결과는 continuum_jwt_auth_total{result}와 continuum_jwt_jwks_fetch_total{result}로 집계합니다. 기능이 없는 빌드는 설정을 검증하지만 해당 섹션이 비활성이라고 경고합니다. 활성화된 섹션으로 검증기를 만들 수 없으면 정적 키는 계속 동작하고, 설정된 issuer를 가리키는 토큰은 거부되어 misconfigured로 집계됩니다. 관리자 설정 스키마, 구성 도우미, 생성된 IDE 규칙, 예제 설정과 템플릿, 영문·국문 보안 및 설계 문서를 함께 갱신했습니다.
  • /v1/vector_stores 패스스루를 추가했습니다 (#2412). OpenAI SDK 클라이언트가 라우터를 통해 벡터 스토어를 생성, 목록, 조회, 수정, 삭제하고, 파일을 첨부·목록·조회·제거하며, 스토어를 검색하면서 라우터의 인증, API 키 scope, 속도 제한, 사용량 귀속을 유지할 수 있습니다. 스토어 호출은 모델을 지정하지 않으므로 생성과 목록은 설정된 전략으로 벡터 스토어 지원 백엔드 하나를 선택하고, 라우터는 (주체, 스토어 ID)를 스토어를 생성한 백엔드에 기록합니다. 스토어 범위 호출은 이 바인딩을 실행 중인 설정과 호출자의 allowed_backends에 대해 다시 검사하며, 알 수 없거나 다른 주체의 ID는 백엔드에 접속하지 않고 404 vector_store_not_found를 반환합니다. 목록은 호출자 자신의 스토어만 남기고, JSON 요청 본문은 1MiB로 제한됩니다. http/https의 openai, generic 유형만 후보이며, 어떤 후보도 처리할 수 없는 요청은 라우터 404 대신 400 vector_stores_not_supported를 반환합니다. 이 경로에는 요청 파라미터 정책, 가드레일, 응답 캐싱, 모델별 생성 예산이 적용되지 않으며, 각 호출은 0 토큰의 non-completion 요청으로 측정됩니다. 영문과 국문 API 페이지와 설계 기록을 추가했습니다.
  • /v1/chat/completions용 middle-out 메시지 변환을 추가했습니다 (#2415). proxy.middle_out.enabled를 켜고 클라이언트가 "transforms": ["middle-out"]로 opt-in하면, 공용 typical 추정치가 대상 모델의 컨텍스트 윈도우에서 출력 예산을 뺀 값을 넘는 입력을 중간 메시지 통째 제거로 피팅합니다. 선두 system·developer 프롬프트와 현재 턴은 유지하며, tool call과 그 tool result는 함께 제거합니다. 피팅된 응답에는 제거 수, 피팅 후 추정치, 목표, 컨텍스트 윈도우를 담은 x-continuum-middle-out 헤더가 붙고, 사용한 변환 항목은 디스패치 전에 제거됩니다. opt-in이 없거나 변환이 꺼져 있거나 입력이 이미 맞거나 보존 메시지만으로 목표를 맞출 수 없으면 페이로드는 바이트 단위로 동일하게 전달되며, 피팅된 요청은 컨텍스트 윈도우 fallback hop을 추가로 타지 않습니다. 구성 도우미, 생성된 IDE 규칙 파일, 영문·국문 middle-out 설계 문서를 함께 갱신했습니다.
  • 키별 데이터 상주 라우팅을 추가했습니다 (#2374). 백엔드는 운영자가 정의한 region_name 태그를 가질 수 있고, allowed_regions가 비어 있지 않은 API 키는 그 태그 중 하나를 가진 백엔드로만 라우팅할 수 있으며 매칭은 정확히 일치하고 대소문자를 구분합니다. 태그가 없는 백엔드는 조건을 만족하지 않으며, 목록은 allowed_backends와 교집합이 되고 교집합이 비면 요청한 리전 밖으로 라우팅하는 대신 403으로 fail closed합니다. 유효 목록은 폴백 홉과 헤지를 포함한 모든 디스패치 경로에 적용되고, 상주 범위가 다른 키는 응답 캐시 항목을 공유하지 않습니다. 어떤 백엔드도 선언하지 않은 태그는 시작 시 경고를 출력합니다. 설정 도우미, 설정 템플릿 하나, 예시 설정, 영문과 국문 설정 페이지를 함께 갱신했습니다.
  • circuit_breaker.latency_as_failure를 추가했습니다 (#2964). 2xx로 응답하지만 ttft_ms(스트리밍, 프로바이더 디스패치부터 첫 바이트까지) 또는 total_ms(버퍼링, 응답 전체)보다 오래 걸리는 백엔드는 해당 디스패치를 종단 실패 하나로 집계합니다. 백엔드는 기존 실패 임계값에서 라우팅에서 빠지고 일반 half-open 프로브로 복귀하며, 느린 프로브는 서킷을 다시 엽니다. 빠른 결과는 평범한 성공이고 성공 뒤에 실패가 따라오는 일은 없으며, 두 임계값은 서로 교차 적용되지 않습니다. minimum_samples(기본값 10)는 서킷이 마지막으로 닫히거나 윈도가 비워진 뒤의 판정 표본 수로 정의되는 워밍업이고 half-open 프로브는 이를 건너뛰며, model_overrides는 서빙 모델 ID에 자체 임계값과 하한을 줍니다. 버퍼링 chat 경로는 완료된 응답을 판정하고, 표준 chat 스트리밍 경로는 핸드셰이크 결과 판정을 릴레이의 첫 바이트 기록 시점으로 미루며, 다른 스트리밍 릴레이는 여전히 핸드셰이크에서 결과를 확정합니다. 전환은 circuit_breaker_transitions_total에 reason="slow"를 담고 느린 실패는 circuit_breaker_failures_total{error_type="slow"}로 집계됩니다. 설정 도우미, IDE 규칙 파일, 설정 템플릿 세 개, 예시·샘플 설정, 메트릭과 로드 밸런싱 페이지, 영문과 국문 서킷 브레이커 페이지를 함께 갱신했습니다.
  • retry.prefer_untried_backends를 추가했습니다 (#2965). 기본값은 꺼짐이며, 활성화하면 재시도가 같은 요청이 아직 연결하지 않은 사용 가능한 백엔드 중에서 선택합니다. 사용 가능한 모든 백엔드에 이미 연결한 경우에는 전체 후보 집합으로 되돌아가므로, 백엔드가 하나인 배포에서도 모든 시도를 수행합니다. 연결한 백엔드 집합은 재시도 상태에 저장되어 모든 시도 복제본이 공유하며, 버퍼링 디스패치 지점에서 선택, 홉 다이얼 한도, 서킷 및 속도 제한 허용이 모두 성공한 뒤에 기록합니다. 따라서 선택되었지만 허용 단계에서 거부된 백엔드는 집계되지 않습니다. false는 방금 실패한 백엔드를 다시 선택할 수 있던 기존 동작을 유지하며, 부수 효과가 있는 백엔드는 여전히 replay: disabled가 제어합니다. 설정 도우미, 예시 설정, 관리자 설정 스키마, 영문과 국문 설정 페이지를 함께 갱신했습니다.
  • logging.access.completion_line, success_level, client_error_level, server_error_level을 추가했습니다 (#2402). 요청 로깅 미들웨어가 핸들러에 도달한 요청마다 상태에 맞는 수준으로 완료 줄 하나를 남깁니다. 2xx·3xx는 success_level(기본값 info), 4xx는 client_error_level(기본값 warn), 5xx는 server_error_level(기본값 error)을 사용하며, 각 값은 debug, info, warn, error 중 하나입니다. 이 줄은 요청 ID, 사용자 ID, 조직 ID, 클라이언트 주소를 구조화 필드로 함께 담습니다. 이 줄은 기본적으로 꺼져 있어 logging.access를 생략한 설정은 기존 출력을 그대로 유지하고, 알 수 없는 수준 표기는 설정 불러오기에서 거부되며, 제외된 경로는 완료 줄을 남기지 않습니다. 핸들러 전에 거부된 요청은 계속 event=request_rejected로 보고됩니다. 요청·응답 본문 수집은 선택적 프롬프트 로깅 에픽 #2294 범위로 남으며, log_request_body나 log_response_body는 설정 불러오기 오류입니다. 구성 도우미, 생성된 IDE 규칙 파일, 예제 설정, 관리자 설정 스키마, 영문·국문 로깅 문서를 함께 갱신했습니다.
  • streaming.mid_stream_fallback.same_model_first를 추가했습니다 (#2966). 기본값은 꺼짐이며, 스트림 중간 실패나 정지 후 확정된 OpenAI 와이어 릴레이가 같은 공개 모델을 제공하는 다른 백엔드를 먼저 시도하고, 그다음에야 fallback.fallback_chains를 진행합니다. 형제 집합은 사용자 라우팅 대상이고, API 키에 허용되며, 릴레이가 처리할 수 있고, 서킷 브레이커가 허용한 백엔드로 구성되며, 이 요청에서 해당 모델로 이미 호출된 백엔드는 제외합니다. 이 홉은 클라이언트의 model을 유지하고, 릴레이의 연속 또는 재시작 프롬프트를 다시 사용하며, max_fallback_attempts에 포함됩니다. 형제 시도도 실패하거나 형제 서킷이 열려 있거나 형제 집합이 비어 있으면 기존과 동일하게 체인으로 넘어갑니다. 대체된 시도는 fallback_source로 기록되고 형제 시도는 같은 request_id 아래의 정상 후속 시도가 되며, streaming_fallback_total{reason="same_model_sibling"}로 집계됩니다. 스트림 중간 복구는 응답이 이미 확정되었으므로 여전히 X-Fallback-* 헤더를 보내지 않습니다. 예시 설정, 설정 도우미, 영문과 국문의 아키텍처·설정 페이지를 함께 갱신했습니다.
  • backends[].model_configs[].upstream_id를 추가했습니다 (#2963). 하나의 공개 모델 ID로 서로 다른 이름으로 서빙하는 백엔드를 묶을 수 있습니다. 설정된 id는 라우터와 클라이언트가 보는 모든 곳에서 라우팅 식별자로 남습니다(백엔드 선택, 키별 allowed_models 및 request_params 범위, /v1/models, 폴백 체인 조회, 사용량 원장). 반면 upstream_id는 해당 백엔드로 나가는 요청의 model 값이며, 네이티브 Gemini 호출에서는 models/{id} 경로 조각도 이 값으로 바뀝니다. 각 시도는 요청을 받는 백엔드에 맞춰 재작성되므로 재시도와 폴백 홉은 각자의 표기를 사용하며, 응답의 model은 버퍼링된 JSON 본문과 OpenAI 와이어 SSE 청크에서 공개 ID로 복원됩니다. 백엔드의 라이브 카탈로그는 공개 ID로 기여되므로 풀은 항목 하나를 게시하고, 업스트림 표기는 백엔드가 문자 그대로 공개하지 않는 한 라우팅 대상이 아닙니다. config validate는 비어 있는 upstream_id와 같은 백엔드의 다른 model_configs[].id와 충돌하는 값을 거부합니다. 서로 다른 백엔드가 같은 공개 ID를 서로 다른 업스트림 ID로 매핑하는 것은 허용됩니다. 영문과 국문 고급 설정 페이지를 함께 갱신했습니다.
  • 거절된 요청이 refused 사용량 레코드를 남깁니다 (#2368). 라우터가 디스패치 전에 거절한 요청은 사유 코드, 확인된 경우의 요청 모델과 키, 클라이언트가 받은 상태, 토큰 수 0을 담은 레코드 하나를 남기므로 허브가 어떤 백엔드도 받지 않은 요청을 표시할 수 있습니다. 라우팅, 권한, 백엔드 단위 레이트 리밋 거절만 해당하며, 레코드는 router 프로바이더 표식을 쓰고 시도 식별자를 담지 않으며 허브는 이 레코드를 모든 사용량 집계에서 제외합니다. 벤더링한 continuum-protocol을 허브 리비전 09f45d3c로 옮겼고, 이 리비전이 결과와 refusal_reason 필드를 받아들입니다. 새 메트릭 레이블 값 usage_records_total{outcome="refused"}이며 영문과 국문 아키텍처 페이지를 갱신했습니다.
  • fallback.empty_reply_retry를 추가했습니다 (#2440). 기본값은 꺼짐이며, 비스트리밍 /v1/chat/completions 응답에서 첫 choice에 message가 없거나 message.content(없으면 message.refusal)가 없거나 공백뿐이면 기존 폴백 체인을 전진시킵니다. 홉은 fallback.quality_check의 홉 경로를 그대로 쓰므로 체인의 시도 번호를 이어받아 max_fallback_attempts, 홉 타임아웃 배수, 폴백 다이얼 허가, 키의 backend/model 허용 목록, 비스트리밍 체인 예산이 모두 적용되고, max_extra_hops(1-5, 기본 1)가 이 트리거 자체의 홉 수를 추가로 제한합니다. tool_calls 응답과 finish_reason: length 응답은 트리거하지 않으며, fallback.quality_check가 적용되는 요청은 이 트리거를 건너뜁니다. 트리거는 가드레일 출력 게이트가 통과시킨 본문만 검사하고, 홉의 응답도 같은 게이트를 거치며, 홉이 실패하거나 예산을 초과하거나 차단되면 게이트를 통과한 가장 최근 응답을 제공합니다. 적용 범위는 proxy.internal_streaming과 같습니다. choice가 하나인 버퍼링 요청, 로그 확률 없음, text 또는 json_object 응답 형식, HTTP(S)의 OpenAI 와이어 백엔드이며, unix:와 네이티브 azure/anthropic/gemini/bedrock, replay: disabled 백엔드는 기존 동작을 유지합니다. 새 메트릭은 fallback_empty_reply_retry_total{result}입니다. 설정 도우미, IDE 규칙 파일, 설정 템플릿, 예시 설정, 관리자 설정 스키마, 영문과 국문의 아키텍처·설정·메트릭 페이지를 함께 갱신했습니다.
  • 선택적 섀도 트래픽 미러링을 추가했습니다 (#2468). shadow 섹션은 일치하는 클라이언트 요청 중 샘플링된 비율을 주 요청이 디스패치된 뒤 분리된 태스크에서 두 번째 모델로 재전송합니다. 따라서 주 응답, 헤더, 클라이언트가 체감하는 지연은 그대로이며, 섀도 실패는 기록만 되고 표면화되지 않습니다. 미러는 HTTP 계층으로 다시 들어가지 않으므로 클라이언트 속도 제한 쿼터를 쓰지 않고, 모델별·백엔드별 버킷을 차감하지 않으며, 서킷 상태를 바꾸지 않고, 사용량 행도 기록하지 않습니다. 클라이언트 자격 증명 헤더(authorization, x-api-key, api-key, x-goog-api-key, cookie, set-cookie, proxy-authorization, x-auth-token, x-access-token), 라우터 소유 귀속 헤더, 클라이언트 신원 및 테넌트 헤더(x-user-id, x-tenant, x-org-id), 라우터 제어 및 포워딩 헤더, 프레이밍 헤더는 제거되며, 섀도 백엔드는 자체 설정의 자격 증명으로 인증합니다. 샘플링은 API 키 ID 또는 사용자 ID의 안정적인 해시로 결정되고(sample_key: api_key 또는 user), none이면 요청마다 무작위입니다. max_request_bytes를 넘는 본문은 건너뛰고 집계하며, max_concurrent는 대기하지 않고 드롭하고, timeout은 시도 한 번의 전체 시간을 제한하며, 스트리밍 섀도 응답은 누적하지 않고 소진합니다. 새 메트릭은 router_shadow_requests_total{result}, router_shadow_latency_seconds{model,backend}, router_shadow_usage_tokens_total{model,backend,kind}입니다. 프롬프트와 완성 내용은 로그에 남지 않습니다. 섹션이 없거나 비활성화되어 있으면 요청 처리는 달라지지 않습니다. 설정 도우미, IDE 규칙 파일, 설정 템플릿 하나, 예시 설정, 영문과 국문의 섀도 트래픽 문서를 함께 갱신했습니다.
  • 스트리밍 응답에 요청별 비용·토큰 trailer를 추가했습니다 (#2546). 비스트리밍 응답을 이미 지원하던 proxy.response_cost_headers가 이제 사용량을 계량하는 스트리밍 경로(chat completions, completions, Responses API와 그 브리지)에 x-continuum-cost-microusd, x-continuum-tokens-in, x-continuum-tokens-out, x-continuum-tokens-cached, x-continuum-latency-ms를 HTTP trailer로 전달합니다. 응답 헤더가 Trailer 헤더로 필드를 예고하고, 마지막 프레임 뒤의 trailer 구간이 헤더와 같은 요금 계산과 같은 생략 규칙으로 값을 담습니다. 라우터가 직접 확정한 스트림 사용량을 쓰므로 백엔드의 usage trailer를 읽는 경로는 관련이 없습니다. HTTP/1.1 클라이언트는 TE: trailers로 필드를 요청하며(그때만 hyper가 인코딩), trailer를 무시하는 클라이언트도 본문 전체를 받습니다. HTTP/2는 협상 없이 전달합니다. HTTP/1.1과 HTTP/2 cleartext를 모두 지원하며, 구성 도우미와 영문·국문 설정 페이지를 함께 갱신했습니다.
  • json_schema 가드레일 프로바이더를 추가했습니다. 이 프로바이더는 해당 요청에서 클라이언트가 선언한 JSON Schema에 대해 어시스턴트의 구조화 출력을 검증합니다 (#2470). 스키마는 response_format.json_schema.schema(OpenAI Chat Completions), response_format.schema(llama.cpp), text.format.schema(OpenAI Responses), output_config.format.schema 또는 output_format.schema(Anthropic Messages)에서 읽으므로 정적으로 설정하는 것이 없고, 스키마를 선언하지 않은 요청은 아무 동작도 하지 않습니다. 의존성 없는 검증기는 type, enum, const, required, properties, additionalProperties: false, items, minimum, maximum을 다루고, $ref와 pattern은 무시하므로 클라이언트 스키마가 라우터로 하여금 URL을 가져오거나 비용이 큰 정규식을 컴파일하게 만들 수 없으며, 깊이와 위반 개수로 제한됩니다. monitor는 위반을 계수하고 응답을 그대로 통과시키며, enforce는 실패한 JSON Pointer와 키워드를 사유에 담은 schema_violation 차단을 반환하고 설정된 block_behavior로 렌더링합니다. options.repair: {enabled, max_retries (0-5), instruction}는 버퍼링된 응답을 검증 오류를 시스템 지시문으로 덧붙여 다시 디스패치하고 검증을 통과한 첫 응답을 제공하며, 스트리밍 응답은 조립된 메시지로 검증만 하고 repair하지 않습니다. 새 메트릭은 router_structured_output_checks_total{result}, router_structured_output_violations_total{keyword}, router_structured_output_repairs_total{result}이며 모두 레이블 cardinality가 제한됩니다. 구성 도우미 문서, IDE 규칙 파일, 영문과 국문 가드레일 페이지를 함께 갱신했습니다.
  • /v1/responses 변환 백엔드가 거부하는 대신 일부 네이티브 전용 필드를 버릴 수 있도록 backends[].responses_drop을 추가했습니다 (#2867). 버릴 수 있는 필드는 include(요청한 모든 값이 허용 목록에 있을 때만, 현재는 reasoning.encrypted_content뿐), prompt_cache_key, safety_identifier, reasoning.summary입니다. 기본값은 기존의 폐쇄형 거부이며, 목록을 설정하지 않으면 모든 응답과 메시지가 그대로 유지됩니다. 의미론적 필드(text, conversation, prompt_cache_options, prompt_cache_retention, multi_agent, context_management, max_tool_calls, reasoning.mode, reasoning.context, 도구, 프로그래밍 방식 호출, 네이티브 입력 항목)는 항상 거부하고, 알 수 없는 이름이나 의미론적 이름은 설정 로드 오류이며, responses_api: native는 목록을 무시합니다. 응답은 제거한 필드를 정렬하고 중복을 제거한 x-continuum-dropped-fields 헤더로 알리며, 이 헤더는 CORS로 노출되고 응답 캐시 적중 시에도 재생되며, responses_drop_fields_total{field}가 처리된 요청마다 버린 필드별로 한 번 증가합니다. prompt_cache_key를 버려도 라우터의 프롬프트, 프리픽스, 시맨틱 캐시 키는 바뀌지 않으며, 옵트인한 Codex CLI 요청은 응답 캐시 대상으로 남습니다. Codex CLI 가이드, 백엔드 설정 문서, 설정 도우미, 예시 설정과 샘플 설정을 영문과 국문으로 갱신했습니다.
  • 사용량 가중 백엔드 선택을 추가했습니다 (#2452). routing.usage_weight는 백엔드가 선언한 backends[].provider_tpm / provider_rpm 예산 중 라우터가 아직 쓰지 않은 몫으로 후보 순위를 매기는 가산 스코어링 항입니다. 라우터 로컬의 백엔드별 소비 윈도를 기존 토큰 계량에서 채우므로 새 데이터 소스 없이 클라우드 백엔드에도 동작합니다. 선언한 후보는 weight x 남은 비율을 받고, 아무것도 선언하지 않았거나 예산을 모두 쓴 후보는 정확히 0.0을 받아 기존 점수를 유지하므로, 알 수 없는 쿼터를 무제한으로도 잔량 0으로도 취급하지 않습니다. 이 항은 KV 오버랩·엔진 부하 항과 함께 적용되며, 한 후보를 엄격히 앞세우면서 나머지 항만으로는 같은 백엔드가 선택되지 않을 때 그 패스를 결정합니다. 따라서 실제 KV 적중은 KV 메트릭에 남습니다. 동점이면 selection_strategy로 되돌립니다. provider_tpm과 provider_rpm은 선언일 뿐 아무것도 강제하지 않습니다. 섹션은 기본적으로 꺼져 있고, 설정하지 않으면 선택 동작이 바이트 단위로 동일합니다. 항이 결정하거나 판정하지 못한 패스마다 routing_usage_weight_decisions_total{backend,reason}을 기록하며 reason은 applied, no_declaration, no_differentiation 중 하나입니다. 구성 도우미 문서, IDE 규칙 파일, 예제 설정, 그리고 영어·한국어 로드 밸런싱·고급 설정·메트릭 페이지를 함께 갱신했습니다.
  • Codex CLI 통합 가이드 docs/en/guides/codex-cli.md와 국문 문서를 추가했습니다 (#2851). 라우터 클라이언트로서의 CLI와 ChatGPT 구독 업스트림을 구분하고, responses 방식과 chat 방식 각각의 ~/.codex/config.toml을 보여 주며, openai와 azure는 기본으로 /v1/responses를 패스스루하지만 나머지 OpenAI 와이어 유형은 responses_api: native가 필요하다는 점을 설명하고, 실제로 관찰되는 400 unsupported_request_parameter 거부를 원인과 해결 방법에 대응시킵니다. 새 tests/responses/codex_cli_test.rs는 캡처한 codex-cli 0.166.0 요청 본문을 네이티브 백엔드로 보내 전달된 include, prompt_cache_key, store, 도구 목록을 검증하고, 변환 백엔드로 보내 거부 메시지가 prompt_cache_key를 지목하는지 검증합니다. 네이티브 전용 거부 메시지는 이제 두 가지 해결 방법을 함께 안내합니다. Responses API를 직접 제공하는 백엔드에 responses_api: native를 설정하거나, 해당 필드를 생략할 수 있는 클라이언트에서 Chat Completions 와이어로 요청을 보내면 됩니다.
  • 데이터 플레인 budgets: 섹션으로 장기 윈도 토큰 및 비용 쿼터를 추가했습니다 (#2461). budgets는 컨트롤 플레인과 무관하게 API 키별, 모델별, 전역 롤링 윈도 한도를 적용합니다. windows는 롤링 기간을 지정하고, scopes는 스코프별 tokens와 cost_usd를 설정하며, storage는 프로세스별 메모리 또는 공유 Redis를 선택하고, fail_open은 스토리지 장애 시 허용 여부를 정합니다. 이는 두 번째 트래커를 추가하지 않고 #2323 트래커를 확장합니다. api_keys[].max_tokens_budget은 명시적 키별 재정의로 유지되고 섹션의 per_api_key 한도는 설정하지 않은 키의 기본값이므로, 한 요청이 같은 스코프에 두 번 계상되지 않습니다. 비용은 model-metadata.yaml 가격에서 정수 마이크로 USD로 계산하며, 가격이 없는 모델은 토큰만 계상하고 비용은 계상하지 않습니다. 거부는 인그레스 프로토콜의 오류 봉투에서 스코프, 윈도, 설정된 한도를 명시하는 429와 type: budget_exceeded이며, 남은 양은 식별자에 묶인 키별 스코프에만 보고하고 공유 스코프에는 노출하지 않으며 X-Budget-* 헤더가 키별 수준을 전달합니다. 완료된 사용량은 OpenAI 및 Anthropic 와이어의 버퍼링 및 스트리밍 응답 모두에서 사용량 지점에서 정확히 한 번 기록됩니다. GET /admin/budgets는 현재 카운터를 보고하고 POST /admin/budgets/reset는 스코프의 윈도를 초기화하며, router_budget_rejections_total{scope,window}와 router_budget_used{scope,window,unit} 메트릭을 제공합니다. 이 섹션은 핫 리로드되며, 없거나 enabled: false이면 완전한 무동작입니다. 구성 어시스턴트, IDE 규칙 파일, 구성 템플릿 두 개, 예제 구성, 영어 및 한국어 구성, 보안, 설계 기록 페이지를 갱신했습니다.
  • 테넌트 간 선택적 공정 큐잉을 추가했습니다 (#2397). rate_limiting.fair_queuing은 식별자 차원(per_api_key, per_user, per_team, per_model)이 거부한 요청을 벌거벗은 429 대신 해당 테넌트의 경계가 있는 큐에 보류하고, 큐를 가중 공정 스케줄링으로 처리하므로 한 테넌트의 홍수가 다른 테넌트를 굶기지 못합니다. 테넌트는 리미터가 이미 과금하는 사용자, 키, 팀 식별자와 같고(tenant_dimension, 기본값 user), weights가 테넌트별 몫을 정합니다. 모든 수준에 경계가 있으며(max_queue_depth_per_tenant, max_total_queued, max_tenants, max_in_flight, max_wait_ms), 대기 시간은 요청의 전체 타임아웃에 포함되고, 큐에 있던 요청은 차례가 오면 리미터를 다시 검사합니다. 큐 진입 시점이나 대기 후 거부된 요청은 x-fair-queue-position과 x-fair-queue-wait-ms를 받고, 대기 후 수락된 요청은 두 헤더와 x-fair-queue-estimated-wait-ms를 함께 받습니다. error.details.fair_queue는 테넌트 차원, 사유, 위치, 대기 시간을 담습니다. 전역 및 클라이언트별 거부는 해당 마운트가 인증 전에 실행되므로 즉시 처리되고, deferred_admission은 토큰 예산 거부를 계속 담당하므로 두 기능을 함께 켤 수 있습니다. 새 메트릭은 rate_limit_fair_queue_depth, rate_limit_fair_queue_tenants, rate_limit_fair_queue_wait_seconds{tenant_dimension}, rate_limit_fair_queue_grants_total{tenant_dimension}, rate_limit_fair_queue_refused_total{tenant_dimension, reason}입니다. 설정 도우미, IDE 규칙 파일, 설정 템플릿 두 개, 예시 설정, 관리자 스키마, 영문과 국문의 속도 제한·아키텍처·설계 기록 문서를 함께 갱신했습니다.
  • rate_limiting.limits.*.max_in_flight_requests를 추가했습니다 (#2383). per_api_key, per_user, per_team, per_client에 적용되는 테넌트별 동시 처리 중인 요청 상한입니다. 테넌트 상한을 넘는 요청은 429, limit_type: "inflight", 차원을 밝히는 error.details.inflight_dimension, Retry-After: 1로 즉시 거부되며 큐에 쌓이지 않습니다. 슬롯은 요청 수와 토큰 예산 검사를 모두 통과한 뒤 잡으므로 거부된 요청은 슬롯을 차지하지 않으며, 응답 본문에 붙는 가드가 해제하므로 스트림은 마지막 프레임까지 슬롯을 유지하고 핸들러 실패나 클라이언트 연결 끊김이 슬롯을 해제합니다. 이 상한은 기본값 0(무제한)으로 꺼져 있고, global, per_backend, per_model에서는 무시되며(config validate 경고), 레플리카별로 회계하고, 새 inflight 범위 지정 바이패스 클래스로 면제할 수 있습니다. 새 메트릭은 rate_limit_inflight_requests{dimension,tenant}와 rate_limit_inflight_refusals_total{dimension,tenant}입니다. 설정 도우미, IDE 규칙 파일, 설정 템플릿 세 개, 예시 설정, 설계 기록, 영문과 국문의 속도 제한·아키텍처·변경 내역 문서를 함께 갱신했습니다.
  • 허브 tier_id를 키로 하는 rate_limiting.tier_overrides 맵을 추가했습니다 (#2406). 등급마다 키별 요청 속도, 버스트, 분당 토큰 예산을 따로 둘 수 있습니다. 제한기는 요청마다 허브에서 동기화된 키 테이블에서 등급을 해석하며, 이는 smart_routing.routing_policies[].when.key_tier가 가리키는 것과 같은 식별자입니다. 항목이 있으면 해당 등급 키의 유효 속도와 버스트가 바뀌고, 항목에 tokens_per_minute를 적으면 분당 예산도 바뀝니다. 명시적인 api_keys[].rate_limit(버스트와 분당 예산 오버라이드 포함)은 여전히 가장 구체적인 설정으로 등급보다 우선하며, 등급은 limits.per_api_key보다 우선합니다. 해석된 등급은 요청 속도 버킷 키와 분당 예산 버킷 키에 포함되므로, 등급이 바뀐 키는 새 등급의 버킷에서 시작하고 x-ratelimit-* 헤더는 실제로 적용된 제한을 보고합니다. control-plane 기능, 정책 집행이 켜진 컨트롤 플레인, 동기화된 정책 중 하나라도 없으면 맵은 아무 효과가 없고 모든 키는 이 맵이 없을 때의 설정을 유지합니다(config validate와 시작 로그가 경고). 이 제한은 로컬 제한입니다. control_plane.policy.minute_limits에 설정하는 공유 카운터를 쓰는 허브 등급의 rpm, input_tpm, output_tpm 윈도는 따로 집행되며 대체되거나 넓혀지지 않으므로, 요청은 두 경로가 모두 허용할 때만 통과합니다. config validate는 0인 속도나 버스트를 거절하고, config_report는 속도보다 작은 버스트를 거절하며, 항목 안의 알 수 없는 필드는 로드 오류입니다. 설정 도우미, 설정 템플릿 두 개, 예시 설정, 영문과 국문 속도 제한 및 아키텍처 문서를 함께 갱신했습니다.
  • 키별 범위 지정 속도 제한 우회와 키별 버스트·분당 토큰 예산 오버라이드를 추가했습니다 (#2426). rate_limiting.scoped_bypass_keys는 지정한 부류(request_rate, tokens_per_minute, tokens_per_day)에서만 키를 면제하며, bypass_keys는 기존의 전체 면제 의미를 유지합니다. api_keys[].rate_limit_burst는 해당 키에 적용되는 요청 속도의 버킷 용량을 정하고, api_keys[].rate_limit_tokens_per_minute는 해당 키의 per_api_key 분당 토큰 예산을 대체합니다(0이면 비활성화). 두 값 모두 설정 파일과 POST /admin/api-keys, PUT /admin/api-keys/{id}에서 설정할 수 있습니다. Admin API를 통한 키별 제한 변경은 이제 admin.audit.include_body 없이도 키 ID와 이전·이후 값을 담은 api_key_rate_limit_change 감사 줄을 따로 남깁니다. 키별 값은 설정 내보내기와 WebUI 비교 화면에서 제외됩니다. 한 사용자의 모든 키가 공유하는 예산은 여전히 이슈 #2381의 범위입니다. 설정 도우미, 설정 템플릿 두 개, 예시 설정, 영문과 국문의 속도 제한 및 Admin API 문서를 함께 갱신했습니다.
  • rate_limiting.limits.per_user와 per_team 차원을 추가했습니다 (#2381). 두 차원은 API 키가 이미 해석한 식별자를 기준으로 하며, per_user는 키의 사용자 ID를, per_team은 키의 조직/팀 ID를 사용합니다. 이제 한 사용자의 여러 키 또는 한 팀의 모든 구성원이 각자 별도 허용량을 받는 대신 하나의 버킷을 함께 사용합니다. 두 차원은 다른 차원과 같은 설정 형식을 가지며, tokens_per_minute와 tokens_per_day 예산도 같은 방식으로 공유됩니다. 키에 해석된 식별자가 없는 요청(익명, 식별자 없이 설정된 정적 키, 동기화된 team_id가 없는 허브 키. 이때 continuum-hub 자리표시자는 팀 식별자가 아닙니다)은 해당 차원을 건너뛰고 rate_limit_skipped_total{dimension="user"|"team"}으로 집계되며, 다른 식별자 없는 키와 공유하는 버킷에 절대 청구되지 않습니다. 버킷은 인메모리와 Redis 키 공간(per_user:<hash>, per_team:<hash>) 모두에서 레플리카 간 안정적인 식별자 해시를 키로 사용하고, 설정을 다시 적용하면 새 설정이 즉시 반영되며 테넌트 버킷이 초기화됩니다. 거절은 error.details.limit_type에 차원(user 또는 team)을 밝히고 rate_limit_exceeded_total{limit_type}으로 집계되며, 두 차원 모두 둘 이상의 자격 증명이 공유하므로 버킷 잔량은 응답 헤더에 노출하지 않습니다. 테넌트 차원을 설정했지만 빌드와 설정이 API 키 식별자를 해석하지 못하면 config validate와 시작 로그가 경고합니다. 설정 도우미, IDE 규칙 파일, 설정 템플릿 두 개, 예시 설정, 관리자 스키마, 영문과 국문의 속도 제한·아키텍처 문서를 함께 갱신했습니다.
  • 클라이언트가 보낸 업스트림 리소스 참조를 필드를 명시한 400 unsupported_request_parameter로 거부하는 닫힌 목록 responses_tools.deny_resource_fields를 추가했습니다 (#2891). 기본값은 vector_store_ids와 container_ids이며 모든 백엔드와 중첩 도구에 적용합니다. previous_response_id, conversation, prompt, item_reference는 선택 항목이며 패스스루 전략에만 적용합니다.
  • POST /v1/responses가 전달할 호스티드 도구 유형을 운영자가 정하는 responses_tools 설정 섹션을 추가했습니다 (#2873). unmodeled_types(기본값 deny)는 mcp, image_generation 같은 유형에, modeled_hosted_types(기본값 allow)는 web_search, file_search, code_interpreter, computer_use, programmatic_tool_calling에 적용되며, allow 항목(정확히 일치)과 deny 항목(ASCII 대소문자 무시)이 두 기본값보다 우선합니다. function, custom, local_shell, execution: "client"인 tool_search, 컨테이너로서의 namespace는 항상 허용되고, 네임스페이스 멤버는 깊이와 상관없이 판단합니다. 거부는 도구를 명시한 400 unsupported_request_parameter이며 모든 백엔드 유형에서 백엔드 선택과 캐시 조회보다 먼저 실행되고, 변경 사항은 다음 요청부터 적용됩니다.
  • prompt_logging.export.hub_push 런타임을 추가했습니다 (#2764). control-plane 빌드에서 control_plane.policy.enabled: true이면, 등록된 라우터는 프롬프트 로그 내보내기 작업 하나를 더 실행해 마스킹된 레코드를 등록 자격 증명으로 POST /api/agent/v1/prompt-logs에 보냅니다. 자체 커서로 레코드 파일의 끝에서 시작하고, usage_push_interval_secs마다 실행하며, 요청 하나는 허브의 prompt_log_batch_max_records(기본 100, 최대 500)개 레코드와 16 MiB의 인코딩된 JSON을 넘지 않고, 각 본문은 64 KiB로 자릅니다. 레코드는 키가 허브의 키 테이블에 allowed로 있고, default_consent가 아니라 그 allowed를 근거로 캡처되었으며, 로컬 denied가 이를 덮지 않을 때만 나갑니다. 키 없는 레코드, 배포 기본값으로 캡처한 레코드, 로컬에만 있는 키, 동의를 지정하지 않은 키의 레코드는 나가지 않으며, sink="hub" 아래 consent_keyless, consent_deployment_default, consent_key_absent, consent_withdrawn으로 집계합니다. 허브의 404는 밀린 레코드를 버리고 커서를 파일 끝으로 옮기며, 413은 요청 전체를 버리고, 400, 403, 409는 레코드를 하나씩 다시 보내 거부된 레코드만 집계하며, 401, 408, 429, 5xx, 전송 실패는 최대 60초의 백오프 뒤 같은 멱등 키로 같은 레코드를 재시도합니다. 허브가 이미 저장했다고 답한 레코드는 duplicate로 집계합니다. 404는 밀린 레코드를 영구히 버리고, 허브가 폐기한 라우터는 레코드가 하나씩 거부되며, 팀의 denied는 허브의 키 테이블을 통해서만 라우터에 도착합니다. 에이전트의 HTTP 클라이언트는 이제 어떤 엔드포인트에서도 리다이렉트를 따라가지 않습니다. 벤더링한 프로토콜 크레이트는 허브 1e3dac51(PromptLogBatch, PromptLogAck 와이어 타입과 RouterSettings.prompt_log_batch_max_records)로 옮겼고, 정책 적용 시 허브 키 테이블의 동의를 키 ID로 색인하며, make hub-compat에 프롬프트 로그 단계를 추가했고, 비활성 설정 경고는 이제 기능 없이 빌드한 바이너리나 control_plane이 없는 배포에만 적용됩니다.
  • rate_limiting.limits.*.tokens_per_day 필드를 추가했습니다. 모든 수락 시점 차원에 tokens_per_minute 옆의 일일 토큰 예산을 더하며 에픽 #2401의 토큰 예산 표면을 완성합니다 (#2327). 텍스트 생성 요청은 수락 시점에 설정된 모든 차원의 일 버킷에 입력 추정치와 출력 한도를 예약하고 분 버킷과 같이 보고된 사용량으로 재조정하며, 요청은 설정된 모든 차원의 두 예산을 모두 감당해야 합니다. 일 버킷의 용량은 속도의 1일치이고 초당 tokens_per_day / 86400 토큰으로 누출 버킷(leaky-bucket) 방식으로 보충됩니다. 자정에 리셋되는 캘린더 데이 방식이 아닙니다. 인증 단계의 #2323 max_tokens_budget 롤링 윈도 할당량과 함께 적용되지만 서로 다른 방식입니다. 거부는 error.details.token_dimension에 차원을, error.details.token_budget에 소진된 예산(minute 또는 day)을 밝히고, 신원 범위의 per_api_key 일일 예산은 새 x-ratelimit-tokens-day-{limit,remaining,reset} 헤더로 보고합니다. 일 버킷은 token_budget_day 키 네임스페이스 아래의 Redis 기반이며 TTL이 최소 1일로 올라갑니다. 새 메트릭은 rate_limit_token_budget_period_tokens_total{dimension,budget,outcome}과 rate_limit_token_budget_period_refusals_total{dimension,budget}입니다. per_backend.tokens_per_day는 무시되며 config validate가 경고합니다. 설정 도우미, IDE 규칙 파일, 설정 템플릿 두 개, 예시 설정, 영문과 국문의 속도 제한·아키텍처·설계 기록 문서를 함께 갱신했습니다.
  • 토큰 예산 거부의 Retry-After를 크기 인식으로 바꾸고 선택적 rate_limiting.deferred_admission 모드를 추가했습니다 (#2423). 거부의 Retry-After는 고정된 윈도가 아니라 소진된 예산이 거부된 요청 자체의 비용을 다시 채우는 데 필요한 시간을 알려 줍니다. 버스트 안에서의 부족 거절은 기존의 부족분-리필 대기를 유지하고, 버스트 전체보다 큰 요청(기다려도 들어갈 수 없는 요청)의 거부는 요청 비용을 예산의 tokens_per_minute에 대비해 계산하며 최소 1초, 최대 86400초입니다. deferred_admission.enabled: true이면 대기 시간이 max_wait_ms(기본값 2000)에 맞는 거부 요청을 429 대신 그 시간 동안 보류합니다. 보류는 라우터 프로세스마다 프로세스 로컬 스토어 카운트인 max_held_requests(기본값 1000)로 경계가 정해지고, 보류된 요청은 대기가 성공할 때까지 백엔드 슬롯도 토큰 예약도 잡지 않으며, 재시도 시점에 요청 수 차원을 다시 과금하지 않습니다. 클라이언트 취소는 가드의 Drop으로 보류를 해제하고, 버스트 전체보다 큰 요청은 보류하지 않습니다. 어느 경계든 넘는 요청은 크기 인식 힌트와 함께 즉시 429를 받습니다. 설정 도우미, IDE 규칙 파일, 예시 설정, 설정 보고 경고, 영문과 국문 속도 제한 문서를 함께 갱신했습니다.
  • 프롬프트 로그 내보내기에 운영자 복구 수단을 추가했습니다(#2820). 싱크의 on_permanent_failure: skip은 상태가 400, 409, 413, 415, 422인 거부 배치를 포기하고, 전달하지 못한 레코드를 prompt_log_export_total{result="dropped"}로 집계합니다. 기본값 block은 계속 재시도합니다. cursor_id는 대상의 해시 대신 싱크의 커서 파일 이름을 정하므로, 웹훅 URL 토큰을 교체해도 커서가 유지되고 남은 레코드는 새 대상으로 전달됩니다.
  • 로컬에 설정한 API 키에 prompt_logging_consent를 추가했습니다 (#2819). 키는 api_keys[], 외부 키 파일, Admin API, WebUI에서 allowed 또는 denied를 나타낼 수 있습니다. 허브 없는 배포에서도 default_consent: denied로 옵트인 캡처를 운영하거나 default_consent: allowed에서 특정 키만 제외할 수 있습니다. 로컬 또는 허브 키의 denied가 가장 우선하고, 다음은 로컬 allowed, 허브 allowed, default_consent 순입니다. 허브의 실패 시 차단 구간은 여전히 로컬 값보다 우선합니다. 이 필드가 없는 키는 이전과 같이 동작합니다. denied를 지정한 비활성화 또는 만료 키는 permissive 모드에서도 캡처되지 않으므로, 키를 비활성화해도 동의 범위가 넓어지지 않습니다. 이런 키의 allowed는 캡처를 허용하지 않습니다. 잘못된 값으로 인한 YAML 시작 또는 재로드 오류는 이제 필드 경로를 표시합니다.
  • 다른 백엔드가 활성화된 상태에서 backends[].models에 모든 이름과 일치하는 항목(* 또는 models/*)이 있으면 config validate가 경고합니다 (#2792). 이 항목은 메타데이터 별칭 요청을 해당 백엔드에 유지하지 않으므로, 요청이 정식 ID를 제공하는 외부 제공자로 전달될 수 있습니다.
  • 스트림 종료 시점에 스트리밍 사용량을 토큰 예산으로 정산하도록 변경했습니다 (#2420). #2395의 예약 수명 주기를 완성하는 변경입니다. 정상적으로 끝난 스트리밍 완료는 수락 예약을 최종 사용량으로 정산합니다. 백엔드의 사용량 블록이 있으면 그 값을 쓰고, 없으면 입력 추정치에 라우터가 센 델타 수를 더한 값으로 정산하므로 사용량 블록이 없는 정상 종료도 예약을 의도 있게 끝냅니다. 끝나기 전에 끊긴 스트림(클라이언트 취소, 타임아웃, 업스트림 오류, 가드레일 차단)은 정산하지 않습니다. 예약은 응답 본문 가드가 전부 해제하며, 끊긴 스트림은 스트림이 완료된 것처럼 과금되지 않습니다. 스트림 중간의 fallback 홉도 정산하지 않으며, 후속 시도가 최종 보고 사용량으로 예약을 정산합니다. 정산은 정확히 한 번 일어나고 본문 가드의 이후 해제와 멱등하며, 클라이언트 응답을 막거나 늦추지 않습니다. chat completions SSE 파이프라인, Responses 변환 및 패스스루 릴레이, 유닉스 소켓 릴레이, responses-only 브리지, Anthropic 인그레스의 스트림 종료 행이 모두 정산합니다. 요청 수 차원은 그대로이며 토큰 차원은 기본값 꺼짐입니다. 설계 기록, 영문과 국문 속도 제한 문서, 설정 도우미를 함께 갱신했습니다.
  • 모든 수락 시점 속도 제한 버킷에 공개 차원 rate_limiting.limits.*.tokens_per_minute를 추가했습니다 (#2395). #2419의 예약 수명 주기를 완성하는 변경입니다. 텍스트 생성 요청(/v1/chat/completions, /v1/completions, /v1/responses, /v1/responses/compact, /v1/messages, /anthropic/v1/messages)은 전체 요청 입력 추정치와 출력 한도(max_tokens, max_completion_tokens, max_output_tokens, 셋 다 없으면 4096)를 per_client, per_api_key, 클라이언트가 지정한 모델과 일치하는 per_model 항목, global에 예약하고 완료 시점에 백엔드가 보고한 사용량으로 재조정합니다. 남은 토큰은 예산으로 돌아가고 초과분은 추가로 과금됩니다. 각 버킷의 버스트는 설정된 속도의 1분치이며 버킷 전체보다 큰 요청은 즉시 거부합니다. 요청을 감당하지 못하는 첫 차원이 429와 limit_type: "token_budget", 거부한 차원을 밝히는 error.details.token_dimension으로 응답합니다. Redis 기반 검사가 Redis에 닿지 못하면 redis.fallback_to_memory를 따르며 fail-closed이면 Retry-After: 5와 함께 503을 반환합니다. 응답 헤더 x-ratelimit-tokens-{limit,remaining,reset}으로는 per_api_key 예산만 보고합니다. per_backend.tokens_per_minute는 무시되며 config validate가 경고합니다. 새 메트릭은 rate_limit_token_budget_tokens_total{dimension,outcome}과 rate_limit_token_budget_refusals_total{dimension}이고 건너뛴 차원은 rate_limit_skipped_total{dimension="token_budget_<차원>"}으로 집계됩니다. 스트리밍 응답은 본문이 끝날 때까지 예약을 유지하며 스트림 정산은 #2420입니다. 설정 도우미, IDE 규칙 파일, 설정 템플릿 두 개, 예시 설정, 영문과 국문 속도 제한 문서를 함께 갱신했습니다.
  • /v1/fine_tuning/jobs 패스스루를 추가했습니다 (#2358). OpenAI SDK 클라이언트가 라우터를 통해 파인튜닝 작업을 생성, 목록, 조회, 취소하면서 라우터의 인증, 속도 제한, 사용량 귀속을 유지할 수 있습니다. 생성은 요청의 model로 백엔드를 선택하고, 목록은 파인튜닝을 지원하는 백엔드 하나에서 호출자의 작업만 반환하며, 조회와 취소는 라우터별 바인딩을 통해 작업을 생성한 백엔드와 주체에만 도달합니다. http/https의 openai, generic 유형만 후보이며, 어떤 후보도 처리할 수 없는 요청은 라우터 404 대신 400 fine_tuning_not_supported를 반환합니다. 작업 CRUD에는 요청 파라미터 정책, 가드레일, 응답 캐싱, 모델별 생성 예산이 적용되지 않으며, 각 호출은 0 토큰의 non-completion 요청으로 측정됩니다.
  • 프롬프트 로깅 운영 가이드 docs/ko/guides/prompt-logging.md를 추가했습니다 (#2333). 기본값에서 라우터 밖으로 나가는 정보, 캡처 활성화, 동의와 실패 시 차단 구간, 마스킹, 보존, webhook·Langfuse·S3 싱크와 최소 한 번 전달 규칙, 기록되는 경로와 기록되지 않는 경로, 확인할 메트릭을 다룹니다. 허브 푸시와 허브 열람은 아직 사용할 수 없다고 명시하며, 설계 기록은 출시된 내용에 맞게 갱신했습니다.
  • 백엔드별 forward_cache_control을 추가했습니다 (#2343). /anthropic/v1/messages에서 OpenAI 와이어 형식으로 변환되는 요청은 선택된 백엔드가 이 기능을 켠 경우 클라이언트의 cache_control 브레이크포인트를 유지합니다. 시스템 텍스트, 메시지 텍스트, 이미지, 문서 텍스트 파트의 브레이크포인트가 변환이 만든 OpenAI 와이어 파트로 복사되며, 시스템 블록 배열은 하나의 문자열로 합쳐지지 않고 블록별 브레이크포인트를 유지합니다. 피어 라우터의 수신 측은 이 파트들로 Anthropic 블록 배열 system과 이미지 source.cache_control을 다시 구성합니다. 기본값은 type: continuum-router에서 true, 그 밖의 타입에서는 false입니다. 엄격한 클라우드 엔드포인트와 대부분의 로컬 엔진이 이 필드를 거부하거나 무시하기 때문이며, 네이티브 anthropic과 bedrock 백엔드는 영향을 받지 않습니다.
  • prompt_logging.export.sinks 런타임을 추가했습니다 (#2328). prompt_logging.enabled가 true이면 설정된 싱크마다 소유자 전용 커서(directory 아래 .export-cursor-<id>.json)로 레코드 파일을 읽고, 요청 경로 밖에서 크기가 제한된 배치를 최소 한 번 보장으로 전달하며 실패하면 백오프로 재시도합니다. 모든 레코드는 읽을 때 redaction.patterns를 다시 거칩니다. 싱크는 webhook(JSON 배열, headers_env로 자격 증명 지정), langfuse(public_key_env와 secret_key_env의 Basic 인증으로 {endpoint}/api/public/otel/v1/traces에 OTLP JSON 전송. Langfuse가 Cloud에서 /api/public/ingestion을 2026-11-16에 종료하므로 이 경로를 사용합니다), s3(파일 위치에서 유도한 키로 배치마다 JSONL 오브젝트 하나 업로드. 공식 릴리스 바이너리에 포함된 새 prompt-log-s3 기능 필요)입니다. 커서 파일이 없는 싱크는 시작 후에 기록된 레코드만 내보내며, 시작 위치를 곧바로 저장하므로 재시작해도 건너뛰는 레코드가 없습니다. backfill: true이면 남아 있는 모든 레코드를 먼저 보냅니다. 읽을 수 없는 커서는 위치를 옮기지 않고 재시도합니다. 해석할 수 없는 커서는 WARN을 남기고 backfill을 따르며, false이면 끝에서, true이면 가장 오래된 파일부터 시작합니다. 사설 주소로 보이지 않는 호스트의 http:// 엔드포인트에는 평문 전송 경고를 남기고, s3 싱크는 access_key_id_env와 secret_access_key_env를 함께 지정하거나 둘 다 생략해야 합니다. 새 메트릭은 prompt_log_export_total{sink,result}와 prompt_log_export_backlog_bytes{sink}입니다. 레코드 파일 순번은 재시작 후 비어 있는 낮은 번호를 다시 쓰지 않고 기존 파일과 커서보다 높은 번호로 이어집니다.
  • fallback.fallback_policy.content_policy_fallback을 추가했습니다 (#2319). 이 값이 true이면, 비스트리밍 POST /v1/chat/completions 요청의 입력 가드레일 차단이 요청을 종료하는 대신 디스패치된 모델의 fallback_chains를 전진시킵니다. 따라서 기본 모델에 엄격한 정책이 적용되어도, 더 느슨한 홉 모델이 허용할 요청을 거부하지 않습니다. 홉은 디스패치 전에 홉 모델 자체의 입력 게이트를 클라이언트 문서에 다시 실행하고, max_fallback_attempts 횟수에 포함되며, 자체 fallback_timeout_multiplier 시간 창과 폴백 다이얼 퍼밋을 사용하고, 키의 allowed_backends를 따르며 allowed_models를 다시 적용하고, 응답을 제공하기 전에 홉 모델의 출력 게이트를 거칩니다. 홉 게이트가 차단하면 그 차단 응답을 제공하고 체인을 멈춥니다. 홉이 제공한 응답은 이어서 fallback.quality_check(설정된 경우 usage.quality_judgment 포함)의 판정을 받으며, 품질 검사는 체인을 한 번 더 전진시킬 수 있습니다. 전송 실패는 종료 실패가 아닌 경우 다음 체인 모델로 진행합니다. replay: disabled 백엔드에 도달했을 수 있는 홉과 이미 출력을 생성한 내부 스트리밍 디스패치는 최종 실패이며, 어떤 모델도 응답할 수 없으면 기본 모델의 차단 응답을 제공합니다. 클라이언트의 models 목록은 해당 요청의 체인을 대체합니다. 기본 패스와 홉 패스는 요청 수준 입력 결정 하나로 합쳐지고, 응답을 제공한 홉은 X-Fallback-Reason: content_policy를 보고하며, fallback_content_policy_hops_total{result}는 triggered, hopped, no_chain, blocked_again, budget_spent를 집계합니다. 기본값은 false이며 최종 차단 계약을 바이트 단위로 동일하게 유지하고, 스트리밍 요청, 분류기 이그레스 게이트, responses 브리지, 웹 검색 루프 요청, 그 밖의 인그레스, 그리고 다른 모델로의 홉을 끈 요청(disable_fallbacks: true 또는 fallback: within_variant 실험)은 이 트리거를 적용하지 않습니다.
  • 명시적 체인이 없는 모든 모델에 적용되는 포괄 폴백 목록 fallback.default_chain을 추가했습니다 (#2348). 해석 순서는 model_settings[].custom_fallback_chain, fallback_chains[model], default_chain 순서이며, 명시적 항목이 항상 우선합니다. 목록을 비워 두면 기존 동작(체인 없음, 추가 할당 없음)이 그대로 유지됩니다. 이 목록은 평면 공유 목록이므로 요청 모델 자신의 이름은 해석된 목록에서 제외되어 모델이 자기 자신으로 홉하지 않고, 실행기는 홉 대상의 체인을 다시 따라가지 않으므로 목록이 순환할 수 없습니다. 이름은 fallback_chains와 같은 규칙을 따르며 중복은 거부됩니다. 요청 키의 allowed_models가 허용하지 않는 홉 대상은 디스패치하지 않고 건너뛰며, (원본, 대상) 쌍마다 한 번만 경고를 기록하고 fallback_default_chain_hop_skipped_total{original_model,fallback_model}로 집계합니다. 모든 대상이 제외된 요청은 해당 모델에 체인이 없는 것과 동일하게 동작하고, 명시적 체인은 기존 동작을 유지합니다. 이제 목록에 없는 모든 모델이 체인을 가진 것으로 취급되므로, models 목록이 비어 있는 quality_check가 해당 모델의 응답도 판정하고 응답 캐시 라우트는 비활성화됩니다. 기본 체인 대상이 활성화된 어떤 백엔드에서도 서빙되지 않으면 config validate가 경고하며, 활성 백엔드가 런타임에 모델을 검색하는 경우에는 이 경고를 생략합니다.
  • 옵트인 alerting 설정 섹션을 추가했습니다 (#2356). alerting.enabled: true와 webhook.url이 있으면 라우터는 백엔드 상태 전환(backend_unhealthy, backend_recovered), 서킷 브레이커 전환(circuit_opened, circuit_closed), Hub에서 동기화된 키 예산 마일스톤(budget_exhausted, 설정한 비율에서 한 번의 budget_fraction 경고)을 메타데이터만 담은 JSON 페이로드(event, occurred_at, router_id, subject, detail, suppressed)로 운영자 웹훅 하나에 푸시합니다. 전달은 fail-open입니다. 생산자는 유한 큐(max_queue)에 try_send만 하고, 전용 태스크가 POST를 수행하며, 큐가 가득 차면 이벤트를 버리고 집계합니다. 재시도는 상한이 있는 지수 백오프를 쓰고, 이벤트와 대상별 cooldown이 반복을 병합하며 다음 이벤트가 억제 횟수를 함께 전달합니다. 실패는 URL 없이 로그로 남기고 alert_deliveries_total{event,result}에 집계하며, alert_queue_depth, alert_dropped_total, alert_coalesced_total{event}도 제공합니다. 섹션은 기본으로 꺼져 있고 매 로드마다 검증합니다. https만 허용하고, 자격 증명을 포함할 수 없으며, allow_private_network가 아니면 엄격한 SSRF 차단 목록을 적용하고, 확인한 주소를 고정합니다. 두 예산 이벤트는 control-plane 빌드가 필요하며, 없으면 시작 로그로 비활성임을 알립니다. 핫 리로드에서 새 웹훅은 리로드 이후 큐에 들어간 이벤트부터 적용되고, 서빙 경로가 알림기를 시작합니다(비활성 상태에서는 대기).
  • context_window_exceeded 폴백 트리거를 추가했습니다 (#2307). fallback.fallback_policy.trigger_conditions.context_window_exceeded: true이면, 입력이 기본 모델의 선언된 컨텍스트 윈도우를 넘을 때 디스패치 전에 그 입력을 담을 수 있는 첫 번째 설정 체인 대상으로 홉합니다. 버퍼링 요청과 스트리밍 요청 모두 동일하게 동작하며, 요청에 max_tokens가 없어도 대상은 입력 크기로 판정합니다. 트리거 기본값은 false이므로 기존 400 context_length_exceeded 동작이 유지됩니다. 적합한 대상이 없으면 클라이언트는 백엔드 호출 없이 기본 모델의 윈도우와 추정 입력 토큰 수를 명시한 같은 거부 응답을 받고, 한도를 알 수 없는 기본 모델은 fail-open으로 처리됩니다. 디스패치된 홉은 X-Fallback-Reason: context_window_exceeded를 보고합니다. Hub 설정 동기화는 이 키를 받아들이며, 허브는 이 키를 이해하는 라우터 버전에서만 전송해야 합니다. 관측 경로 레지스트리에 이 키가 없는 허브는 하트비트 전체를 거부하므로, 라우터는 설정 스냅샷에 이 키를 보고하지 않습니다 (#2844). 호환 하한을 포함해 지원하는 모든 허브가 이 키를 갖추기 전까지는 보고를 재개할 수 없습니다.
  • 같은 모델을 서빙하는 후보 중 예상 비용이 가장 낮은 적격 백엔드로 라우팅하는 LeastCost 선택 전략을 추가했습니다 (#2335). 비용은 요청의 토크나이저 없는 입력 추정치와 출력 상한을 1M 토큰당 USD 단위의 PricingInfo로 계산합니다. 출력 상한은 채팅의 max_tokens 또는 max_completion_tokens, Responses의 max_output_tokens, Anthropic Messages의 max_tokens입니다. 가격은 백엔드의 model_configs[].metadata.pricing을 먼저 보고, 없으면 모델 메타데이터 카탈로그를 보며, 추정치가 임계값을 넘으면 장문 맥락 구간을 적용합니다. 가격이 없는 후보는 무료로 취급하지 않고 마지막 순위로 밀며, 비용이 같으면 출력 단가로 순서를 정한 뒤 풀 카운터로 순회합니다. 가격이 책정된 후보가 하나도 없으면 RoundRobin과 똑같이 동작합니다. 본문을 파싱하지 않는 디스패치 경로(오디오, 토크나이즈, 실시간, ACP)는 입력 100만 토큰, 출력 0이라는 문서화된 단가 기준을 사용합니다. config validate는 둘 이상의 백엔드가 서빙하는 모델에 가격이 책정된 복제본이 둘 미만이면 경고하며, configuration-assistant 지식, CLI 도움말, 관리자 스키마, 샘플 설정, 로드 밸런싱 가이드에 전략을 문서화했습니다.
  • 운영자가 실행하는 HTTP 서비스를 범용으로 호출하는 webhook 가드레일 프로바이더를 추가했습니다 (#2267). 라우터가 {stage, model, texts: [...]}를 POST하고 {decision: allow|block|transform, category?, confidence?, reason?, spans?}를 읽으므로, 사내 규칙 서비스나 계속 운영 중인 LiteLLM CustomGuardrail, 고객 목록·체크섬·사전 대조 로직을 가드 모델이나 chat 엔드포인트, Presidio 분석기를 흉내 내지 않고 하나의 문서화된 계약으로 연결할 수 있습니다. transform은 반환된 구간을 마스킹하며, 구간 오프셋은 유니코드 코드 포인트이고 마스킹은 PII 프로바이더의 중첩 해소·리다크션 경로를 재사용합니다. phase: sanitize, on_error, timeout_ms, 프로바이더별 서킷 브레이커는 다른 프로바이더와 동일하게 동작하고, 호출은 공유 HTTP 클라이언트와 제한된 재시도, 1 MiB 응답 상한을 사용합니다. endpoint는 필수이며 backend는 허용하지 않습니다.
  • 옵트인 프롬프트 로깅을 요청 경로에 연결했습니다 (#2320). prompt_logging.enabled: true이면 키별 동의 게이트를 통과한 POST /v1/chat/completions(A2A 브리지 포함), POST /v1/responses, POST /anthropic/v1/messages 요청(buffered와 스트리밍 모두)마다 새 필드 prompt_logging.directory(기본 ./prompt-logs, 0700으로 생성, 파일은 0600, retention으로 순환과 정리) 아래 prompt-log-<sequence>.jsonl에 마스킹된 JSONL 레코드 하나를 추가합니다. 레코드에는 프롬프트, 완성과 tool call, 사용량, 동의 근거, 결과(ok, refused, backend_error, 부분 응답을 담은 client_cancelled, stream_errored)가 들어가고, hop을 넘어간 fallback은 응답한 hop 기준으로 레코드 하나만 남기며, content 필드는 각각 256 KiB에서 잘라 새 추가 필드 truncated에 잘린 필드를 적습니다. 게이트는 정책 enforcement 미들웨어 뒤, 본문을 복사하기 전에 실행되므로 꺼진 섹션, denied 키, 아직 정책이 없는 Hub 정책 저장소는 아무것도 복사하지 않습니다. 마스킹은 요청 태스크에서 한 번 실행되며 내장 자격 증명 패턴과 redaction.patterns를 적용하고 일치 구간마다 redaction.masked_replacement를 그대로 넣습니다($는 더 이상 확장되지 않고, \b 같은 폭이 0인 패턴은 레코드를 망가뜨리는 대신 건너뜁니다). 쓰기는 fail-open입니다. 전용 스레드가 파일을 소유하고 요청 경로는 크기가 제한된 큐로 레코드를 넘기며, 큐가 가득 차거나 쓰기에 실패하면 레코드를 버리고 prompt_log_records_total{result}(written, queue_full, sink_error)로 셀 뿐 요청에는 손대지 않습니다. /v1/completions, 임베딩, ACP는 아직 캡처하지 않습니다. 응답 캐시 적중은 레코드를 남기지 않습니다. 섹션은 여전히 재시작이 필요하고, enabled: true는 더 이상 inert 설정 경고를 남기지 않습니다. 조기 종료된 스트림(client_cancelled, stream_errored, refused)의 출력은 마스킹 전에 마지막 공백까지 최대 4 KiB 되돌려 불완전한 토큰이 패턴을 피하지 못하게 하고, 잘린 필드는 truncated에 적습니다. 1 MiB 마스킹 상한으로 잘린 필드도 마찬가지입니다. model도 다른 클라이언트 텍스트처럼 상한과 마스킹을 적용하고, 기본 패턴은 한글이나 CJK 문자 바로 뒤에 붙은 자격 증명도 찾으며, 허용(permissive) 모드의 Hub 키도 key_id에 기록합니다. Disaggregated 요청은 buffered와 스트리밍 모두 기록하고, 오케스트레이터 거부는 backend_error로 남깁니다. retention.max_age_days는 트래픽이 없는 라우터에서도 지켜집니다. 쓰기 스레드가 한 시간마다 적용하고, 현재 파일은 상한의 8분의 1에서 전환하며, 어떤 레코드도 상한을 넘지 않도록 파일을 일찍 삭제합니다. 그래서 레코드는 상한의 약 8분의 7에서 상한 사이에 삭제됩니다.
  • 모델 하나와 선택적인 API 키 하나에 대한 라우팅 결정을 읽기 전용으로 설명하는 POST /admin/routing/explain을 추가했습니다 (#2467). 후보 백엔드마다 채팅 요청이 그 백엔드로 라우팅되는지, 아니면 라이브 경로의 어떤 검사가 처음으로 제외하는지를 보고합니다. 제외 사유는 비활성 키, 키의 allowed_models 또는 allowed_backends, 비활성화되었거나 internal: true인 백엔드, 열린 서킷, 사용할 수 없는 허브 자격 증명, 비정상 백엔드, 소진된 백엔드별 레이트 리밋(retry_after_ms 포함)입니다. 레이트 리밋 토큰을 소비하지 않고, 서킷을 옮기지 않으며, 헬스나 사용량 카운터를 바꾸지 않는 드라이런이고, 선택이 공유 카운터를 전진시키는 선택 전략은 실행하지 않습니다. 알 수 없는 모델은 404 model_not_found, 알 수 없는 키는 404 api_key_not_found로 응답하며, 응답에는 백엔드 URL이나 키 값이 담기지 않습니다.
  • metrics.port의 전용 리스너에서 /metrics를 제공합니다 (#2388). 이 필드는 기본값 9090으로 파싱되었지만 아무 곳에서도 바인딩하지 않았으며, 함께 배포되는 Kubernetes 매니페스트와 Helm 차트는 9090 포트를 스크레이프하고 NetworkPolicy는 모니터링 네임스페이스를 그 포트에만 허용합니다. 이제 메트릭이 켜져 있으면 라우터는 metrics.bind_address(신규, IP 주소 또는 localhost, 기본값은 첫 번째 TCP server.bind_address 항목의 호스트)와 metrics.port에 두 번째 TCP 리스너를 바인딩하며, 이 리스너는 같은 metrics.auth 뒤에서 메트릭 경로만 제공합니다. API, 관리자, 상태 확인 경로는 이 포트에서 404를 반환합니다. 리스너는 서버와 함께 시작하고 종료 시 드레인됩니다. metrics.port나 metrics.bind_address를 설정한 경우 바인딩할 수 없는 포트는 metrics.port를 밝힌 오류와 함께 시작을 실패시키므로, 9090이 이미 사용 중인 호스트에서는 metrics.port를 옮기거나 리스너를 끄는 metrics.port: 0을 설정해야 합니다. 둘 다 설정하지 않은 경우에는 경고를 남기고 리스너 없이 시작합니다 (#2845). 업그레이드 참고: 이제 라우터는 metrics: 섹션이 없어도 기본으로 9090 포트를 엽니다. 이 포트가 사용 중인 호스트에서는 경고를 남기고 메인 리스너에서 /metrics를 계속 제공하며, metrics.port: 0을 설정하면 리스너를 끕니다. 이전 릴리스의 config show나 Admin API 내보내기로 저장한 파일에는 port: 9090이 들어 있으며, 이는 설정한 것으로 간주합니다. metrics.port가 메인 리스너 포트와 같으면 두 번째 리스너를 시작하지 않고, metrics.bind_address 없이 Unix 소켓에만 바인딩하면 경고와 함께 건너뛰며(config validate도 경고), AppProxy ROUTER의 traffic_port가 metrics.port와 같으면 거부합니다. /metrics는 메인 리스너에도 계속 남습니다. 핫 리로드에서 metrics.enabled, metrics.port, metrics.bind_address를 바꾸면 재시작이 필요하다고 로그에 남깁니다. metrics.auth를 생략해 인증 없는 ServiceMonitor에 401을 반환하던 Helm values 변형 세 개는 이제 기본 values 파일처럼 method: none을 설정합니다.
  • POST /v1/models/query에 q와 limit 필터를 추가하고, capability를 capabilities의 다른 표기로 받도록 했습니다 (#2758). q는 모델 ID와 표시 이름에 대한 대소문자 무시 부분 문자열 매칭이며, limit(1~1000)은 필터링된 결과의 개수를 제한합니다. 이제 backends[].models의 글롭 항목은 Hub 별칭의 로컬 충돌 검사와 카탈로그 항목이 없는 백엔드의 별칭 디스패치에도 반영되며, 글롭 매칭은 더 이상 힙을 할당하지 않습니다.
  • 백엔드의 response_format 지원 여부를 운영자가 선언하는 backends[].supports_response_format을 추가했습니다(#2759). 값이 없으면 기존처럼 백엔드 유형에서 추론합니다. false는 이 필드가 포함된 요청을 해당 백엔드에서 우회시키고, 남은 후보가 없으면 400으로 거부하며, GET /v1/models의 supported_parameters에서 필드를 제외합니다. true는 변환 과정에서 필드가 삭제되는 anthropic과 bedrock에서는 효과가 없으며 config validate가 경고합니다. continuum_response_format_inferred_dispatch_total{backend}는 추론으로 유지된 후보를 집계합니다.
  • 라우터 측 키별 프롬프트 로그 동의를 추가했습니다 (#2324). KeyEntry에 선택 필드 prompt_logging_consent(allowed 또는 denied)가 추가되었고, 라우터는 prompt_logging_consent_v1을 광고하고 이 값을 영속 키 테이블에 저장하며 두 인증 모드 모두에서 요청에 전달합니다. 새 게이트가 캡처 여부를 정합니다. prompt_logging.enabled: false이면 캡처하지 않고, denied는 캡처하지 않으며, allowed는 default_consent: denied에서도 캡처하고, 값이 없는 키와 키 없는 요청은 default_consent를 따릅니다. Hub 정책 저장소에 정책이 없는 동안(첫 동기화 전, 또는 복원한 키 테이블이 만료된 뒤)에는 키로 인증한 요청을 default_consent와 상관없이 캡처하지 않습니다. continuum-hub#1975 전에는 이 필드를 보내는 허브가 없습니다. 프롬프트 로그 레코드에는 선택 필드 consent(key_allowed 또는 deployment_default)가 추가되었고, PROMPT_LOG_SCHEMA_VERSION은 2가 되었습니다.
  • prompt_logging 설정 섹션을 추가했습니다 (#2301). 옵트인 캡처 스위치, default_consent, 캡처 범위, 마스킹 패턴, 유한한 보존 한도, 내보내기 자리표를 정의합니다. 기본값은 꺼짐이고 모든 feature 조합에서 검증되며, export.hub_push와 export.sinks는 경고와 함께 로드될 뿐 아무것도 전송하지 않습니다. 싱크 endpoint와 redaction.patterns는 Admin 구성, config show --resolved, config diff, Debug 출력에서 마스킹하고, 패턴 오류에는 패턴 원문을 넣지 않습니다. redaction.masked_replacement의 $는 거부하며, 싱크 endpoint의 해석되지 않은 ${VAR}는 다른 필드와 같이 config validate 경고로 보고합니다.
  • request_presets 설정 섹션과 POST /v1/chat/completions의 preset 요청 필드를 추가했습니다 (#2436). 프리셋은 선택적인 모델 기본값과 7개 정규 요청 파라미터를 묶은 단위이며, 요청이 슬러그로 선택합니다. 요청에 명시한 필드가 우선하고, 프리셋은 request_params.defaults보다 우선하며, request_params.overrides/limits는 프리셋 값에도 그대로 적용됩니다. 프리셋 모델은 요청에 사용할 수 있는 모델이 없을 때만 사용합니다. 프리셋은 로드와 핫 리로드에서 검증합니다. 슬러그는 [A-Za-z0-9._-] 1~64자이고, 프리셋은 최대 256개이며, 비어 있지 않은 model(256바이트 이하, 제어 문자 없음) 또는 params가 필요하고, 파라미터 범위는 request_params와 같으며, 알 수 없는 키는 거부합니다. 알 수 없는 슬러그는 400 unknown_request_preset(형식이 유효한 슬러그는 이름 포함), 문자열이 아닌 값은 400 invalid_type으로 응답하고 null은 무시하며, 이 필드는 백엔드로 전달되지 않습니다. 이 섹션은 request_params와 별개이므로 Hub 설정 동기화가 덮어쓰지 않습니다.
  • backends[].models에서 글롭 패턴을 받도록 했습니다(#2357). *가 들어간 항목(접두, 접미, 중간 일치, 예: gpt-*, openai/*)은 일치하는 모든 모델 ID를 그 백엔드로 라우팅하므로, 공급자가 새 모델을 추가해도 설정을 고치지 않고 라우팅할 수 있습니다. /v1/models는 패턴 문자열을 나열하지 않고 백엔드 자체 모델 목록이 알리는 ID 중 패턴과 일치하는 구체적인 ID를 나열하며, 실시간 목록이 없는 백엔드(Bedrock)는 패턴에 대해 아무것도 나열하지 않습니다. 어떤 백엔드의 정확한 항목(또는 그 날짜 스냅샷)이든 패턴보다 우선하고, 두 백엔드의 패턴이 겹치면 로드 경고와 config validate 경고가 나며, **를 포함한 항목과 빈 항목은 로드 오류입니다. 키별 allowed_models는 여전히 요청된 ID와 비교되므로 패턴이 키의 범위를 넓히지 않으며, Hub 프로필은 모델 ID를 담기 때문에 Continuum Hub 백엔드 동기화는 패턴을 쓰는 백엔드를 미리보기 전용으로 내보냅니다.
  • LeastBusy 선택 전략을 추가합니다 (#2334). 풀의 백엔드별 처리 중(in-flight) 요청 카운터를 읽어, 라우터가 자체적으로 처리 중인 요청 수가 가장 적은 적격 백엔드를 선택합니다. 카운터가 모든 디스패치 경로에서 갱신되므로 어떤 백엔드 타입에도 동작하고 엔진 통계를 요구하지 않으며, 통계를 보고하지 않는 백엔드에 EngineLoad가 routing.engine_load.base_strategy로 폴백하는 것과 달리 동일하게 순위가 매겨집니다. 한 번도 라우팅된 적이 없는 후보는 유휴 상태로 취급되고, 동률인 경우 풀 카운터로 순회하여 부하가 같은 후보들이 트래픽을 고르게 나눕니다. selection_strategy, --selection-strategy, CONTINUUM_SELECTION_STRATEGY에서 least-busy, least_busy, leastbusy 표기를 허용합니다.
  • 옵트인 헤지 요청을 추가했습니다 (#2317). proxy.hedge_delay_ms(0~60000, 기본값 미설정)를 설정하면, 지연 시간 안에 응답하지 않은 버퍼링된 /v1/chat/completions 또는 /v1/completions 디스패치를 같은 모델을 서빙하는 두 번째 백엔드에도 보냅니다. 먼저 온 응답을 채택하고, 다른 시도는 취소해 outcome: hedge_cancelled인 사용량 행으로 기록합니다. 클라이언트 스트림, 폴백 체인 홉, 임베딩, rerank, 이미지 생성, replay: disabled 백엔드는 경합하지 않습니다.
  • /v1/chat/completions에서 요청별 모델 폴백 목록을 지원합니다 (#2403). model 옆의 models 배열(OpenRouter의 폴백 형식)은 ingress에서 추출되고 키가 소비되므로 어떤 백엔드도 보지 못합니다. primary에서 발생한 오류는 설정된 체인이 쓰는 것과 같은 매커니즘(시도별 재시도, X-Fallback-* 헤더, hop별 타임아웃, fallback dial bound, mid-stream relay)으로 목록의 다음 모델로 장애 조치되고, 응답의 model은 실제 응답한 모델을 가리키며 사용량도 그 모델에 기록됩니다. model이 없으면 첫 항목이 primary가 됩니다. 각 항목은 설정된 체인 이름 규칙을 따르고, 항목은 클라이언트가 보낸 순서대로 중복 제거되며 primary는 다시 시도되지 않고, 파싱되지 않는 목록은 문제가 된 항목을 가리키는 400이 됩니다. 목록은 연산자가 fallback 기능을 켠 경우에만 적용됩니다. fallback 섹션이 없거나 enabled: false이면 소비만 되고 무시되며, dispatch된 모델이 클라이언트가 primary로 지정한 모델이 아니어도 무시됩니다. 그 경우 모델 선택은 Hub 결정이나 스마트 라우팅 재작성이 소유하기 때문입니다. 요청에 대해 dispatch된 모델의 체인만 오버레이하며, 다른 설정된 체인과 fallback 정책, mid_stream_enabled는 설정대로 유지되고 모델의 notify_on_fallback 선택은 유지됩니다. 모든 항목은 키의 모델 허용 목록에 있어야 하며(허용 목록 밖의 항목은 건너뛰지 않고 디스패치 전에 거부), disable_fallbacks: true가 우선하므로 이때 목록은 소비만 되고 요청은 primary에 머뭅니다.
  • OpenAI 스타일 배치 생성이 라우터의 로컬 /v1/files 저장소에 있는 input_file_id를 받을 수 있습니다 (#2733). 고객 콘텐츠를 보내기 전에 소유권, purpose=batch, 200 MiB 스캔 제한, 모델 제한을 확인하고, 승인된 백엔드에 파일을 한 번 스트리밍한 뒤 제공자 파일 ID로 치환합니다. 제공자 소유 ID는 변경 없이 전달하며, 네이티브 Anthropic 인라인 배치는 로컬 파일 ID를 거부하고 제공자 복사본은 제공자의 보존 수명 주기를 따릅니다.
  • 실행 중인 라우터의 토큰 사용량 보고서를 출력하는 continuum-router usage를 추가했습니다 (#2525). GET /admin/stats* 엔드포인트를 읽어 모델(기본값), 백엔드, API 키, 사용자 중 하나를 기준으로 한 행씩(--by models|backends|api-keys|users) 출력합니다. 행은 토큰 수 순으로 정렬하고 점유율 막대와 실패 수, 초당 토큰 수, 평균 지연 시간을 함께 보여줍니다. 백엔드 행에는 토큰 카운터가 없어서 요청 수로 정렬하고 막대를 그립니다. --series는 전역 일별 토큰 시계열(GET /admin/stats/series)을 프롬프트 토큰과 완성 토큰으로 나눈 차트로 덧붙입니다. API 키별·사용자별 카운터는 전체 기간 값이므로 --window는 모델과 백엔드 기준에만 적용됩니다. 인증, --json, --timeout, 제어 문자 처리는 continuum-router status와 같습니다.
  • 요청 로깅 미들웨어용 logging.access 섹션을 추가했습니다 (#2402). log_headers는 TRACE 수준의 헤더 줄을 켜거나 끄며(자격 증명 헤더는 계속 마스킹됨), exclude_paths는 기본 제외 경로 /health, /health/ready, /healthz를 대체합니다. 각 항목은 /로 시작해야 하고 그 아래 경로도 포함합니다. 섹션이 없으면 기존 동작을 유지합니다. 본문 기록은 여기서 설정할 수 없으며, 섹션 안의 알 수 없는 키는 로드 시 거부합니다.
  • 로테이션된 프로세스 로그에 기간 기준 보존 설정 logging.file.rotation.max_age_days를 추가했습니다 (#2369). 0이 아니면 파일 싱크가 max_files 개수 상한과 함께, 수정 시각이 그 일수보다 오래된 패턴 일치 로그 파일을 시작 시와 매 로테이션 후 삭제합니다. 현재 열려 있는 파일과 설정된 패턴에 맞지 않는 파일은 삭제하지 않고 미래의 수정 시각은 오래된 것으로 보지 않습니다. 기본값 0은 개수 기준 보존만 적용합니다. 값은 로드 시 검증합니다(0 ~ 36,500).
  • 레거시 이진 backend_healthy 옆에 backend_health_status{backend,url}를 숫자 enum 게이지(0=알 수 없음, 1=정상, 2=워밍업, 3=비정상)로 내보냅니다 (#2315). 헬스 상태 머신은 WarmingUp을 1초 가속 체크와 최대 워밍업 마감 시간을 갖춘 별개 상태로 이미 추적하고 있었지만, 메트릭은 이를 0으로 뭉개 죽은 백엔드와 동일하게 만들어 오토스케일링과 대시보드가 가중치를 적재 중인 백엔드와 개입이 필요한 백엔드를 구분할 수 없었습니다. 레거시 게이지는 같은 상태에서 파생되므로, 기존 스크레이프 설정은 그대로 동작합니다.
  • GET /v1/models와 POST /v1/models/refresh에 기능 메타데이터를 게시합니다(#2417). 각 항목은 값을 아는 경우 확정된 모델 메타데이터의 context_length, max_output_tokens, pricing, capabilities를 담고 모르는 필드는 null 대신 생략합니다. 운영자가 limits를 지정하지 않으면 context_length는 모델을 제공하는 엔진들이 보고한 창의 최솟값입니다. 채팅 모델에는 supported_parameters도 담기며, 이는 모델을 제공하는 모든 백엔드의 변환이 전달하는 샘플링 및 logprob 파라미터(temperature, top_p, max_tokens, presence_penalty, frequency_penalty, top_k, min_p, logprobs, top_logprobs)입니다. 모델마다 관리하지 않고 채팅 ingress가 적용하는 백엔드별 표에서 도출합니다. 이 필드로 필터링하는 클라이언트는 더 이상 /v1/models/extended를 읽지 않아도 됩니다.
  • 라우팅 디버깅을 위한 프로바이더별 실패 상세를 내보냅니다 (#2435). 오류 HTTP 상태로 답한 모든 백엔드 홉이 이제 상태 코드와 마스킹된 오류 요약을 롤링 프로바이더 트래커에 기록하고, 새 GET /admin/provider-failures 엔드포인트에서 노출되며 provider_failures_total{backend, status}로 집계됩니다. 백엔드당 최대 20개, 256개 백엔드까지 유지하고 풀에서 제거된 백엔드는 목록에서 빠집니다. 요약은 클라이언트 오류가 이미 담고 있는 마스킹된 복사본(#2253, 200자에서 자름)이므로, 자격 증명이나 내부 엔드포인트가 저장되지 않습니다. 메트릭의 status 레이블은 400에서 599까지는 정확한 코드이고 그 밖에서는 other입니다.
  • OpenAI 형식의 POST /v1/moderations 엔드포인트를 추가해 client.moderations.create()를 라우터에 사용할 수 있습니다(#2336). 각 입력(문자열, 또는 최대 32개의 문자열이나 텍스트 파트 배열)은 flagged, 카테고리별 불리언, category_scores를 담은 results[] 항목을 하나씩 받습니다. 분류는 설정된 가드레일이 맡으며 deny와 allow 매치 목록을 먼저 확인하고 이어서 선택 사항인 model이 고르는 경로의 입력 단계 프로바이더를 모두 실행합니다. 임계값은 실제 트래픽과 같지만 전역 guardrails.mode와는 무관합니다. 카테고리는 라우터의 가드레일 카테고리이며, 프로바이더 이름과 사유는 반환하지 않고 프로바이더의 실패 정책 판정으로는 텍스트를 표시하지 않습니다. 이 경로에는 write 스코프가 필요합니다. 가드레일이 비활성화되어 있으면 400 moderation_unavailable, 모든 프로바이더가 실패하면 503 moderation_unavailable, input이 비었거나 너무 많거나 텍스트가 아니면 400(invalid_input 또는 too_many_inputs)으로 응답합니다.
  • /v1/chat/completions와 /v1/completions에 요청 단위 disable_fallbacks 본문 파라미터를 추가했습니다 (#2347). true이면 버퍼링 요청과 스트리밍 요청 모두에서 첫 바이트 이전과 스트림 도중의 다른 모델 폴백 홉을 해당 요청에 한해 건너뛰고 기본 모델의 오류를 반환합니다. 같은 모델을 제공하는 백엔드 사이의 재시도와 장애 조치는 계속 적용되며, 다른 요청은 체인을 그대로 사용합니다. 이 필드는 값과 관계없이 디스패치 전에 제거되므로, 백엔드는 받지 않습니다.
  • 테스트와 디버깅을 위해 /v1/chat/completions에 mock_response 요청 파라미터를 추가했습니다 (#2346). 이 필드가 있는 비스트리밍 요청은 백엔드에 연결하거나 사용량 기록을 남기지 않고 그 값으로 응답합니다. 문자열은 finish_reason: "stop"인 chat.completion choice 하나의 assistant 내용이 되고, JSON 객체는 그대로 반환됩니다. 인증, 요청 검증, 키의 allowed_models는 그대로 적용되며, 다른 타입은 400 invalid_type으로 응답합니다. 스트리밍 요청은 평소처럼 디스패치하며, 이 필드는 백엔드로 전달되지 않습니다.
  • Continuum Hub가 선언한 모델별 백엔드 별칭을 적용합니다 (#2723). Hub 백엔드 프로필의 model_aliases(허용 모델 하나당 클라이언트용 이름 최대 50개)는 Hub 소유 백엔드의 model_configs 별칭으로 생성됩니다. 따라서 별칭 요청은 기존 별칭 규칙에 따라 대상 모델로 디스패치됩니다(internal 프로필의 별칭은 검증만 하고 적용하지 않습니다). 대상 모델을 제공하는 모든 사용자 라우팅 가능 백엔드 중에서 선택이 이뤄지고, 업스트림은 대상 모델 ID를 받으며, /v1/models에 별칭이 표시됩니다. 라우터는 백엔드 동기화가 동작하는 동안(backends.mode가 disabled가 아닌 경우) heartbeat 기능 목록에 backend_model_aliases_v1을 광고합니다. 별칭이 프로필의 allowed_models에 없는 모델을 가리키거나, 모델당 50개를 넘거나, 형식이 잘못되었거나, 다른 Hub 프로필의 별칭이나 모델 또는 보존되는 로컬 백엔드의 모델이나 별칭과 겹치면 스냅샷은 model_policy_invalid로 거부됩니다. 복원된 스냅샷의 별칭이 그 사이의 로컬 변경과 겹쳐도 경고를 남기고 활성화합니다. 실시간 오디오 기능 조회는 메타데이터 없는 백엔드 model_configs 항목에서 멈추지 않으므로, 별칭 전용 항목이 model-metadata.yaml의 기능 정보를 가리지 않습니다.
  • Continuum Hub가 발급한 키의 거부 건수를 heartbeat 공급 블록에 키별로 보고합니다 (#2515). 라우터가 Hub 키 테이블로 확인한 키의 거부는 키와 단계별로 rejections_by_key에 프로세스 시작 이후 누적 집계됩니다. 처음 거부된 32개 키는 재시작할 때까지 자리를 유지하고, 그 밖의 Hub 키 거부는 rejections_by_key_other에 합산됩니다. 이 값은 0이어도 항상 보냅니다. 로컬 키, AppProxy 키, 키를 알 수 없는 거부는 지금처럼 rejected_requests로만 Hub에 전달되며, 이 카운터는 바뀌지 않습니다. 벤더링한 continuum-protocol 크레이트를 lablup/continuum-hub 37d98a1aca96cbc5b708aece86f3bee1f74f7e3f 기준으로 다시 가져왔습니다. 이 리비전에는 백엔드 프로필 모델 별칭도 포함되며, 별칭 적용은 #2723에서 추가되었습니다.
  • 옵트인 usage.ledger_export.max_files 보존 설정을 추가했습니다 (#2428). 설정하면 각 로테이션 후 한도를 초과하는 가장 오래된 로테이션 파일(<path>.<sent_at_ms>과 그 -<n> 변형)을 삭제합니다. 라이브 파일은 집계하지 않고, 실패는 최대 분당 한 번 기록되며, 푸시는 영향을 받지 않습니다. 기본값인 미설정은 모든 로테이션 파일을 유지하므로 전체 이력이 필요한 변조 방지 소비자는 바뀌지 않습니다.
  • Redis 기반 속도 제한에 fail-closed 옵션과 장애 신호를 추가했습니다 (#2424, #2408). rate_limiting.redis.fallback_to_memory의 기본값은 true로, Redis에 연결할 수 없으면 모든 검사가 레플리카 자체 버킷으로 내려가는 기존 fail-open 동작입니다(레플리카 N개 플릿은 설정한 rate의 최대 N배를 허용). false로 설정하면 거부합니다: Redis가 응답할 때까지 진입 검사가 503(type: service_unavailable, Retry-After: 5)으로 응답하고, per_backend 차감은 소진된 백엔드처럼 디스패치를 거부합니다. 두 경우 모두 rate_limit_redis_unavailable_total{dimension, action}(action은 fallback 또는 refused)으로 집계되며, fail-closed 거부는 할당량 위반으로 집계하지 않습니다. 각 Redis 검사는 이제 500 ms로 제한되므로, 네트워크가 분리된 Redis가 OS 연결 타임아웃 동안 요청을 붙잡아 두지 않습니다. 이 필드를 바꾸려면 재시작해야 합니다.
  • 키별 토큰 예산을 추가했습니다 (#2323). max_tokens_budget(입력과 출력을 합한 토큰 수)을 설정한 키는 롤링 budget_window(1d, 7d, 30d 중 하나, 기본값 30d) 안의 사용량이 예산에 도달하면 429 rate_limit_error로 거부되며, /anthropic 마운트에서는 Anthropic 오류 형식으로 응답합니다. 승인 시점에 처리 중인 각 요청의 프롬프트 추정치를 응답 본문이 끝날 때까지 예약하므로, 거의 소진된 예산을 동시 요청들이 한꺼번에 통과할 수 없습니다. 예산이 있는 키만 키당 고정된 메모리로 추적하고, 창을 받을 수 없는 키는 추적 없이 승인하지 않고 거부합니다. api_keys.persistence_file을 설정하면 창을 30초마다 <persistence_file>.budget.json에 기록해 재시작 후에도 유지하며, 설정하지 않으면 메모리에만 둡니다. 창은 복제본별입니다. max_tokens_budget: 0과 그 밖의 창 표기는 로드 오류이며, 예산이 없는 키의 동작은 바뀌지 않습니다.
  • Anthropic Messages API를 서버 루트(/v1/messages, /v1/messages/count_tokens)에서 /anthropic ingress 엔드포인트의 루트 수준 별칭으로 제공합니다 (#2276). 베이스 URL을 서버 루트로 지정한 클라이언트(/anthropic 접두사 없이)는 설정 변경 없이 같은 핸들러에 도달하며, GET /v1/models는 OpenAI 호환 모델 목록으로 남습니다. 루트 경로는 /anthropic 경로와 동일한 API 키 보호와 read/write scope 분류를 적용받고, 경로에 따라 정해지는 동작도 같습니다. 라우팅 전 거부 응답(rate limit, Hub 정책, 동시성 상한, hop guard)은 Anthropic 오류 형식을 쓰고, 응답에는 Anthropic request-id 헤더가 붙으며, POST /v1/messages는 per_model rate limit과 Hub 비용 센터 예산에 반영됩니다. count_tokens는 두 가지 모두에서 계속 제외됩니다.
  • 백엔드 model_configs[].aliases를 모델 목록에 게시합니다 (#2248). 활성화된 백엔드가 선언한 별칭은 별칭 디스패치가 그 이름을 해석하는 동안 GET /v1/models, /v1/models/extended, /v1/models/{model}, /anthropic/v1/models에 정규 모델의 백엔드와 메타데이터를 가진 별도 항목으로 나오며, 정규 모델이 빠지는 새로고침에서 함께 빠집니다. 키별 allowed_models 목록에는 별칭 자체가 있어야 하고, 백엔드 허용 목록, 내부 백엔드, 호출 가능 모델 기준은 정규 모델의 백엔드를 기준으로 적용됩니다. 그대로 제공되는 이름은 자기 항목을 유지하고, 모델 실험의 클라이언트용 이름은 실험이 처리하며, model-metadata.yaml 별칭과 model_aliases 슬롯은 목록에 나오지 않습니다. GET /admin/models는 선언된 별칭을 정규 항목의 aliases에 표시합니다.
  • 버퍼드 /v1/chat/completions 응답의 usage 블록에 금전 비용을 보고합니다 (usage.cost와 usage.cost_details.upstream_inference_prompt_cost, upstream_inference_completions_cost, USD 단위). 클라이언트가 usage 레코드를 가격 메타데이터와 오프라인으로 조인하지 않고도 요청별 지출을 측정할 수 있습니다 (#2404). 비용은 라우팅된 모델의 pricing 메타데이터에서 계산됩니다(프롬프트가 임계값을 넘으면 long-context 티어, 캐시된 프롬프트 토큰에는 cached_input_discount 적용), x-continuum-cost-microusd 헤더와 같은 계산식을 씁니다. pricing 메타데이터가 없는 모델, usage 블록이 없는 응답, 백엔드가 직접 usage.cost를 보고한 응답은 그대로 유지됩니다. 찍히는 가격은 카탈로그의 것이며 해당 경로의 모든 클라이언트에게 보입니다.
  • 라우팅 결정 설명 기능을 완성했습니다 (#2467). POST /admin/routing/explain은 이제 적격 집합에 대해 설정된 전략을 미리 계산해 selected, deterministic, 후보별 rank, latency_ms, score를 보고하고, 스마트 라우팅에 대해 virtual_model과 excluded_models를 함께 보고합니다. 접두사 키와 비용 추정치를 위한 선택적 request 본문을 받으며, 라운드 로빈 카운터를 전진시키지 않고, 난수를 소비하지 않고, 해시 링 캐시를 쓰지 않고, 스코어러의 prepare()를 호출하지 않고 전략을 미리 계산하므로, 동일한 호출을 두 번 하면 본문이 바이트 단위로 같습니다. 적격성 판정은 이제 라이브 경로의 공유 경로를 그대로 사용하며, 응답에는 capability_mismatch, model_not_allowed_by_tier(적용된 Hub 정책 티어 기준), credential_unavailable, not_in_backend_pool, unhealthy, retry_after_ms가 붙은 rate_limited가 추가되었습니다. 새 server.explain_response_header(기본값 false, 재시작 필요)는 관리자 권한 없이 디버깅할 수 있도록 일반 API 응답에 x-continuum-route: <backend>를 추가합니다.
  • 호출자 본인의 한도와 남은 값을 반환하는 읽기 전용 GET /v1/quota 엔드포인트를 추가합니다 (#2872). 키별 요청 버킷과 분당 토큰 버킷, 사용자별·팀별 요청 버킷, 동시 처리 수, 장기 max_tokens_budget, 허브 티어 윈도를 각각의 scope(fleet 또는 replica)와 함께 보고합니다. 신원에 묶인 버킷을 차감하지 않고 항목도 만들지 않으므로, 반복 조회와 이미 한도에 도달한 키의 조회가 모두 정상 동작합니다. 공유 global과 per_client 상한은 /v1/models 비용인 0.1로 계속 차감되지만 응답에는 싣지 않아, 공유 수준을 노출하지 않는다는 규칙(#1170)을 유지합니다. 이 경로는 read scope를 요구하고 x-ratelimit-* 또는 x-continuum-ratelimit-* 헤더를 싣지 않으며, 미등록 호출자에게는 한도가 설정되지 않은 등록 키와 바이트 단위로 같은 빈 문서를 반환합니다. 따라서 permissive 모드에서 허용되는 키 유효성 구분이 per_api_key에서 max_tokens_budget까지 넓어집니다.
  • proxy.client_provider_preferences를 추가합니다 (#2396). 기본값은 꺼짐이며, 켜면 POST /v1/chat/completions가 클라이언트의 최상위 provider 객체를 OpenRouter 형식의 요청별 라우팅 기본 설정으로 해석합니다. only, ignore, max_price는 후보 집합을 좁히고, order는 순위를 정하며, sort(price, throughput, latency)는 실시간 텔레메트리로 순위를 정합니다. 모든 필터는 백엔드 가시성, 키의 allowed_models와 allowed_backends, Hub와 정책의 축소를 적용한 뒤에 적용되므로 클라이언트가 운영자가 허용한 범위를 넓힐 수 없습니다. 비어 있지 않은 후보 집합을 필터가 비우면 아무 데도 디스패치하지 않고 provider.<field>를 지목하는 400 invalid_request_error로 응답하며, 입력 집합이 비어 있으면 기존처럼 503입니다. 알 수 없는 필드와 라우터가 지킬 수 없는 OpenRouter 지시 두 개(require_parameters: true, data_collection: "deny")는 무시하지 않고 거부하며, 객체는 16 KiB, 목록당 이름 16개, 이름당 1~128바이트로 제한합니다. 스위치가 켜지면 라우터가 디스패치 전에 키를 제거하므로 애그리게이터 백엔드는 더 이상 이를 원문 그대로 받지 않습니다. 운영자는 backends[].request_extensions.body.overrides.provider로 provider 객체를 의도적으로 전달하며, 이 값이 제거된 클라이언트 키보다 우선합니다. x-backend는 여전히 첫 시도에서 이깁니다. 스위치가 꺼져 있으면 요청 경로는 그대로입니다.

Changed

  • 토큰 예산 경로를 보강했습니다 (#2899). 토큰 예산 429 응답에도 호출자 본인의 요청 수 x-ratelimit-* 헤더가 실립니다. 이 헤더는 토큰 검사 이전에 요청을 허용한 별도 버킷을 나타냅니다. 다시 불러온 tokens_per_minute 또는 tokens_per_day는 다음 요청부터 적용됩니다. 재로드는 예산을 다시 만들고 버킷 맵을 비웁니다. 토큰 버킷 맵이 가득 차면 fail-open으로 허용하고 rate_limit_skipped_total{dimension="token_budget"}(분 맵) 또는 rate_limit_skipped_total{dimension="token_budget_day"}(일 맵)을 기록하므로, 조용히 건너뛰지 않습니다. 두 기간이 모두 설정된 차원은 Redis 왕복 한 번으로 두 버킷을 예약하며, 둘 중 하나라도 거부하면 둘 다 차감하지 않습니다. RateLimitError는 #[non_exhaustive]이므로 향후 거부 세부 정보가 추가되어도 하위 매치가 깨지지 않습니다. 요청 수 per_api_key 버킷은 이제 자격 증명을 도메인 분리한 배포-안정 해시로 키를 만들므로, storage: redis에서 모든 복제본이 하나의 공유 버킷을 차감합니다. 기존 요청 수 per_api_key 버킷 키는 업그레이드 시 한 번 바뀌고 이전 Redis 키는 설정된 TTL로 만료됩니다. per_model 토큰 예산은 수락 시점 차원이므로 per_backend와 달리 실제로 적용되며 config validate가 경고하지 않습니다.
  • 토큰 예산 설계 기록에 네 가지 결정을 추가했습니다(#2898). 로컬 tokens_per_minute/tokens_per_day 차원은 입력·출력 분할 없이 기간당 하나의 윈도를 유지합니다. 수락 예약은 전체 요청 입력 추정치와 출력 상한에 추가 여유 없이 정확히 그 합입니다. 중복 제거로 진행 중인 리더에 합류한 요청은 전부 해제되고 예약은 서비스를 수행한 요청만 정산합니다. 응답 캐시 적중은 입력 토큰을 포함해 아무것도 과금하지 않습니다. 각 결정은 테스트로 고정되어 있으며 런타임 동작과 설정 표면은 바뀌지 않았습니다.

  • continuum-protocol을 lablup/continuum-hub d7903743f4942e33117bb23e4159b614d097a38e 기준으로 다시 벤더링했습니다 (#2817). 이제 크레이트는 LOCAL-AHEAD 항목 없이 허브와 바이트까지 동일합니다. continuum-hub#1994 이후의 허브는 키 테이블로 키별 프롬프트 로그 동의를 전달하며, 운영 가이드도 이에 맞게 고쳤습니다. 라우터는 새 PII 사용자 정의 엔티티와 PII 프로바이더 행 타입으로 컴파일되지만 guardrail_pii_provider_row_v1과 guardrail_provider_content_v2는 광고하지 않으므로, 허브는 해당 구문을 계속 이전 형식으로 변환해 보냅니다. 그래도 PII custom_entities가 담긴 구문은 이 라우터가 실행하지 못하므로 전체가 거부되고 guardrails_unavailable로 보고됩니다. make hub-compat에는 실제 허브로 전달을 검증하는 동의 단계 scripts/hub-compat-consent.sh가 추가되었습니다(HUB_COMPAT_SKIP_CONSENT=1로 건너뜁니다).

  • 거절 로그의 구간 요약을 타이머로도 기록합니다 (#2427). 60초 구간에서 억제된 거절 횟수를 알리는 WARN 요약은 구간이 지난 뒤의 다음 거절이 있어야만 기록되었기 때문에 구간 도중에 멈춘 버스트는 마지막 누적 횟수가 기록되지 않았습니다. 이제 첫 거절이 시작하고 30초마다 실행되는 백그라운드 플러시가 억제된 반복이 있는 모든 종료 구간의 요약을 기록합니다. 플러시는 지연 경로와 같은 로그 형식과 상한이 있는 테이블을 공유하며, 둘 중 먼저 실행된 쪽만 요약을 기록하므로, 중복되지 않습니다. 유휴 상태의 라우터는 요약을 기록하지 않습니다.
  • request_rejected_total이 처음 관측된 256개 대신 가장 최근에 거부된 256개의 키 ID를 추적하도록 변경했습니다 (#2514). 256개를 추적하는 중에 새 키 ID의 거부가 들어오면 가장 오래전에 거부된 키를 제외합니다. 제외된 키의 건수는 key_id="other"에 더해지고 그 키의 시리즈는 삭제되므로, key_id를 합산한 총계는 정확하고 시리즈 수도 상한을 넘지 않습니다. 제외된 키가 쌓은 전체 건수가 한 번에 other로 옮겨지므로 key_id="other"의 increase()와 rate()는 제외가 일어날 때 급증합니다. 경보는 key_id!="other" 시리즈에 거세요. 프로세스 실행 후반에 거부되기 시작한 키도 재시작할 때까지 other를 공유하지 않고 자기 시리즈를 갖습니다. 분당 request_rejected_summary 창은 여전히 키 ID를 먼저 관측된 순서대로 받아들입니다.

Deprecated

  • 파싱만 되고 적용된 적이 없는 metrics.latency_buckets를 더 이상 사용하지 않도록 지정했습니다 (#2514). http_request_duration_seconds는 항상 컴파일된 버킷 구성(0.01초부터 1200초까지)을 사용합니다. 이 필드가 기본값과 다르면 continuum-router config validate와 시작 과정에서 metrics.latency_buckets 경로로 경고합니다. 경고를 없애려면 필드를 삭제하세요.

Fixed

  • fallback.fallback_policy.trigger_conditions.context_window_exceeded의 관측 보고를 복원했습니다(#2956). Router v2.0.0은 관측 경로 레지스트리에 이 리프를 등록한 Hub(continuum-hub #2038)를 요구합니다. 등록하지 않은 이전 Hub는 알 수 없는 경로 때문에 하트비트 전체를 거부하기 때문입니다. 보고가 없으면 이 리프를 전달하는 Hub는 이를 desired로 다이제스트하지만 라우터의 관측에는 빠지므로, 해당 설정은 수렴하지 못하고 영구 드리프트로 남습니다. 라우터는 이 리프를 이전과 같이 파싱하고 적용하며, 값 없이 관측하는 보고만 복원했습니다. 영어·한국어 구성 문서를 함께 갱신했습니다.
  • reasoning_efforts 어휘를 선언하지 않은 경우에도 Anthropic 인그레스가 도출한 reasoning_effort를 대상에 맞춰 정규화하도록 수정했습니다(#2321). 이제 정규화 단계는 백엔드의 model_configs 선언, 공유 모델 메타데이터 레이어, 백엔드 타입의 문서화된 기본 어휘 순서로 조회하고, 어느 것도 없을 때만 도출된 값을 그대로 전달합니다. openai와 azure 대상은 내장 OpenAI reasoning 패밀리 테이블을 사용하고, 자체 호스팅 OpenAI 와이어 타입은 low, medium, high를 사용하며 xhigh는 high로 보냅니다. 선택된 소스와 정규화된 값은 디버그 레벨로 vocabulary_source=backend_model_config|model_metadata|backend_type_default|passthrough와 함께 backend, model, effort_field에 기록됩니다. 운영자 선언이 여전히 우선하며, 알려진 어휘가 없는 대상은 도출된 값을 그대로 받고, /v1/chat/completions에서 클라이언트가 보낸 reasoning_effort는 계속 다시 쓰지 않습니다. 영어·한국어 reasoning effort 아키텍처 문서와 API 문서를 함께 갱신했습니다.
  • responses_tools.deny_resource_fields의 네 가지 빈틈을 막았습니다(#2930). 옵트인 항목 input_item_resource_ids는 container_id, file_id, vector_store_ids, previous_response_id, conversation 중 하나를 모든 깊이에서 담은 입력 항목을 거부하고, 위치를 input[code_interpreter_call].container_id처럼 명시하며, POST /v1/responses/compact에도 적용합니다. 모델 자신의 출력을 다시 보내는 클라이언트가 그 ID를 재전송하므로 기본값에는 넣지 않았고, 라우터가 로컬 파일로 해석한 file_id는 어느 쪽이든 허용합니다. 이 목록은 전달되는 도구가 선언한 모든 중첩 tools 목록도 판단하며, programmatic_tool_calling과 allowed_tools 같은 원시 tool_choice가 여기에 포함됩니다. allow나 deny 항목은 이제 출력 가능한 ASCII여야 하므로, 너비 없는 공백처럼 보이지 않는 유사 문자는 절대 일치하지 않는 이름이 아니라 로드 오류가 됩니다. 패스스루 전략이 거부할 요청은 응답 캐시에서 조회하지도 저장하지도 않으므로, 항목을 추가한 재로드는 이전 정책으로 캐시된 응답에 대해 즉시 적용됩니다. 구성 어시스턴트와 영어·한국어 구성 및 API 문서를 함께 갱신했습니다.
  • 허브 프롬프트 로그 푸시가 백로그를 조용히 잃던 문제를 수정했습니다(#2880). 이제 404는 응답 본문이 수집 핸들러 자신의 {"error":"prompt-log ingest disabled"}일 때만 백로그를 버립니다. 프록시 오경로 같은 다른 404는 재시도하므로 레코드가 디스크에 남습니다. 수집이 꺼진 채널이 건너뛴 레코드는 dropped(상한)로 집계하고, 단일 레코드 격리 요청 사이에는 간격을 두며, 배치 전체가 거부되면 푸시가 한 주기 멈춥니다. 죽은 평문 경고를 제거했습니다. 해당 hub_url은 설정을 불러올 때 거부되기 때문입니다. 지속적인 리다이렉트는 재시도하며 프로세스당 한 번 상태 코드와 함께 경고합니다.
  • 네이티브 백엔드로 재전송되는 message의 알 수 없는 필드를 유지하도록 수정했습니다 (#2896). 이제 패스스루 백엔드는 스트리밍과 비스트리밍 요청 모두에서 메시지의 id, status 및 라우터가 모델링하지 않는 그 밖의 필드를 받으며, type 없이 역할과 내용만 있는 항목도 마찬가지입니다. 백엔드는 function_call과 마찬가지로 모르는 id를 재전송하면(예: store: false인 경우) 거부할 수 있습니다. 변환은 이 필드를 무시하므로 출력이 달라지지 않습니다. 이 필드는 입력 크기 제한에 포함되며 입력 가드레일이 읽습니다. 파서가 원문 그대로 유지하는 message에도 같은 읽기가 적용되며, 이전에는 그 미모델링 필드가 검사 없이 네이티브 백엔드에 전달되었습니다.
  • 프롬프트 로그 동의의 두 가지 결함을 수정했습니다(#2879). 내보내기는 이제 redaction이 마스킹하기 전, 캡처된 키 ID로 동의를 판단합니다. 따라서 redaction.patterns 항목이나 기본 sk- 패턴이 키 ID와 일치해도 해당 키의 레코드가 consent_key_absent로 바뀌지 않습니다. 기록되거나 전송되는 값은 마스킹된 ID뿐이며, 허브 푸시는 마스킹으로 ID가 바뀐 레코드를 거부해 요청 없이 rejected로 집계하고 ID 없이 warn을 한 번 남깁니다. 허브는 ID를 자체 테이블과 대조하기 때문입니다. 허브 정책 저장소가 없으면 인증되지 않은 제시 토큰은 더 이상 default_consent로 캡처되지 않습니다. 알 수 없는 토큰, 삭제·교체된 키의 이전 값, allowed를 지정했거나 값이 없는 비활성·만료 키는 모두 레코드를 쓰지 않으므로, 자리 표시자 토큰을 보내는 클라이언트는 default_consent를 따르려면 토큰을 보내지 않아야 합니다. 정책 저장소가 붙어 있으면 비활성 로컬 키의 denied만 그대로 적용되므로, allowed를 지정한 유효한 허브 키는 계속 캡처됩니다. 영문·국문 프롬프트 로깅 가이드, 설계 페이지, 아키텍처 페이지, 동의 설계 기록을 함께 갱신했습니다.
  • 네이티브 백엔드로 재전송되는 message의 알 수 없는 필드를 유지하도록 수정했습니다 (#2896). 이제 패스스루 백엔드는 스트리밍과 비스트리밍 요청 모두에서 메시지의 id, status 및 라우터가 모델링하지 않는 그 밖의 필드를 받으며, type 없이 role과 content를 가진 항목도 마찬가지입니다. 백엔드는 function_call과 마찬가지로 모르는 id를 재전송하면(예: store: false인 경우) 거부할 수 있습니다. 변환은 이 필드를 무시하므로 출력이 달라지지 않습니다. 이 필드는 입력 크기 제한에 포함되며 입력 가드레일이 읽습니다. 파서가 원문 그대로 유지하는 message에도 같은 읽기가 적용되며, 이전에는 그 미모델링 필드가 검사 없이 네이티브 백엔드에 전달되었습니다.
  • 내장 도구만 사용하는 Gemini 네이티브 /v1/chat/completions 요청이 업스트림에서 실패하거나 응답을 받지 못하던 문제를 수정했습니다 (#2890). 이제 네이티브 본문은 요청이 함수를 선언한 경우에만 toolConfig를 포함합니다. web_search_options나 code_execution만 있는 요청의 tool_choice는 검증하지만 강제하지 않으며, Google이 400, 빈 응답 또는 무응답으로 답하던 function calling 설정을 더 이상 보내지 않습니다.
  • 프롬프트 로그 내보내기의 시작 및 전달 결함을 수정했습니다(#2848). 실행 중인 writer 파일이 비정상적으로 먼 커서를 정상으로 오인하게 만들지 않으며, 파일 끝을 지난 커서는 완전한 줄 경계부터 다시 시도합니다. 캡처가 꺼져도 보존 정책은 계속 동작합니다. S3 자격 증명 엔드포인트는 제한 시간이 있는 DNS 검사와 주소 고정을 전달마다 적용하고, OperationAborted 같은 S3 서비스 요청 오류는 계속 재시도합니다. 전역 URL 검사기는 차단 대상 IPv4 주소를 포함한 6to4 및 Teredo 주소도 거부합니다.

  • 사설 CA에서 rediss://를 신뢰하는 방법을 문서화하고, Redis 서버 인증서 거부가 로그에 드러나도록 했습니다(#2865). 인증서 검증 때문에 연결이 실패하면 응답 캐시, 속도 제한, 허브 티어 분당 한도, KV 캐시 인덱스, AppProxy 구독자의 레코드 error 텍스트에 SSL_CERT_FILE을 안내하는 문구가 추가되며, 속도 제한은 이 경우를 debug 대신 경고로 남깁니다. 폴백 동작은 그대로이고 검증은 여전히 끌 수 없습니다. SSL_CERT_FILE은 시스템 신뢰 저장소를 대체하고 Linux에서는 HTTPS 백엔드와 허브 연결에도 적용되므로, 배포 가이드는 시스템 번들과 함께 SSL_CERT_DIR로 사설 CA를 추가하도록 안내하며, Helm 차트에 이를 마운트할 extraVolumes와 extraVolumeMounts를 추가했습니다. KV 리스너 컨테이너 스모크 워크플로는 이제 이미지에 비어 있지 않은 CA 번들이 있는지 확인합니다.

  • rediss:// Redis URL은 config validate를 통과한 뒤 시작 단계에서 실패했습니다. 클라이언트가 TLS 없이 빌드되었기 때문입니다 (#2747). 이제 모든 Redis 사용처(응답 캐시, Redis 속도 제한, KV 인덱스, Hub 분당 제한, AppProxy 이벤트)가 TLS로 연결하고 서버 인증서를 검증합니다. 문서의 rediss:// 형식을 따랐지만 분당 제한을 라우터별로 따로 세고 있던 배포는 이제 제한을 공유합니다.
  • 프롬프트 로깅 동의를 철회한 키의 밀린 레코드를 더 이상 내보내지 않습니다 (#2821). 싱크는 읽기와 전달 사이에 각 레코드의 키를 다시 확인합니다. denied 키와 default_consent: denied에서 아무것도 지정하지 않은 키는 prompt_log_export_total{result="consent_withdrawn"}로, 어느 테이블에도 없는 키는 consent_key_absent로 집계하고 건너뜁니다. 허브 테이블이 동의를 나타낼 수 없는 동안에는 해당 레코드에서 커서를 옮기지 않고 보류합니다. 켜진 AppProxy ROUTER의 appproxy- 키 레코드도 라우터가 첫 키 스냅샷을 받을 때까지 같은 방식으로 보류하며, ROUTER 테이블에서 사라진 키는 consent_key_absent로 집계하고 건너뜁니다. 로컬 키 소스 로드에 실패하면 재시작할 때까지 키가 있는 모든 레코드를 보류합니다. 이미 전달한 레코드는 회수할 수 없고, 로컬 파일은 다시 쓰지 않으며, capture 범위 축소는 강제하지 않습니다.
  • 네이티브 Gemini, /v1/responses, /anthropic/v1/messages, responses_only 채팅 브리지, 이미지 생성 전반에서 스트리밍 릴레이 종료 이벤트와 청크 경계 프레임 분할 처리의 정합성 문제를 수정했습니다 (#2829). GeminiStreamTransformer는 이제 SseLineBuffer와 Utf8Buffer로 청크 사이에 나뉜 불완전한 SSE 줄과 UTF-8 시퀀스를 버퍼링하고, 각 줄에 MAX_SSE_FRAME_BYTES 상한을 적용하며, 스트림 종료 시 finish()로 개행 없이 끝난 마지막 줄을 플러시합니다. /v1/responses의 채팅·Anthropic·Gemini 소스 릴레이, Anthropic의 OpenAI 및 Responses 브리지, responses_only 채팅 브리지에서 frame_json_refused가 JSON 구조가 과도한 SSE 프레임을 거부한 뒤 유효한 종료 청크나 종료 이벤트 없이 [DONE] 또는 EOF에 도달하면, 이제 response.completed나 message_stop을 합성하는 대신 각 경로의 오류 종료 이벤트를 내보내고 거부된 프레임 이전에 관측된 사용량과 함께 upstream_error를 기록합니다. /anthropic/v1/messages에서는 message_stop이 이미 전송된 뒤에 도착한 크기 초과 후속 SSE 프레임이나 업스트림 읽기 오류를 경고와 함께 무시하여, message_stop 뒤에 event: error가 붙지 않고 사용량 결과가 completed로 유지됩니다. 이미지 생성 SSE 오류 종료 이벤트는 try_send 대신 최대 5초(IMAGE_TERMINAL_SEND_TIMEOUT) 동안 큐 여유 공간을 기다리므로, 큐가 가득 찬 상태에서도 슬롯 하나가 비워지면 종료 오류 이벤트가 전달되고 응답이 없는 클라이언트에서도 무기한 대기하지 않습니다.
  • 허브 키의 denied 프롬프트 로깅 동의가 capability 업그레이드 구간에서도 유지되도록 했습니다 (#2818). 허브는 저장된 capability에 prompt_logging_consent_v1이 없는 라우터의 키 테이블에서 prompt_logging_consent를 제거하므로, 이미 등록된 라우터가 이를 광고하는 빌드로 올라가면 첫 하트비트 전에 제거된 테이블을 적용하고 denied 키를 default_consent로 캡처했습니다. 이제 라우터는 허브가 하트비트를 받아들인 뒤에 요청한 키 테이블만 동의를 가진 테이블로 보고, 그 전까지는 정책 없는 구간과 같이 키로 인증된 요청을 캡처하지 않습니다. 첫 하트비트는 한 간격을 기다리지 않고 시작 직후 보내고, 첫 하트비트가 승인되면 즉시 정책을 다시 요청하며, 그 전에 열린 스트림 세션은 재연결하고, 영속 키 테이블의 consent_confirmed 플래그가 복원된 테이블의 상태를 보존합니다 (이전 빌드가 쓴 파일은 동의가 없는 테이블로 복원됩니다). 하트비트는 받아들이지만 동의를 보내지 않는 허브에서는 게이트가 열리고 키가 default_consent를 따르며 info 로그가 한 번 남고, 하트비트가 계속 실패하는 라우터는 warn 로그를 한 번 남기고 키로 인증된 요청의 캡처를 닫은 채로 둡니다. unmatched_key: fail_open에서 어느 테이블도 모르는 토큰을 제시한 요청은 더 이상 캡처하지 않습니다.
  • 시작 시 내보내기 커서를 읽지 못해도 라우터가 멈추지 않습니다(#2820). 200ms 간격으로 5번 재시도한 뒤, 이 프로세스는 캡처와 내보내기 없이 트래픽을 처리하고 ERROR를 한 번 남기며 prompt_log_capture_disabled{reason="cursor_unreadable"}을 1로 설정합니다. 파일을 고친 뒤 재시작하십시오. 읽거나 만들 수 없는 디렉터리는 여전히 시작을 중단시킵니다.
  • 거부된 SSE 이벤트 이름 경고와 60초 억제 구간을 프로세스 전체 버킷 대신 서빙 백엔드별(backend = %backend, 릴레이 백엔드 컨텍스트가 없으면 unknown)로 분리해 기록하고, 이미지 생성 SSE 스트림에서 거부된 이벤트 이름을 backend 필드와 함께 길이를 제한하고 이스케이프해 warn으로 기록하며, Anthropic 스트리밍 폴백 경고에서 도달할 수 없는 원시 - data: {} 페이로드 출력을 제거했습니다. 또한 ACP 프롬프트 핸들러 로그에서 백엔드 URL을 제거(without_url())하고, 업스트림 오류 본문 읽기 크기를 제한(read_bounded_upstream_error_body)하며, 로그에 남기는 오류 본문을 제한(cap_error_body_for_log)했습니다 (#2828).
  • ACP session/prompt의 실패 계량 결과를 바로잡고 정체된 session/update 전송에 시간 제한을 두었습니다 (#2827). finish_reason과 [DONE] 없이 EOF에 도달한 백엔드 SSE 스트림은 이제 completed로 기록하는 대신 -32603(Backend stream ended before the reply finished)으로 실패하고, 세션 대화 기록을 유지하며, 그때까지 관측된 사용량과 함께 upstream_error를 기록합니다. 스트리밍 또는 버퍼링 응답 본문을 읽는 도중 요청 total 타임아웃이 만료되면 이제 upstream_error 대신 {"timeout":"total",...} 데이터를 담은 Backend stream timed out을 반환하고, 본문 청크를 받은 뒤에는 timeout_mid_stream, 그 전에는 timeout_first_byte를 기록합니다. 백엔드 선택 실패(-32602 미제공 모델 및 -32603 가용 백엔드 없음)는 시도를 집계하지 않고 not_dispatched 아래에 실패 행 하나를 기록하며, 디스패치 전 취소와 길이 한도를 넘은 모델 이름은 기존처럼 아무것도 기록하지 않습니다. 또한 각 session/update 전송이 session/cancel 및 timeouts.request.streaming.chunk_interval과 경합하므로, 클라이언트가 전송 채널을 비우지 않아도 프롬프트가 무기한 붙잡히지 않습니다.
  • control_plane.enabled가 true인 상태에서 컨트롤 플레인 사용량 또는 배치 상태 수신기가 아직 가져가지 않은 채 남아 있을 때 ContinuumRouter::into_router()에서 경고하도록 수정했습니다. 또한 BatchManager와 배치 상태 채널을 사용량 채널과 동일한 control_plane_agent_runs 조건으로 제한하고, ContinuumRouterBuilder::without_control_plane_agent()가 설정된 경우 serve()에서 경고와 함께 에이전트 시작을 건너뛰도록 했습니다 (#2826).
  • Anthropic Messages 인그레스, StatsCollector, acp.default_model 검증에서 모델 이름 길이를 256바이트로 제한했습니다 (#2825). POST /anthropic/v1/messages, POST /anthropic/v1/messages/count_tokens와 루트 별칭(POST /v1/messages, POST /v1/messages/count_tokens)은 256바이트를 넘는 model을 별칭 해석이나 백엔드 선택 전에 값 에코 없이 400 invalid_request_error(Model name exceeds maximum length of 256 characters)로 거부합니다. StatsCollector는 256바이트를 넘는 모델 이름을 요청 수는 유지한 채 per_model, per_api_key_model, per_user_model에서 unknown으로 합산하며, 스냅샷 복원 시 상한을 넘은 모델 키는 제외 건수를 알리는 경고 한 건과 함께 버립니다. 공백 제거 후 256바이트를 넘는 acp.default_model 값은 설정 검증과 continuum-router config validate에서 거부됩니다.
  • 스트림별 SSE 릴레이 큐, 프로세스 전체 스트리밍 버퍼 사용량, A2A 및 ACP 누적 응답 텍스트에 상한을 두었습니다 (#2824). 각 SseTx 릴레이 큐에는 256 MiB 상한(2 * MAX_SSE_FRAME_BYTES)이 적용되며, 활성 SseParser 버퍼, SseLineBuffer 버퍼, 릴레이 큐는 server.max_stream_buffer_bytes(기본값 4 GiB, 최소 1, 0은 파싱 시 거부되며 변경 시 재시작 필요)로 설정하는 프로세스 단위 바이트 예산을 공유합니다. 느린 리더가 스트림별 큐 상한에 도달하거나 프로세스 예산을 소진하면, SseTx::send는 경고를 한 번 기록하고 업스트림 읽기를 늦추지 않은 채 이후 클라이언트 프레임을 버리며(큐가 비어 있을 때는 항상 프레임 하나를 통과시킴), Ok(())를 반환하여 릴레이가 마지막 사용량 이벤트까지 업스트림을 계속 읽도록 합니다. 또한 DoneSentinel은 오버플로된 스트림에서 마지막 data: [DONE] 프레임을 생략하여 잘린 스트림이 정상 완료처럼 보이지 않게 합니다. SseParser 버퍼가 server.max_stream_buffer_bytes를 넘으면 SseLimitKind::ProcessBudget으로 스트림을 종료합니다. A2A 스트리밍 작업 저장소와 ACP 스트리밍 및 버퍼링 session/prompt 핸들러의 누적 응답 텍스트는 64 MiB(MAX_ACCUMULATED_REPLY_BYTES)로 제한되어, 상한을 넘은 A2A 작업은 Upstream reply too large로 실패 처리하고 상한을 넘은 ACP 프롬프트는 세션 이력을 변경하지 않은 채 JSON-RPC 오류 -32603(Backend reply exceeds the size limit)과 upstream_error 사용량으로 실패 처리합니다.
  • 스트리밍 가드레일 게이트에서 JSON 구조가 과도한 SSE 프레임을 안전하게 차단(fail-closed)하고 Anthropic 및 Gemini 스트림 변환기의 프레임 파싱에 동일한 상한을 적용했습니다 (#2823). 문자열 밖의 JSON 구조 바이트가 1 MiB를 넘는 SSE 프레임은 serde_json::Value로 파싱할 때 수백 메가바이트에서 수 기가바이트까지 힙을 확장할 수 있습니다 (#2795). 이제 스트리밍 출력 게이트, 스트림 사용량 추적, Anthropic 및 Gemini 스트림 변환기는 모든 프레임 JSON 파싱에 parse_frame_json을 사용합니다. StreamingOutputGate::observe에서 frame_json_refused가 감지되면 라우터는 프레임 원문을 남기지 않고 모델 이름과 category = "guardrail_frame_uninspectable"을 포함한 경고를 기록합니다. 활성 강제 게이트(streaming_mode가 buffer_full 또는 chunked인 mode: enforce)는 스트림을 차단 상태로 표시하고 StreamGateStep::Block을 반환하므로, 각 릴레이가 와이어 형식의 차단 종료 이벤트를 내보내고 보류 중인 텍스트나 지연된 /v1/responses 미러 이벤트를 폐기하며 읽기를 멈추고 거부된 프레임 이전에 보고된 마지막 사용량(또는 라우터의 델타 집계)으로 guardrail_blocked 사용량 레코드를 기록합니다. 비활성 게이트(mode: monitor, 또는 streaming_mode: passthrough인 mode: enforce)는 JSON Value를 생성하지 않고 프레임을 원문 그대로 전달하며, monitor 모드의 스트림 종료 검사는 해당 프레임의 텍스트를 제외한 채 실행됩니다. Anthropic 및 Gemini 스트림 변환기에서도 JSON 구조가 과도한 프레임은 serde_json::from_str을 호출하는 대신 건너뛰거나 파싱하지 않고 그대로 전달합니다.
  • 2328 보안 리뷰에 따라 프롬프트 로그 내보내기를 보강했습니다 (#2810). Admin API 백엔드 URL, Converse 이미지 가져오기, 클라이언트가 보낸 파일 URL, 내보내기 싱크가 쓰는 엄격 SSRF 검증기가 이제 공유 주소 공간 100.64.0.0/10(통신사급 NAT와 100.100.100.200 메타데이터 주소), 198.18.0.0/15, 0.0.0.0/8, 멀티캐스트, 예약, NAT64 내장 주소도 거부하므로, 이 범위에 있는 관리자가 만든 백엔드는 이전과 달리 검증에 실패합니다. 소스 설정의 백엔드는 이 검증기를 거치지 않으므로 영향을 받지 않습니다. 내보내기 싱크는 전달할 때마다 엔드포인트의 이름을 확인하고, 확인되지 않는 이름을 거부하며, 검사를 통과한 주소로만 연결하므로 리바인딩되는 이름이 전달을 다른 곳으로 돌릴 수 없습니다. S3 싱크는 엔드포인트를 쓸 때만 이를 적용하고, 시작할 때 AWS_ENDPOINT_URL_STS와 AWS_METADATA_ENDPOINT도 검사하며 둘을 커서 식별자에 포함합니다. 레코드 마스킹, 스팬 생성, 직렬화는 블로킹 풀에서 실행하고, 일부만 전달된 배치는 전달된 앞부분 다음으로 커서를 옮기며, 재시도 지연에 지터를 더했습니다. 커서 파일은 심볼릭 링크를 따라가지 않고 FIFO에서 멈추지 않으며 최대 64 KiB까지만 읽고, 레코드 파일 범위를 벗어나거나 파일 끝을 지난 커서는 손상으로 처리하거나 끝으로 맞춥니다. 정규 이름의 일반 파일만 내보내고, outcome과 truncated를 읽을 때 마스킹하며, Debug 출력에서 bucket과 prefix를 가립니다. 그룹이나 모든 사용자가 쓸 수 있는 캡처 디렉터리는 시작할 때 경고하고, ..이 들어가거나 IP 형태인 S3 버킷 이름은 거부하며, 환경의 AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY 쌍 중 하나만 있으면 시작할 때와 config validate에서 그 변수 이름을 알려 줍니다. S3 credential 클라이언트는 1초 연결 타임아웃을 유지하고, 한도를 넘는 호출이 수집을 멈추면 추적 중인 모든 스트리밍 도구 호출을 토큰 경계에서 자릅니다.

  • 백엔드 하나가 스트림 하나로 라우터 메모리를 소진하거나 CPU를 제곱 시간으로 소모하지 못하도록 SSE 파서에 상한을 두었습니다 (#2795). 128 MiB를 넘는 줄이나 표준 모드 이벤트, 또는 1 KiB를 넘는 event: 이름은 해당 릴레이의 일반 오류 종료와 upstream_error 사용량 기록으로 스트림을 끝내며, 파서는 선형 시간으로 스캔합니다. 이미지 릴레이의 10 MiB 제한은 같은 128 MiB로 바뀌고 큐에 스트림당 128 MiB의 바이트 예산이 생겼습니다. A2A 릴레이는 Upstream stream frame too large로 작업을 실패 처리하고, 버퍼링 디스패치는 한도를 넘으면 읽기를 멈춥니다. 문자열 밖의 JSON 구조 바이트가 약 100만 개를 넘는 프레임은 파싱하지 않습니다. 패스스루 릴레이는 이를 전달하고 변환 릴레이는 파싱할 수 없는 프레임으로 건너뛰며, 사용량은 누락 경로로 처리됩니다. base64 이미지 같은 긴 문자열은 사용량을 유지합니다. 이 한도는 프레임 하나에 적용되며 스트림 전체에는 적용되지 않습니다. 전역 바이트 예산은 없고, 끝없는 작은 프레임에 대한 릴레이 큐의 바이트 한도도 없으며, 가드레일 게이트는 여전히 모든 프레임을 전부 파싱합니다. 줄은 이제 SSE 명세대로 \r, \n, \r\n 중 처음 나온 것에서 끝나므로 단독 \r이 두 필드를 합치지 않으며, 빈 줄은 대기 중인 이벤트 이름을 지웁니다

  • 잘못된 업스트림 SSE 이벤트 이름 때문에 라우터 프로세스가 종료되던 문제를 수정했습니다 (#2791). 원시 event: 줄이나 페이로드의 type 필드에서 가져온 이름에 \r 또는 \n이 있으면 axum이 패닉을 일으켰고, 릴리스 프로필의 panic = "abort" 설정 때문에 진행 중인 모든 요청과 함께 프로세스가 종료되었습니다. 이제 해당 프레임은 event: unknown으로 전달됩니다. 라우터는 길이를 제한하고 이스케이프한 이름을 60초 구간마다 한 번 warn 수준으로 기록해 억제된 횟수를 함께 남기고, 반복되는 경우는 debug 수준으로 기록합니다. 라우터의 Responses 변환처럼 이벤트 이름으로 분기하는 소비자는 이 프레임을 버립니다. \r로 구분된 event: 줄은 파서에서 data: 줄을 잃을 수 있으며, 이는 별도의 결함입니다.

  • 모델 이름 확인 두 가지를 라우팅과 맞췄습니다 (#2792). 보존되는 로컬 models 항목의 날짜 접미사 형태(또는 그 반대)인 Hub 모델 별칭은 이제 로컬 충돌로 거절하며, 모든 이름과 일치하는 항목(* 또는 models/*)은 더 이상 별칭 디스패치에서 이름을 유지시키지 않으므로 match-all 백엔드 하나가 라우터 전체의 디스패치를 끄지 않습니다.

  • 일반 입력 토큰 추정치가 라우팅된 모델의 유효 컨텍스트 윈도우 이상인 채팅 요청을 백엔드 전송 전에 거절합니다 (#2326). 출력 예산이 없는 스트리밍 요청에도 적용하며, 제한이 없거나 0이면 통과시킵니다. 백엔드별 설정이 캐시된 엔진 보고보다 우선하고, 라우팅과 압축 후 확인된 서빙 윈도우 중 최솟값을 적용합니다. 메타데이터가 실제 백엔드 윈도우보다 작으면 설정을 수정해야 기존에 광고된 제한을 넘어서도 성공하던 요청을 다시 처리할 수 있습니다.

  • Hub 인벤토리에 모델 목록과 같은 클라이언트용 모델 이름을 게시합니다 (#2434). 하트비트의 라우터 수준 models_served가 원시 집계에서 나왔기 때문에, GET /v1/models가 게시하고 디스패치가 해석하는 백엔드 model_configs[].aliases와 모델 실험 이름이 Hub의 엔드포인트 모델 관측에 빠졌고, 포털은 제공 중인 모델을 "제공 안 함"으로 표시했습니다. 백엔드별 귀속은 원시 카탈로그를 그대로 사용합니다.

  • CONTINUUM_DEV_MODE를 불리언으로 처리하도록 수정했습니다 (#2361). admin 스코프 키를 포함한 내장 개발용 API 키가 이 변수가 설정되기만 하면 로드되었으므로, 릴리스 빌드에서 CONTINUUM_DEV_MODE=false나 0도 키를 켰습니다. 이제 true 또는 1(대소문자 무시)만 키를 켜며, false, 0, 빈 값, 인식할 수 없는 값(경고를 남김)은 키를 끈 상태로 둡니다. 변수를 설정하지 않으면 이전처럼 빌드 유형을 따릅니다. 디버그 빌드에서는 켜지고, 릴리스 빌드에서는 꺼집니다.
  • ACP session/prompt 스트림의 정체 시간을 제한하고 ACP 프롬프트 사용량을 기록합니다 (#2762). 백엔드가 timeouts.request.streaming.first_byte(요청 전송부터 첫 본문 청크까지) 또는 chunk_interval(이후 청크 간격)을 넘기면, 스트리밍 프롬프트는 전체 예산까지 세션을 붙잡지 않고 JSON-RPC 오류 -32603으로 실패합니다. 오류 데이터는 {"timeout":"chunk_interval","timeout_ms":1000} 형식입니다. 백엔드에 도달한 모든 프롬프트는 통계 행 하나와 토큰 카운터를 남기며, outcome은 completed, length, client_cancelled, timeout_first_byte, timeout_mid_stream, upstream_error 중 하나입니다. 스트리밍 프롬프트는 stream_options.include_usage를 요청하고, usage 블록이 없으면 레코드에 라우터의 입력 추정치와 token_source: none을 기록합니다.
  • --mode stdio에서 전달할 수 없는 ACP 사용량을 더 이상 큐에 쌓지 않고, ACP 모델 이름과 모델별 통계에 상한을 둡니다 (#2790). control_plane.enabled: true인 stdio 모드에는 control-plane 에이전트가 없어 사용량 채널을 비우는 주체가 없었고, 채널이 가득 차면 레코드마다 경고를 남겼습니다. 이제 사용량 채널과 사용량 write-ahead 로그를 열지 않고, 시작할 때 경고를 한 번만 남기며, 사용량은 로컬 통계에만 기록합니다. session/prompt는 256바이트를 넘는 모델 이름을 백엔드 선택 전에 JSON-RPC 오류 -32602로 거절하고, 오류 데이터는 {"field":"model","code":"model_too_long"}입니다. per_model 통계는 모든 인그레스에서 이름을 최대 10,000개까지 추적합니다. 상한을 넘은 새 이름은 unknown으로 집계하고, 복원된 스냅샷의 이름을 포함해 이미 추적 중인 이름은 자기 행을 유지합니다.
  • 실시간 WebSocket 업그레이드(GET /v1/realtime, GET /realtime)에 키별 allowed_models 목록을 적용했습니다 (#2695). 일부 모델로 제한된 키가 카탈로그의 다른 오디오 모델로 실시간 세션을 열 수 있었으며, 같은 모델을 /v1/chat/completions로 요청하면 403이 반환되었습니다. 이제 핸드셰이크는 예약된 변형 검사, 별칭 해석, 업그레이드보다 먼저 요청한 이름에 공유 enforce_model_access 검사를 실행하므로, 거부된 키는 403 permission_error를 받고 핸드셰이크를 완료하지 못하며 백엔드에 연결하지 않습니다. 목록 밖 모델의 별칭도 거부됩니다.
  • make docker-run이 안내된 백엔드에 연결하고 호스트 8000번 포트로 접근할 수 있도록 수정했습니다 (#2646). 이 타깃은 아무 곳에서도 읽지 않는 ROUTER_BACKENDS를 전달했고, 아무것도 수신하지 않는 컨테이너 8000번 포트를 공개했습니다. 이제 설정 로더가 읽는 백엔드 재정의 변수 CONTINUUM_BACKEND_URLS를 전달하고, 호스트 8000번 포트를 이미지의 8080번 포트에 연결하며, --add-host=host.docker.internal:host-gateway를 추가해 Linux Docker Engine에서도 Docker Desktop과 마찬가지로 host.docker.internal이 해석되게 합니다.
  • 이미지 생성 및 KV 인덱스 SSE 스트림에서 줄바꿈 없이 끝난 마지막 줄도 처리하고, KV 이벤트에서 청크 사이에 나뉜 UTF-8 문자를 보존하도록 수정했습니다 (#2751).
  • Responses, Anthropic, chat completion의 파일 참조에 하나의 파일 접근 정책을 적용했습니다. 이제 files.auth.admin_can_access_all이 활성화된 경우에만 admin 스코프 키가 관리자가 업로드한 파일을 참조할 수 있습니다 (#2751).
  • 네이티브 Anthropic Messages ingress가 만든 관리 대상 트레이싱 컨텍스트를 TCP, Unix 소켓, Bedrock 디스패치에 한 번만 전달하고, 운영자나 클라이언트가 보낸 추적 헤더가 중복되지 않도록 수정했습니다 (#2751).
  • Dynamo broker tunnel 통합 테스트의 모든 비동기 대기를 제한해, 종속 요소가 멈추면 테스트 대상이 멈추는 대신 만료된 작업을 보고하도록 수정했습니다 (#2751).
  • response_format을 전달하지 못하는 백엔드로 해당 요청을 보내지 않도록 수정했습니다 (#2438). 클라이언트의 response_format은 OpenAI 호환 와이어와 Gemini 변환만 전달하고 Anthropic Messages와 Bedrock 변환은 버리므로, 구조화 출력 요청이 이런 백엔드로 디스패치되면 요청한 형식 없이 응답을 받았습니다. 이제 response_format을 담은 채팅 완료는 라우팅된 모델과 폴백 모델을 통틀어 이 필드를 유지하는 변환의 후보로만 라우팅되며, 라우팅된 모델에 그런 후보가 없으면 그 모델을 밝히는 400(param: response_format, 코드 unsupported_request_parameter)으로 거부됩니다. response_format이 없는 요청은 이전과 같이 라우팅됩니다. GET /v1/models는 모델을 서빙하는 모든 백엔드가 이 필드를 전달할 때 supported_parameters에 response_format을 나열합니다.
  • 스트리밍 /v1/completions 요청을 계량하도록 수정했습니다 (#2331). 레거시 인그레스는 stream: true 요청에 버퍼링 경로로 응답하는데, 백엔드의 SSE 본문에 사용량 블록이 없었고 통계 기록 단계는 이 본문을 파싱하지 못해 사용량이 전혀 기록되지 않았습니다. 이제 라우터가 백엔드에 stream_options.include_usage: true를 요청하고(클라이언트가 보낸 다른 stream_options 필드는 유지), 백엔드가 보낸 사용량 전용 청크로 계량합니다. 응답은 chat 스트림처럼 클라이언트의 include_usage를 따릅니다. 클라이언트가 설정하지 않았으면 usage가 없고, 설정했으면 마지막 사용량 전용 청크에만 담깁니다. 응답은 여전히 버퍼링되어 text/plain으로 나가며, 이 인그레스의 스트리밍 릴레이는 별도 변경으로 다룹니다.
  • 모든 /v1/chat/completions 스트림이 클라이언트의 stream_options.include_usage를 따르도록 수정했습니다 (#2271). include_usage: true를 보내지 않은 클라이언트는 usage를 전혀 받지 않습니다. 사용량 기록, 응답 캐시, 가드레일 게이트가 스트림을 읽은 뒤에 chat 인그레스가 모든 청크에서 usage 필드를 제거하고(null 포함) 마지막 사용량 전용 청크를 빼므로, 계량은 그대로입니다. include_usage: true를 보낸 클라이언트는 OpenAI 스트림이 보내는 형태대로 마지막 사용량 전용 청크에서만 usage를 받습니다. usage: null을 담은 청크는 그대로 전달되고, 선택지와 null이 아닌 usage를 함께 담은 청크에서는 usage가 제거됩니다(청크마다 사용량을 보내는 엔진의 running usage). 사용량 전용 청크 없이 [DONE]에 이른 스트림(마지막 본문 청크에만 사용량을 보내는 엔진)에는 라우터가 마지막으로 제거한 usage를 담은 사용량 전용 청크를 써 넣으므로, 클라이언트는 사용량을 한 번 받습니다. 청크마다 chunk.usage를 누적하던 클라이언트가 더는 토큰을 이중 집계하지 않으며, 모델별 옵트인 목록 proxy.honor_include_usage_models는 지원이 종료되어 무시됩니다. 모델을 나열해도 아무 변화가 없고, 필드는 여전히 파싱됩니다.
  • POST /v1/responses/compact를 요청 통계와 사용량 원장에 기록합니다 (#2690). 백엔드는 compact 요청을 처리하고 과금했지만 라우터는 행을 남기지 않아, compact 트래픽이 /admin/stats*, 키별 및 사용자별 사용량, control-plane 사용량 원장에서 빠져 있었습니다. 이제 처리된 요청은 백엔드의 토큰 사용량과 함께 한 행을 기록하고, 백엔드 오류, 읽거나 파싱할 수 없는 응답, 연결 실패는 토큰 없이 실패 행 하나를 기록합니다. 디스패치 전에 거부된 요청(검증, 키별 모델 또는 백엔드 허용 목록, compact를 지원하지 않는 백엔드 유형)은 여전히 아무것도 기록하지 않습니다.
  • 출력 예산이 와이어 최솟값을 만족하지 못하는 채팅 완료를 디스패치 전에 거부하도록 수정했습니다 (#2366). max_tokens(두 OpenAI 표기 모두)가 0인 요청은 지금까지 전달되었고, 이를 거부한 업스트림은 클라이언트가 보낸 필드가 아닌 자체 내부 필드 이름(max_completion_tokens: Validation error: range ...)을 지칭했습니다. 그 결과 param이나 메시지 접두사 기준으로 오류를 매핑하는 SDK나 IDE 플러그인이 클라이언트가 보내지 않은 필드를 표시·재작성했고, 문제 필드 이름을 기준으로 하는 자동 재시도 로직이 오작동했습니다. 이제 라우터는 어떤 백엔드 다이얼보다 먼저, 클라이언트가 보낸 필드 이름을 지칭하는 OpenAI 형식의 400을 반환합니다(max_tokens must be at least 1., param에 해당 필드 포함). 1 이상의 예산은 변경 없이 전달됩니다.
  • 백엔드가 중간에 끊은 /v1/responses 응답을 completed로 보고하던 문제를 수정했습니다(#2715). 채팅 finish_reason이 length 또는 content_filter이면 이제 status: "incomplete"와 함께 incomplete_details.reason을 max_output_tokens 또는 content_filter로 설정합니다. 버퍼링 응답과 스트림 모두에 적용되며, 스트림은 response.completed 대신 response.incomplete로 끝납니다. Anthropic 백엔드의 max_tokens와 refusal 중단 사유도 같은 방식으로 매핑합니다. 부분 출력은 유지됩니다.
  • Anthropic의 빈 system(system: [] 또는 텍스트가 없는 블록)을 변환 백엔드에서 빈 system 메시지로 바꾸지 않도록 수정했습니다(#2721). 빈 항목 때문에 system을 생략한 요청보다 매번 입력 토큰이 1개 더 과금되었습니다. 이제 OpenAI 호환 변환과 Gemini 변환은 이 경우 system 항목을 추가하지 않으며, Bedrock, Anthropic, Responses 변환은 원래 빈 system 텍스트를 건너뛰었습니다.
  • 백엔드가 추론을 수행할 요청에 대해 Anthropic 와이어로 stop 시퀀스를 전달하지 않도록 수정했습니다 (#2279). 백엔드는 숨겨진 추론 구간 안에서 stop_sequences를 매칭하기 때문에, 추론 텍스트 초반에 stop 문자열이 등장하면("e"처럼 짧은 시퀀스에서 흔함) 사용자에게 보이는 콘텐츠가 생기기 전에 생성이 중단되고 응답이 content: null과 finish_reason: "stop"을 담았습니다. OpenAI→Anthropic 변환은 이제 변환된 요청이 thinking 파라미터를 담고 있거나 기본으로 추론하는 어댑티브 thinking 모델(Claude 4.6+)을 대상으로 할 때 stop_sequences를 제거하며, 제거 사실을 debug 레벨로 기록합니다. 요청할 때만 추론하는 모델은 thinking 파라미터가 없으면 stop_sequences를 그대로 전달하고, thinking: {"type": "disabled"}이면 모든 모델에서 그대로 전달하므로 가시 콘텐츠에 대한 stop 매칭은 바뀌지 않습니다. Bedrock Converse 변환도 additionalModelRequestFields.thinking으로 thinking을 켠 요청에 같은 규칙을 적용합니다. 트레이드오프: 추론 요청에서는 클라이언트의 stop 시퀀스가 강제되지 않고, completion이 잘리는 대신 가시 콘텐츠가 stop 문자열을 지나 계속 생성됩니다.
  • 스트리밍 Anthropic Messages 응답에서 action-safety 판정 결과를 Claude Code가 읽는 위치에 담아 보내도록 수정했습니다 (#2682). safeguards.action_safety를 켜면 판정 결과가 별도의 safeguard_results SSE 이벤트로 나갔는데, claude-cli는 이 이벤트를 건너뛰기 때문에 auto 모드 세션이 모두 과금되는 자체 분류기로 전환되고 "isn't eligible" 안내가 표시됐습니다. 이제 판정 결과는 턴의 마지막 message_delta에 delta.safeguard_results로 실립니다. 결과가 없는 응답도 세션을 클라이언트 분류기로 전환시키므로, 도구 사용을 제안하지 않은 응답에는 스트리밍 여부와 관계없이 tool_uses 맵이 빈 available 검사를 싣습니다. 평가기를 설정하지 않은 경우의 동작은 그대로이며, /v1/responses Chat Completions 브리지는 [DONE] 앞의 이벤트를 유지합니다.
  • 라우터가 형식을 인식하지 못하는 백엔드의 4xx 본문을 상태 코드만 담은 문구 대신 정리한 일부와 함께 전달합니다 (#2353). 낯선 JSON 형식으로 거부 사유를 설명하던 백엔드의 응답은 이전까지 Anthropic Messages 클라이언트에 Invalid request sent to backend로만 전달되었습니다. 이제 본문은 추출한 메시지와 같은 방식으로 민감 정보를 가리고 길이를 제한해 Backend rejected the request: HTTP <status>: 뒤에 붙습니다. /v1/responses 중계 경로(Chat Completions 변환, Responses 전용 스트리밍, 패스스루)에도 같은 방식을 적용합니다. 빈 본문, HTML 페이지, 이진 본문, 5xx는 기존의 일반 문구를 유지합니다.
  • /anthropic/v1/messages에서 Gemini thought signature를 보존합니다(#2312). 서명된 Gemini 함수 호출은 tool_use 앞의 thinking 블록에 서명을 담아 반환하고(스트리밍에서는 signature_delta), 클라이언트가 이 블록을 다시 보내면 서명을 함수 호출에 되돌려 씁니다. 이제 Gemini 3 함수 호출 루프가 두 번째 턴에서 실패하지 않으며, 다른 백엔드에는 이 필드를 보내지 않습니다.
  • POST /v1/responses/compact에 키별 allowed_models 목록을 적용했습니다 (#2686). 이 엔드포인트는 목록을 확인하지 않아, 일부 모델로 제한된 키도 라우터가 제공하는 다른 모델로 대화를 압축할 수 있었고 /v1/responses는 같은 요청을 거부했습니다. 이제 별칭과 실험 디스패치, 가드레일, 백엔드 선택보다 먼저 클라이언트가 보낸 모델 이름으로 검사하며, /v1/responses와 같은 403(permission_error, insufficient_permissions)으로 거부합니다. 목록 밖의 모델은 백엔드가 제공하는지와 관계없이 이 403을 받고, 별칭은 키가 별칭 자체를 나열한 경우에만 허용됩니다.
  • 페일오버 뒤 허브 티어 분당 한도 저장소가 강등된 primary에 머무는 문제를 수정했습니다 (#2745). 관리형 페일오버는 옛 primary의 클라이언트 연결을 닫지 않고 강등할 수 있습니다. 풀에 남은 그 연결들이 계속 PING에 응답했기 때문에 복구 probe는 저장소가 돌아왔다고 판단했지만 모든 스크립트가 READONLY로 실패했고, 각 라우터는 재시작할 때까지 따로 셌습니다. 이제 READONLY 응답이나 끊긴 연결을 만나면 풀의 연결을 버려 다음 호출이 엔드포인트를 다시 해석하고, probe는 PING 대신 짧게 유지되는 <key_prefix>probe:<router id> 키를 씁니다.
  • 허브 티어 분당 한도 저장소에 요청보다 먼저 연결하도록 수정했습니다 (#2748). 풀이 필요할 때만 연결했기 때문에 기동 직후나 복구 직후의 첫 호출이 이름 해석과 연결 비용을 100 ms 저장소 deadline 안에서 치렀고, 이를 넘기면 그 라우터가 probe 주기 동안 자체 윈도로 돌아갔습니다. 이제 maintenance 작업이 기동할 때와 복구할 때마다 자체 1초 상한 안에서 풀 연결 몇 개를 먼저 열고, 그 연결로 probe합니다.
  • ACP initialize 핸드셰이크가 유령 세션을 만들지 않도록 수정했습니다 (#2430, #2457). 협상된 capability를 실제 저장소 세션에 담고 이를 지우지 않았기 때문에, initialize를 호출할 때마다 1시간 TTL 정리가 회수할 때까지 GET /admin/acp/sessions에 세션이 하나씩 늘었습니다. 이제 capability는 연결의 핸들러 컨텍스트에 보관합니다. session/new와 session/load의 capability 검사는 이 값을 읽으며, initialize와 session/new 사이에 보낸 session/set_mode나 session/cancel에는 "No active session"으로 응답합니다.
  • session/update notification 전송이 실패해도 ACP session/prompt 턴을 유지하도록 수정했습니다 (#2431, #2458). 이전에는 notification 전송 실패가 프롬프트 전체를 실패시키고 히스토리 갱신을 건너뛰었지만, 백엔드는 이미 응답하고 과금한 상태였습니다. 이제 실패는 로그로 남기고 그 턴의 이후 notification은 건너뛰며, 누적된 응답은 세션 히스토리에 그대로 기록합니다.
  • ACP 프롬프트의 백엔드 타임아웃을 고정된 300초 대신 timeouts 설정에서 가져오도록 수정했습니다 (#2425). 스트리밍 프롬프트는 streaming.total, 버퍼링 프롬프트는 standard.total 예산을 핫 리로드되는 타임아웃 스냅샷에서 읽습니다. 따라서 더 짧은 제한은 그대로 적용되고, 더 긴 제한을 설정하면 긴 응답이 5분에서 잘리지 않습니다.
  • ACP session/cancel이 진행 중인 백엔드 요청을 중단하도록 수정했습니다 (#2418). 프롬프트 핸들러가 백엔드 응답 전체를 기다렸기 때문에, 취소는 업스트림이 이미 생성하고 과금한 본문의 전달만 멈췄습니다. 이제 요청과 본문의 점진적 읽기가 세션의 취소 토큰과 경쟁하며, 취소된 프롬프트는 stopReason: "cancelled"를 보고하고 중단된 응답을 세션 히스토리에 추가하지 않습니다.
  • System One choice 선택지를 클라이언트가 보낸 순서대로 유지합니다 (#2453). POST /v1/decisions는 choice 질문의 criteria를 선택지 id 순으로 다시 정렬해 전달했기 때문에 선택지를 위치로 레이블링하는 decision 서버는 클라이언트가 작성한 것과 다른 질문을 받았습니다. 이제 passthrough 대상과 에뮬레이션 채팅 프롬프트 및 도구 스키마가 요청 순서대로 선택지를 받습니다. 라우터 자체의 내장 배터리(스마트 라우팅 도메인, action safety)는 보정된 대로 계속 id 순으로 선택지를 보냅니다.
  • DeepSeek 모델에 대해 Anthropic ingress가 파생한 reasoning_effort를 서빙 모델이 선언한 어휘에 맞춥니다 (#2268). model-metadata.yaml과 15-deepseek.yaml 프래그먼트에서 deepseek-v4.1-flash는 API 거부 응답으로 측정한 허용 집합인 low, high, xhigh, max를 선언하고 deepseek-v4-pro는 문서화된 3단계인 low, high, max를 선언합니다. 이제 Claude Code의 output_config.effort: medium은 high로 맞춰집니다. 기존에는 이 값이 그대로 전달되어 거부되었기 때문에 DeepSeek 모델에서 모든 Claude Code 요청이 400으로 실패했습니다.
  • src/services/mod.rs에서 배선되지 않은 ServiceRegistry를 제거했습니다 (#2300). 이 레지스트리의 health_check()는 등록하거나 실행한 적이 없는 네 서비스에 대해 항상 Healthy를 보고했습니다. initialize는 아무것도 등록하지 않았고 자체 단위 테스트 밖에서는 이 레지스트리를 생성하는 곳이 없었습니다. Service 트레이트와 ServiceConfig는 유지되며 HealthServiceImpl은 변경되지 않았습니다.
  • 게시된 컨테이너 이미지가 이제 설정 파일을 마운트하지 않아도 시작됩니다 (#2429). 기본 명령은 --config /etc/continuum-router/config.yaml을 전달하지만 그 경로에 파일이 없어서 docker run -p 8080:8080 ghcr.io/lablup/continuum-router가 바로 종료되었습니다. 이제 Dockerfile과 Dockerfile.ci는 빌드 시 바이너리 자체의 --generate-config 출력을 그 경로에 쓰고 config validate로 검사하므로, 기본 설정이 검증을 통과하지 못하면 빌드가 실패합니다. 마운트한 파일은 여전히 기본 설정보다 우선합니다.
  • GET /v1/models/{model}이 이제 날짜가 붙은 모델 id의 날짜 없는 형태를 해석합니다 (#2577). 이 엔드포인트는 id를 문자열 그대로 비교했기 때문에 /v1/chat/completions가 날짜 접미사 대체 매칭으로 claude-opus-4-5-20251130에 라우팅하는 이름을 GET /v1/models/claude-opus-4-5에서는 404로 응답했습니다. 이제 캐시 조회와 집계 조회 모두 정확히 일치하는 id를 먼저 찾고 라우팅 경로와 같은 날짜 접미사 대체 매칭을 적용합니다. 응답에는 일치한 카탈로그 id가 담깁니다.
  • Claude Code가 설정된 모델 이름 뒤에 붙이는 Anthropic [1m] 컨텍스트 윈도 표기를 해석합니다 (#2274). /v1/chat/completions, /anthropic/v1/messages 및 모델로 라우팅하는 다른 진입점에서 <id>[1m] 요청은 이제 <id>와 똑같이 라우팅됩니다. Continuum Hub 티어 허용 목록과 키별 allowed_models 목록은 표기를 뗀 id로 판단하고(두 표기 중 어느 쪽으로 적힌 항목도 일치합니다) 별칭 디스패치도 그 id에 적용되며, 업스트림은 표기를 뗀 id를 받습니다. 클라이언트에 보이는 오류는 클라이언트가 보낸 이름을 그대로 인용합니다. 이전에는 표기가 붙은 이름이 티어 허용 목록 검사에 실패해 오해를 부르는 403 model_not_allowed가 반환되었습니다. 정확히 [1m] 접미사만 제거하며 model[1] 같은 다른 대괄호 이름은 바뀌지 않습니다.
  • 오류로 끝나는 OpenAI 와이어 스트림이 이제 data: [DONE]으로 끝납니다 (#2441): 오류로 끝나는 chat-completions 스트림(오류 상태 핸드셰이크, 타임아웃, 업스트림 오류 종결, max-errors 차단, 소진된 mid-stream 폴백 체인, Responses 브리지 chat 스트림의 response.failed / response.cancelled / error 종결)은 event: error 프레임 직후에 멈춰서 엄격한 클라이언트가 스트림을 닫는 데 쓰는 센티널을 받지 못했습니다. 이제 모든 OpenAI 와이어 릴레이 본문은 정확히 하나의 data: [DONE]으로 끝나며, 스트림이 이미 센티널을 전달한 경우에는 추가하지 않습니다. 타입이 있는 이벤트는 data:보다 event:를 먼저 씁니다. 끊긴 Responses 브리지 chat 스트림을 센티널 없이 끝내던 이전 동작(#2122)을 대체하며, 실패는 여전히 센티널이 아니라 오류 이벤트가 전달합니다. Anthropic Messages와 Responses API 스트림은 바뀌지 않습니다.
  • OpenAI 호환 백엔드의 /v1/chat/completions 응답에서 값이 null인 reasoning 필드를 더 이상 전달하지 않습니다 (#2275). 라우터는 null이 아닌 reasoning을 표준 이름인 reasoning_content로 바꾸면서도 reasoning: null은 그대로 넘겨서 같은 모델의 응답이 두 이름 사이를 오갔습니다. 이제 버퍼링된 message 객체와 스트리밍 delta 청크에서 null reasoning을 제거하며, 기존 reasoning_content가 함께 있어도 마찬가지입니다. reasoning_content: null은 추가하지 않으며, 기존 reasoning_content는 덮어쓰지 않습니다.
  • Anthropic과 Gemini 백엔드용으로 요청을 변환할 때 제거되는 도구를 확인할 수 있도록 했습니다 (#2285). 변환이 전달할 수 없는 type의 도구(Anthropic에 보낸 file_search나 web_search 같은 OpenAI 내장 도구, 또는 알 수 없는 유형)는 많아야 debug 로그 한 줄만 남기고 업스트림 요청에서 제거되었고, 네이티브 Gemini 변환은 로그도 남기지 않았습니다. 이제 제거할 때마다 유형과 함께 warn 수준으로 기록하고 새 카운터 tool_conversion_dropped_total{target, tool_type}에 집계합니다. tool_type 레이블 값은 OpenAI 내장 도구 이름과 other로 한정됩니다. Gemini에 보낸 웹 검색과 코드 인터프리터 도구는 이전처럼 Gemini 내장 도구로 바뀌며, 집계하지 않습니다. 요청은 제거된 도구 없이 계속 전달됩니다.
  • 라이브러리 함수 core::tool_calling::transform_tool_message_to_gemini가 항상 객체인 functionResponse.response를 만들도록 수정했습니다 (#2314). 배열 콘텐츠, 숫자, 배열이나 스칼라로 파싱되는 문자열이 그대로 전달되어 Gemini가 400으로 거부했습니다. 이제 이 값들은 {"result": value}로 감싸고, 객체로 파싱되는 문자열은 이전처럼 그 객체를 그대로 사용합니다. 이미 이 값들을 감싸던 네이티브 Gemini 채팅 변환도 같은 헬퍼를 사용합니다.
  • Anthropic 백엔드용으로 채팅 기록을 변환할 때 배열 형식의 도구 결과를 유지합니다 (#2305). OpenAI 스키마가 허용하는 콘텐츠 파트 배열을 content로 가진 role: "tool" 메시지는 빈 tool_result로 업스트림에 전달되어 모델이 도구 출력을 받지 못했습니다. 이제 텍스트 파트를 줄바꿈으로 이어 보내고, 텍스트 파트가 없는 배열과 객체나 숫자 값은 버리지 않고 JSON 텍스트로 보냅니다. 문자열 콘텐츠는 바뀌지 않습니다.
  • 라우터가 Unix 소켓에서만 수신할 때 AppProxy ROUTER 워커가 포트 8080을 알리지 않도록 수정했습니다 (#2433, #2459). appproxy_router가 켜져 있고 traffic_port가 비어 있으며 server.bind_address에 unix: 항목만 있으면 워커는 아무것도 수신하지 않는 8080을 등록했고, 라우터는 정상으로 보였지만 코디네이터의 인그레스는 트래픽을 보낼 곳이 없었습니다. 이제 이 구성은 server.bind_address와 appproxy_router.traffic_port를 지목하는 오류와 함께 시작 단계에서 실패합니다. 명시한 traffic_port는 그대로 따르며, 알리는 api_port는 첫 번째로 설정한 주소에서 읽은 값이 아니라 실제로 바인딩한 첫 TCP 리스너의 포트입니다(unix:가 앞에 오고 TCP 항목이 있는 목록과 ContinuumRouter::serve에 넘긴 주소 포함).
  • Unix 소켓 백엔드에도 TCP 백엔드와 같은 트레이싱 헤더를 보냅니다 (#2528). 버퍼링 채팅, 스트리밍 채팅, Anthropic 스트리밍 채팅의 Unix 소켓 전송은 라우터의 X-Trace-ID를 항상 추가하면서 클라이언트의 값도 함께 전달해 백엔드가 서로 다른 두 값을 받을 수 있었고, 트레이싱이 켜져 있어도 X-Request-ID, X-Correlation-ID, traceparent를 넣지 않았습니다. 이제 트레이싱이 켜져 있으면 클라이언트가 보낸 관리 대상 트레이싱 헤더를 제거하고 라우터의 헤더를 한 번만 넣습니다. 트레이싱이 꺼져 있으면 라우터는 아무 헤더도 추가하지 않으며, 클라이언트의 X-Trace-ID는 그대로 전달됩니다.
  • 클라이언트 채널이 가득 차 재시도한 스트리밍 이미지 생성 이벤트도 event: 유형을 유지합니다 (#2530). 기존에는 100 ms 후 한 번 재시도할 때 유형 없이 프레임을 다시 만들어서 백프레셔를 받은 partial 또는 complete 이벤트가 유형 없는 data: 프레임으로 전달되었습니다. 이제 재시도는 가득 찬 채널이 돌려준 프레임을 그대로 다시 보냅니다.
  • 스트리밍 릴레이에서 업스트림 네트워크 청크 경계에 걸쳐 나뉜 멀티바이트 문자가 깨지지 않도록 수정했습니다 (#2529). Responses API 릴레이(Anthropic, Gemini, chat-completions 브리지, 패스스루)와 Responses 전용 chat-completions 브리지, 스트리밍 이미지 생성은 청크마다 따로 디코딩했기 때문에 청크 경계에서 잘린 한국어, CJK, 이모지 문자가 클라이언트에 U+FFFD 대체 문자 두 개로 전달되었습니다. 이제 원시 바이트에서 디코딩하며, 나뉜 시퀀스는 다음 청크가 채울 때까지 보관합니다. 공용 SSE 바이트 파서도 업스트림 바이트가 실제로 잘못된 UTF-8일 때 청크 전체를 버리지 않습니다. 해당 바이트는 U+FFFD로 바뀌고 [DONE]을 포함한 주변 이벤트는 그대로 파싱됩니다.
  • POST /admin/files로 업로드한 파일을 소유자 없이 저장하지 않고 합성 소유자 __admin__으로 귀속합니다 (#2644). files.auth.enforce_ownership가 켜져 있으면 소유자 없는 파일은 인증된 모든 호출자에게 제공되므로 files 스코프를 가진 키라면 ID만 알면 관리자 업로드 파일을 읽고 내려받고 삭제할 수 있었습니다. 이제 테넌트 키는 이 파일에 403을 받습니다. admin 스코프 키는 admin_can_access_all을 통해 계속 접근하며, Admin API는 바뀌지 않습니다. 이 소유자는 예약되어 있어 공개 Files API와 목록, Responses 및 Anthropic 파일 해석기에서 어떤 호출자 ID와도 일치하지 않으며 user_id가 __admin__인 키도 마찬가지입니다. 이 변경 전에 관리자가 업로드한 파일은 계속 소유자 없이 남습니다.
  • file_id로 해석한 인라인 PDF에 업로드할 때의 파일 이름을 유지합니다 (#2411). 기존에는 저장된 파일 이름을 버리고 {file_id}.pdf를 업스트림으로 보내 내부 저장소 ID가 문서 이름으로 노출되었습니다. 이제 인라인 file 파트는 제어 문자를 제거하고 앞뒤 공백을 다듬은 뒤 255자로 제한한 저장 파일 이름을 담습니다. 쓸 수 있는 이름이 저장되어 있지 않으면 이전처럼 {file_id}.pdf를 사용합니다. 기본 파일 변환기와 OpenAI 파일 변환기가 같게 동작합니다.
  • /v1/chat/completions에서 잘못된 파일 참조를 전달 전에 거부합니다 (#2409). 기존에는 file_id가 없거나 문자열이 아닌 image_file 또는 file 콘텐츠 파트가 파일 해석에서 조용히 빠져 원본 파트가 그대로 백엔드에 전달되었고 클라이언트는 알 수 없는 콘텐츠 유형에 대한 업스트림 오류를 받았습니다. 올바른 참조와 잘못된 참조가 섞인 페이로드는 일부만 해석되었습니다. 이제 이런 요청은 문제가 된 필드(예: messages[0].content[1].image_file.file_id)를 지목하는 400(file_resolution_failed)으로 응답합니다. file_id 없이 file.file_data를 담은 인라인 file 파트는 참조가 아니므로, 이전처럼 전달됩니다.
  • 지속 메트릭의 VACUUM을 별도 연결에서 스냅샷 작업 루프와 분리해 실행합니다 (#2303). 기존에는 컴팩션이 저장소의 유일한 연결 잠금을 잡은 채 작업 루프 안에서 실행되어, 파일 전체를 다시 쓰는 동안 스냅샷 삽입과 GET /admin/metrics/history 질의가 기다렸고 스냅샷 틱이 건너뛰어져 저장된 이력에 공백이 생겼습니다. 이제 컴팩션 중에도 스냅샷이 계속 실행됩니다. VACUUM의 쓰기 잠금에 걸린 삽입은 다음 틱에서 다시 시도하고, 이전 슬롯의 컴팩션이 아직 실행 중이면 다시 시작하지 않습니다.
  • rate_limit_* 메트릭 계열을 /metrics로 내보냅니다 (#2578). 이 중 10개 계열이 /metrics가 제공하지 않는 Prometheus 기본 레지스트리에 등록되어 있어, 제한기가 기록한 검사, 위반, 건너뜀, 우회, 버킷 수가 스크레이프에 나타나지 않았습니다. 이제 이미 그렇게 등록되어 있던 rate_limit_redis_unavailable_total과 함께 모든 계열이 스크레이프 대상 레지스트리에 등록됩니다.
  • 설정에 metrics: 섹션이 없어도 /metrics를 제공합니다 (#2385). 섹션이 없으면 메트릭을 끄는 대신 문서에 적힌 기본값(활성화, 경로 /metrics, metrics.auth를 설정하기 전까지 접근 거부)을 라우트, 요청 기록 미들웨어, 레이트 리밋 예외, GET /admin/capabilities에 적용합니다. 영속 메트릭 로그는 여전히 metrics: 섹션이 명시된 경우에만 기록되며, 섹션이 없을 때도 ContinuumRouter::enable_metrics(false)로 메트릭을 끌 수 있습니다.
  • 잘못된 JSON 요청 본문을 일반 텍스트 프레임워크 거부 대신 라우터의 오류 봉투로 반환하도록 수정했습니다 (#2298). OpenAI 호환 경로에서 JSON 파싱에 실패한 본문은 Axum의 기본 일반 텍스트 400을 반환해 JSON 파싱 클라이언트와 SDK가 읽을 수 없었습니다. 이제 프레임워크 거부 메시지를 담은 OpenAI 오류 형식(invalid_request_error, 코드 invalid_json)을 반환하고 /anthropic/v1/messages, /anthropic/v1/messages/count_tokens와 루트 /v1/messages 별칭에서는 Anthropic 형식을 반환합니다. Content-Type이 없거나 잘못된 경우(415)와 본문이 엔드포인트의 요청 형식과 맞지 않는 경우(422)도 상태 코드는 그대로 두고 같은 봉투를 사용합니다.
  • /v1/images/generations, /v1/images/edits, /v1/images/variations에서 키별 allowed_models 목록을 적용합니다(#2506). 비어 있지 않은 목록에 요청한 모델이 없는 키는 이제 라우터에서 403 insufficient_permissions를 받고 요청은 백엔드에 도달하지 않습니다. chat completions와 오디오 경로와 같은 동작이며, 이전에는 이미지 경로가 요청을 그대로 전달했습니다. 이 검사는 클라이언트가 보낸 이름을 기준으로 별칭 및 실험 디스패치보다 먼저 실행되므로, 키가 사용할 수 없는 예약된 실험 변형 이름도 알 수 없는 이름과 같은 403으로 응답합니다. 이미지 생성 경로에서 백엔드별 속도 제한 거부를 기록하는 로그는 이제 속도 제한 저장소 장애(fail-closed)와 모든 백엔드가 한도에 도달한 경우를 구분해 남깁니다.
  • /v1/chat/completions와 /v1/completions에서 정수가 아니거나 음수인 max_tokens 또는 max_completion_tokens를 디스패치 전에 해당 필드를 지목하는 400 invalid_request_error(code: "invalid_type", 음수이면 integer_below_min_value)로 거부하도록 수정했습니다 (#2389). request_params 정책 설정 여부와 관계없이 적용됩니다. 이전에는 "64" 같은 문자열이 그대로 전달되어 클라이언트가 타임아웃할 때까지 생성 길이가 제한되지 않을 수 있었습니다. null과 필드 생략은 지금처럼 그대로 전달되어 백엔드 기본값이 적용되며, request_params 기본값이나 한도를 설정하면 모든 요청에 라우터 측 예산을 둘 수 있습니다. /v1/responses와 Anthropic Messages 인그레스는 본문을 파싱할 때 이미 정수가 아닌 예산을 거부합니다.
  • model이 없거나 null이거나 문자열이 아니거나 공백뿐인 /v1/chat/completions 또는 레거시 /v1/completions 요청을 임의의 서빙 모델로 라우팅해 200으로 응답하는 대신 400 invalid_request_error(param: "model", code: "missing_model", 메시지 you must provide a model parameter)로 거부하도록 수정했습니다 (#2269). 이 검사는 ingress 재정의가 모델을 채운 뒤에 실행되므로, 본문에 model이 없는 AppProxy ingress 고정 요청은 계속 라우팅됩니다.
  • 백엔드 검증 오류가 param을 null로 두고 메시지 안에서만 필드를 언급할 때, OpenAI 경로의 error.param에 해당 요청 필드를 채우도록 수정했습니다 (#2363). vLLM은 max_tokens=1000000000 cannot be greater than ...처럼 쓰고 끝에 (parameter=<name>, value=...)를 붙입니다. 두 표기 모두 이름이 알려진 요청 파라미터(max_tokens, max_completion_tokens, temperature 등)이면 param을 채웁니다. 업스트림이 param을 명시하면 그 값이 우선하고, 5xx 응답은 바뀌지 않으며, 백엔드 메시지는 이전처럼 전달됩니다.
  • Anthropic 와이어 경로에서 매칭되지 않은 요청에 일반 {"error": "..."} 객체 대신 Anthropic 오류 엔벨로프({"type":"error","error":{"type":"not_found_error","message":...}})와 404로 응답하도록 수정했습니다 (#2432). 이제 Anthropic SDK가 오류 유형을 해석할 수 있습니다. 대상은 /anthropic 아래의 모든 경로, 루트 /v1/messages/* 하위 경로, 기본 URL 안내를 받는 잘못된 접두사의 Messages 경로이며, 안내 문구는 메시지에 그대로 남습니다. 그 밖의 매칭되지 않은 경로는 기존 객체를 유지하고, /와 /v1/models는 LM Studio를 위해 계속 200으로 응답합니다.
  • 비스트리밍 /anthropic/v1/messages 요청에서 라우터 측 web_search 도구 루프의 한 라운드에 백엔드가 HTTP 오류로 응답하면 502 web_search loop failed 대신 업스트림 상태 코드와 정제된 메시지를 유지하도록 수정했습니다 (#2339). 이제 루프도 단일 요청 경로와 같은 매퍼로 응답합니다. 컨텍스트 창 초과는 백엔드 메시지와 함께 400 invalid_request_error를 반환하고, 네이티브 Anthropic 오류 엔벨로프는 그대로 전달되며, 업스트림 재시도 및 상관관계 헤더도 전달됩니다. 전송 오류와 루프 내부 오류는 계속 502를 반환합니다.
  • 변환 백엔드로 가는 Anthropic Messages 요청에서 이전 웹 검색 턴의 server_tool_use(web_search)와 web_search_tool_result 블록을 받아들이도록 수정했습니다 (#2340). 이전에는 요청 전체를 400 Unsupported content block type(s)로 거부했습니다. Claude Code는 다음 턴에 이 기록을 다시 보내므로 셀프 호스팅 백엔드에서 라우터의 웹 검색 에뮬레이션을 쓴 세션은 검색 직후 턴에서 실패했습니다. Chat Completions, Gemini, Responses API 변환은 이 블록을 같은 역할과 위치의 텍스트(Web search: <query>, 결과마다 - <title> (<url>): <snippet> 한 줄, 또는 Web search failed: <error_code>)로 바꿉니다. 형식이 잘못된 웹 검색 블록과 다른 서버 도구(web_fetch, code_execution)는 계속 거부되며, 네이티브 Anthropic 백엔드는 이 블록을 그대로 받습니다.
  • 요청이 thinking을 켜지 않았을 때 Anthropic Messages 인그레스가 백엔드 추론 내용을 thinking 블록으로 바꾸지 않도록 수정했습니다 (#2311, #2400). 모든 응답에 reasoning_content를 담는 백엔드(DeepSeek, 최신 vLLM)에서는 모든 응답이 thinking 블록으로 시작했고, content[0]을 텍스트로 읽는 클라이언트가 실패했습니다. 이제 thinking 블록과 델타는 thinking.type이 enabled 또는 adaptive일 때만 나타납니다. Chat Completions 변환 경로(버퍼링과 스트리밍)와 Responses API 버퍼링 경로에 적용되며, Responses API 스트리밍 변환기는 이미 같은 규칙을 따르고 있었습니다. 네이티브 Anthropic과 Bedrock 응답은 바뀌지 않습니다.
  • Anthropic Messages 인그레스(/anthropic/v1/messages와 루트 /v1/messages)에서 잘못된 thinking: {"type": "enabled"} budget을 백엔드에 요청을 보내기 전에 400 invalid_request_error로 거부합니다 (#2342). budget_tokens가 없거나 1024 미만이거나 max_tokens 이상이면 거부합니다. 마지막 경우는 anthropic-beta 헤더에 interleaved-thinking- 토큰이 있으면 허용합니다. 이전에는 라우터가 백엔드별로 이 필드를 재작성하거나 제거한 뒤 200을 반환했습니다. 네이티브 Anthropic 변환은 그 과정에서 max_tokens를 클라이언트 값보다 크게 올렸습니다. 검사는 request_params 적용 후의 max_tokens를 기준으로 하므로 정책이 이를 budget 아래로 낮추면 같은 400이 반환됩니다.
  • ACP stdio 서버의 session/prompt 응답을 SSE 본문 전체를 버퍼링하지 않고 도착하는 대로 스트리밍하도록 수정했습니다 (#2414). 핸들러는 백엔드에 stream: true를 보내면서도 response.text()로 응답 전체를 읽은 뒤에야 알림을 보냈기 때문에, ACP 클라이언트의 첫 토큰 지연이 전체 생성 시간과 같았습니다. 이제 본문을 청크 단위로 읽고 완성된 SSE 줄(공백 유무와 관계없이 data:로 시작하는 줄)을 도착 즉시 파싱해, 콘텐츠 델타마다 session/update notification을 바로 보냅니다. 읽기 사이에는 미완성 줄 하나만 보관하며, 그 크기는 16 MiB로 제한합니다. 모든 읽기는 세션의 취소 토큰과 경쟁하고(#2418), session/update 전송 실패는 여전히 턴을 실패시키지 않으며(#2431), 스트림 도중 백엔드 읽기 오류가 나면 버퍼링 방식 때와 같이 -32603으로 프롬프트가 실패합니다.
  • ACP stdio 서버에서 클라이언트의 session/cancel JSON-RPC notification을 버리는 대신 등록된 핸들러로 라우팅하도록 수정했습니다 (#2379). ACP 명세는 session/cancel을 notification으로 보내는데 stdio 메인 루프가 모든 notification을 버렸기 때문에, 명세를 따르는 클라이언트의 취소 요청이 세션에 도달하지 못했습니다. 이제 session/cancel notification은 sessionId가 가리키는 세션(지정하지 않으면 현재 세션)을 취소하고 새 토큰을 설치하므로, 세션을 계속 사용할 수 있습니다. 요청 형식도 같은 방식으로 sessionId를 해석하며, 알 수 없는 세션에는 -32001로 응답합니다. 명세가 클라이언트 notification으로 정의한 메서드만, 그리고 initialize 이후에만 실행되며, session/new나 session/prompt처럼 요청 전용 메서드를 id 없이 보내면 무시합니다. notification에는 응답이 없고 핸들러 실패는 로그로만 남깁니다. stdio 루프는 여전히 메시지를 하나씩 처리하므로, 프롬프트 실행 중에 보낸 취소는 그 프롬프트가 끝난 뒤에 처리됩니다 (#2380).
  • 레이트 리밋, 관리 API, 메트릭, 통계의 잔여 경로 여덟 곳을 보강했습니다 (#2718). per_backend 차원에서 fail-closed Redis 장애가 발생하면 이제 용량 소진을 뜻하는 429 대신 503 service_unavailable과 Retry-After: 5로 응답하며 폴백 체인을 시작하지 않습니다. 연결 수준의 Redis 실패 후 레이트 리미터는 5초 동안 Redis를 건너뛰고 요청 하나만 다시 확인하며, 풀 타임아웃은 250ms이므로 네트워크 분할 중에도 요청마다 Redis 기반 차원당 최대 500ms를 소모하지 않습니다. 토큰 버킷은 잦은 리필에서도 소수점 리필 크레딧을 잃지 않습니다. 핫 리로드가 없으면 PUT/PATCH /admin/config/{section}, 적용할 변경이 있는 POST /admin/config/apply와 /admin/config/import, POST /admin/config/rollback/{version}, 백엔드 변경 엔드포인트가 200 대신 503으로 응답하며, 적용되지 않은 롤백은 이력 버전을 기록하지 않습니다. /anthropic/v1/messages와 /v1/messages는 하나의 endpoint 레이블 값을 공유합니다. 중간에 실패한 스트리밍 /v1/audio/speech 응답은 업스트림 오류로, 클라이언트가 끊은 응답은 취소로 기록됩니다.
  • Hub가 413으로 거부한 사용량 배치를 삭제하지 않고 재분할하도록 수정했습니다 (#2360). Hub는 배치의 레코드 수가 usage.max_batch_records를 넘으면 413으로 답하는데, 라우터는 이런 배치를 통째로 삭제해 최대 usage_batch_max_records개 레코드의 지출 회계 데이터를 잃고 그 cost-center 시퀀스를 정리했습니다. 이제 레코드는 큐 앞으로 돌아가고, 다음 푸시가 배치를 절반 크기로 만들어 Hub가 받아들이거나 단일 레코드만 남을 때까지 반복합니다. Hub가 여전히 거부하는 단일 레코드는 이전처럼 삭제됩니다. 재분할된 레코드는 스풀에 있는 배치보다 먼저 전송되므로, 배치는 계속 router_usage_seq 순서로 Hub에 도착합니다. 버려진 배치는 다음 배치에 chain_gap으로 기록됩니다. Hub는 이를 gap으로 분류하고, 해당 레코드는 같은 멱등성 키로 이후 배치에 실려 도착하므로 로컬 원장 내보내기 파일에는 두 번 나타납니다. 절반으로 줄인 크기는 마지막 413 이후 5분 동안 유지되고, 그다음 전체 크기를 다시 시도합니다.
  • 경합 상황에서 인메모리 토큰 버킷의 리필 크레딧이 유지되도록 수정했습니다 (#2531). 리필 구간 확보와 토큰 수 갱신이 실패를 무시하는 별도의 CAS 두 단계로 이루어져 경합에서 진 쪽이 구간의 크레딧을 잃을 수 있었고, 거부된 요청은 관측한 크레딧을 버킷에 반영하지 않았습니다. 그 결과 동시 부하에서 클라이언트별, 키별, 사용자별, 모델별 제한이 설정한 requests_per_second보다 낮게 적용될 수 있었습니다. 이제 구간을 배타적으로 확보하고, 거부된 경우를 포함해 그 크레딧을 항상 버킷에 기록합니다.
  • 인메모리 rate limiter의 유휴 버킷 제거가 리필과 같은 모노토닉 클럭을 쓰도록 수정했습니다 (#2422). 클라이언트별, 키별, 사용자별, 모델별 버킷은 마지막 접근 시각을 벽시계로 기록했기 때문에, 시계가 앞으로 이동하면(VM 일시정지 복귀, NTP 보정) 사용 중인 버킷이 제거되어 쿼터가 초기화되었고, 뒤로 이동하면 유휴 버킷이 10분 유휴 TTL을 넘겨서도 남아 있었습니다. 이제 마지막 접근 시각을 프로세스 시작 기준 모노토닉 마이크로초로 측정합니다.
  • 접두사를 잘못 붙인 Anthropic 요청의 404 응답에 실제로 마운트된 Anthropic Messages 경로를 표시합니다 (#2398). 베이스 URL을 <root>/v1로 지정한 SDK가 보내는 /v1/v1/messages처럼 /messages나 /messages/count_tokens로 끝나는 라우팅되지 않은 경로에 POST를 보내면, API가 /v1/messages와 /anthropic/v1/messages(또는 각각의 count_tokens 경로)에서 제공되며 클라이언트 베이스 URL을 서버 루트나 <root>/anthropic으로 지정해야 한다는 메시지와 함께 404를 반환합니다. 그 밖의 일치하지 않는 요청은 기존 메시지를 그대로 받습니다.
  • 재시도 가능한 오류 응답에 x-should-retry: true를 붙이고 /anthropic 변환 경로에서 업스트림 재시도 헤더를 전달하도록 수정했습니다 (#2341). 라우터가 만드는 상태 코드 408, 409, 429, 5xx(529 포함) 오류는 이제 /anthropic/v1/messages와 /v1/*에서 x-should-retry: true를 담습니다. 그 밖의 상태 코드에는 힌트를 붙이지 않고, direct-mode shim 거부 응답의 헤더 구성은 그대로입니다. /anthropic/v1/messages가 OpenAI 형식이나 Responses 형식 백엔드로 요청을 변환할 때와 웹 검색 에뮬레이션, count_tokens에서도 백엔드 오류가 네이티브 경로처럼 백엔드의 retry-after, x-should-retry, request-id, anthropic-ratelimit-unified-* 헤더를 전달합니다. 업스트림 x-should-retry는 라우터 값에 두 번째 값을 더하지 않고 라우터 값을 대체합니다.
  • /anthropic/* 경로에서 동시성 상한의 503과 라우터 홉 가드의 400, 508을 OpenAI 형식 대신 Anthropic 오류 형식(overloaded_error, invalid_request_error, api_error)으로 반환하고, 전역 및 클라이언트 단위 속도 제한기, 동시성 상한, 홉 가드가 거부한 응답에도 x-continuum-request-id, x-request-id, (Anthropic 경로에서는) request-id 헤더를 붙였습니다. 이제 이 거부 응답도 ID로 지목할 수 있고, 그 ID는 event=request_rejected 로그 줄과 일치합니다(#2511). OpenAI 경로의 응답 본문은 그대로이며, API 경로 안에서 처리된 응답은 하나의 ID만 유지합니다.
  • 레거시 /v1/completions 요청의 클라이언트 요청 원장을 기록하도록 수정했습니다: stream 플래그와 prompt 텍스트 개수(문자열, 또는 배열의 각 문자열)를 기록합니다. chat, Responses, Anthropic ingress는 이미 기록하고 있던 값입니다 (#2444). /v1/completions 요청은 stream = false와 입력 개수 없이 기록되어, 스트리밍 레거시 completion이 입력 텍스트 없는 버퍼드 요청으로 계량되었습니다.
  • http_request_duration_seconds 버킷에 30, 60, 120, 300, 600, 1200초를 추가해 10초 이후 구간까지 확장했습니다 (#2260, PR #2469). 10초보다 오래 걸리는 모델 호출이 더 이상 +Inf에만 쌓이지 않으므로, p95와 p99를 계산할 수 있습니다. 기존 9개 버킷은 그대로이므로, 기존 대시보드와 le 셀렉터는 계속 동작합니다.
  • 라우터가 백엔드 선택 전에 거부한 모든 요청을 API 키와 단계별로 집계하고, 그 건수를 request_rejected_total{stage, key_id}로 내보내도록 수정했습니다 (#2256). 분당 한도 때문에 event=request_rejected 로그 줄이 억제된 거부도 집계에 포함됩니다. 키 ID는 최대 256개까지 개별 추적합니다. 그 이후의 키는 key_id="other"를, 식별된 키가 없는 거부는 key_id="unattributed"를, 사용할 수 없는 키 ID는 key_id="invalid"를 함께 씁니다. Hub 프로토콜에 거부된 요청을 담을 자리가 없으므로 Hub 사용량 원장은 바뀌지 않습니다.
  • 호출자가 요청할 수 있는 모델만 목록에 표시합니다 (#2263, PR #2475). 헬스 체커가 모든 백엔드를 비정상으로 판정한 모델이 /v1/models, /v1/models/extended, /v1/models/refresh, /anthropic/v1/models에 남아 있어, 클라이언트가 이를 골라 호출하면 실패했습니다. 이제 이런 모델은 활성화된 폴백 체인이 구성된 경우가 아니면 목록에서 빠집니다(폴백 체인이 있으면 #1422의 장애 시 유지로 계속 도달할 수 있습니다). /v1/models/{model}은 계속 available: false와 함께 200을 반환합니다. 또한 설정이나 검색 대체 목록(내장 Anthropic 목록, Codex 설정 목록, Bedrock 및 OAuth 정적 목록)으로 채운 카탈로그 새로고침은 더 이상 백엔드를 정상으로 표시하지 않으며, 실제 응답을 받은 경우에만 정상으로 표시합니다.
  • 프로세스 로그 폴백이 logging 기본값을 따르도록 했습니다. 설정 파일이 없거나 로드에 실패하면 라우터는 이제 한 줄 텍스트 대신 logging 섹션이 없는 설정과 같은 info 레벨의 JSON 줄로 로그를 남깁니다 (#2259). 두 기본값은 하나의 상수에서 나오며, 로드에 실패한 설정 파일(또는 존재하지 않는 --config 파일)은 라우터가 stderr에 로드 오류를 알리고 종료하기 전에 프로세스 로그에 기본값을 사용한다는 WARN 로그를 한 줄 남깁니다.
  • /anthropic/* 경로의 rate limit 거부와 Hub 정책 거부를 OpenAI 형식 대신 Anthropic 오류 형식({"type":"error","error":{"type":"rate_limit_error","message":...}})으로 반환해, Anthropic 클라이언트가 키의 429 를 rate limit 으로 인식합니다 (#2255). 로컬 rate limiter 와 Hub 의 tier, 예산, cost-center, 키 폐기, 모델 허용 목록, unmatched_key: reject 거부가 모두 해당합니다. OpenAI 경로, 상태 코드, retry-after 와 rate limit 헤더는 바뀌지 않습니다.
  • event=request_rejected 로그 한도 때문에 줄이 억제되는 동안 어떤 키가 거부되었는지 알 수 있도록 수정했습니다 (#2257). 한도에 도달한 60초 창이 끝나면, 라우터는 phase="window_closed"인 event=request_rejected_summary 줄 하나를 남깁니다. 이 줄에는 창의 suppressed 합계, 억제 건수 상위 20개 키를 담은 suppressed_by_key, 나머지를 담은 unlisted_keys와 unlisted_suppressed, 그리고 suppressed_by_stage가 들어 있습니다. 요약은 창이 끝난 뒤 들어온 다음 요청에서 남깁니다. 처음 억제될 때 남기는 알림 줄은 이제 phase="cap_reached"를 담으며, 항상 1이던 suppressed 값은 더 이상 기록하지 않습니다.
  • 4xx 오류에서 백엔드의 원래 메시지를 유지하도록 수정했습니다 (#2253). 이전에는 /anthropic/v1/messages가 Anthropic 형식이 아닌 오류 본문을 Invalid request sent to backend 같은 일반 메시지로 바꿨기 때문에 컨텍스트 길이 초과와 잘못된 파라미터를 구분할 수 없었습니다. 이제 OpenAI, Gemini, FastAPI detail, 최상위 message, 일반 텍스트 본문에서 백엔드 메시지를 꺼내 반환하며, 백엔드의 400은 api_error가 아닌 invalid_request_error로 매핑합니다 (413은 request_too_large, 529는 overloaded_error). Anthropic 및 Gemini 백엔드로 가는 Responses API 스트리밍 경로와 Responses 전용 브리지도 4xx에서 백엔드 메시지를 유지합니다. 이 경로들과 이미 메시지를 전달하던 OpenAI 인그레스에서 전달하는 모든 메시지는 절대 URL, URL에 포함된 자격 증명, Bearer 토큰과 공급자 API 키, key=value 형태의 비밀 값, IP 주소, host:port 엔드포인트를 자리 표시자로 바꾸고, 제어 문자를 제거하며, 500바이트로 제한합니다. 5xx 응답은 계속 일반 메시지를 반환하며, 운영자 로그에는 백엔드 본문 전체가 그대로 기록됩니다.
  • 백엔드 오류 본문을 그대로 전달하지 않고, 업스트림 오류 본문 읽기에 모두 상한을 두었으며, Unix 소켓 청크 디코더를 강화했습니다 (#2512). /v1/responses 패스스루, /v1/responses/compact, Responses 전용 브리지, Anthropic count_tokens, /v1/audio/transcriptions와 /v1/audio/translations, OpenAI /v1/images/edits와 /v1/images/variations, Nano Banana 이미지 경로, 네이티브 Gemini 멀티모달 임베딩 경로는 백엔드 오류 본문을 그대로 전달하거나 메시지에 넣었고, 일부는 5xx에서도 그랬습니다. 이제 이 경로들은 정리된 4xx 메시지(있으면 백엔드의 type과 code 포함)나 상태별 일반 메시지를 반환하므로, 백엔드 URL, 내부 호스트, 인라인 키가 클라이언트에 노출되지 않습니다. 모든 인그레스, 스트리밍, 이미지, 오디오, Files API, 백엔드 경로의 업스트림 오류 본문은 전체가 아니라 최대 64 KiB까지만 읽고, 최대 2000바이트로 제한하고 이스케이프한 필드로 로그에 남깁니다. 따라서 위의 #2253 항목과 달리 운영 로그에 백엔드 본문 전체가 기록되지는 않습니다. /anthropic/v1/messages의 Unix 소켓 스트리밍 분기는 이제 백엔드 오류 본문을 읽으므로, 소켓에서도 HTTP와 마찬가지로 4xx에 백엔드 메시지가 담깁니다. 모든 Unix 소켓 오류 본문 읽기는 Content-Length에서 멈추고(청크 본문은 제외) 5초가 지나면 중단하므로, keep-alive 엔진이 요청을 붙잡아 둘 수 없습니다. 청크 전송 인코딩 디코더는 더 이상 백엔드가 선언한 청크 크기만큼 메모리를 할당하지 않습니다. 16 MiB를 넘는 크기나 1 KiB를 넘는 크기 줄은 스트림을 실패로 끝내며, 이전에는 FFFFFFFFFFFFFFFF가 프로세스를 중단시키고 40000000이 1 GiB를 할당했습니다.
  • 지속 메트릭 로그가 스냅샷 틱마다 시리즈당 한 행을 저장하던 것을 시리즈당 값 변경마다 한 행을 저장하도록 바꿨습니다 (#2290). 모든 스냅샷이 전체 레지스트리를 다시 삽입했기 때문에 유휴 라우터도 트래픽과 무관하게 series × (86_400 / snapshot_interval_seconds)행씩 매일 늘었고, 이제 스냅샷은 마지막으로 저장된 값과 다른 샘플만 저장하며, 재시작 후 첫 스냅샷이 기준선을 저장합니다. 값이 바뀌지 않은 시리즈도 한 시간에 한 번 다시 저장하므로, 보존 기간 정리로 유휴 시리즈의 유일한 행이 지워지지 않습니다. GET /admin/metrics/history는 이 변경 지점과 1시간 주기 행을 반환하며, 사용하는 쪽은 행 사이 구간에 마지막 값을 이어서 사용합니다. 삽입에 실패한 변경은 버리지 않고 다음 틱에서 다시 저장합니다.
  • thinking 패턴 릴레이와 Unix 소켓 챗, thinking 패턴, Anthropic 릴레이에 timeouts.request.streaming.chunk_interval을 적용했습니다 (#2513). 이 릴레이들은 청크 사이 간격을 고정된 60초 구간으로 제한했기 때문에, chunk_interval을 낮춰도 멈춘 스트림이 60초 동안 기다렸고, 모델별 chunk_interval을 더 길게 준 모델은 60초에서 끊겼습니다. 이제 일반 챗 릴레이와 같은 방식으로 모델별 값을 결정하며, 멈춘 스트림은 inactivity 대신 {"timeout":"chunk_interval","timeout_ms":<값>}으로 보고합니다.
  • 일반 챗, thinking 패턴, Unix 소켓 챗, thinking 패턴, Anthropic 릴레이에 timeouts.request.streaming.first_byte를 적용하고, HTTP Anthropic 백엔드의 응답을 OpenAI 호환 클라이언트에 전달하는 릴레이에는 first_byte와 chunk_interval을 모두 적용했습니다 (#2656). 앞의 다섯 릴레이는 첫 청크까지의 대기를 chunk_interval로 제한하고 chunk_interval로 보고했습니다. HTTP Anthropic 릴레이에는 읽기 제한 시간이 없어서, 업스트림이 멈추면 요청 전체 타임아웃(streaming.total)까지 기다린 뒤 일반 스트림 오류로 끝났습니다. 이제 여섯 릴레이 모두 첫 청크는 모델별로 결정된 first_byte까지, 그 뒤의 각 간격은 chunk_interval까지 기다립니다. 응답이 없는 업스트림은 {"timeout":"first_byte","timeout_ms":<값>}로 보고하고 사용량 결과 timeout_first_byte로 기록합니다. 미드스트림 폴백 릴레이와 Bedrock, Gemini 파이프라인과 같은 방식입니다. 기본값에서는 일반 챗, thinking 패턴, Unix 소켓 릴레이의 첫 청크 대기가 30초(chunk_interval)에서 120초(first_byte)로 늘어나므로, 이전 한도를 유지하려면 first_byte를 낮추세요.
  • usage WAL 전송 경로에 배압을 적용했습니다 (#2367). 줄 채널이 가득 차면 start 또는 end 줄을 바로 버리지 않고, writer가 드레인할 때까지 최대 250 ms(WAL_SEND_TIMEOUT)를 기다립니다. 따라서 한 번의 드레인을 앞지르는 버스트에서도 줄이 유지되고, router_restart 안전망이 해당 시도를 잃지 않습니다. 대기 시간을 넘긴 전송은 로그를 포화 상태로 표시하며, 이후 전송이 다시 빈 자리를 찾을 때까지 줄을 즉시 버리고 usage_records_dropped_total{reason="wal_dropped"}로 셉니다. 그래서 상태 디스크가 멈춰도 요청 처리는 최대 한 번의 대기만큼만 지연됩니다. 멀티스레드 런타임에서는 대기가 block_in_place 안에서 실행되어 다른 태스크가 계속 실행됩니다. writer가 종료된 경우에는 여전히 즉시 버립니다.
  • 후보 백엔드 변환이 로그 확률을 전달할 수 없는 경우, 로그 확률을 요청하는(logprobs: true, 레거시 정수 logprobs, null이 아닌 top_logprobs) chat completions/completions 요청을 400 unsupported_request_parameter로 거부합니다 (#2364). Anthropic Messages와 Bedrock Converse 변환, responses_only 모델용 Chat-to-Responses 브리지는 이 필드들을 조용히 삭제했고, 그쪽으로 라우팅(또는 폴백)된 요청은 로그 확률이 전혀 없는 choices와 함께 200으로 응답했습니다. OpenAI 호환 백엔드는 필드를 변경 없이 그대로 전달하며, 두 필드가 모두 있을 때는 거부 응답이 활성화 필드인 logprobs를 지목합니다.
  • 모델의 선언된 한계를 초과하는 출력 예산의 채팅 완료를 디스패치 전에 거부하도록 수정했습니다 (#2365). 라우팅된 모델에 대해 메타데이터 계층(model-metadata.yaml, model-metadata.d/ 드롭인, 메타데이터 다운로드)이 선언한 limits.max_output 또는 limits.context_window를 초과하는 max_tokens나 max_completion_tokens는 지금까지 수용되어 클라이언트가 타임아웃할 때까지 바이트도 오류도 없이 멈출 수 있었고, 백엔드는 클라이언트가 이미 버린 요청에 대해 계속 생성하거나 과금했습니다. 이제 라우터는 클라이언트가 보낸 필드, 요청값, 해당 한계를 이름으로 밝히는 OpenAI 형식의 400을 반환합니다(예: max_tokens is too large: 200000. This model supports at most 128000 output tokens.). 한계는 서빙 백엔드별로 결정합니다. 백엔드 자체의 model_configs 항목이 공유 메타데이터 계층보다 우선하며, 모든 후보 백엔드가 선언한 한계를 초과할 때만 요청을 거부합니다(메시지에는 그중 가장 큰 한계를 표시합니다). 어느 후보에도 선언된 한계가 없는 모델과, 윈도우나 출력 상한이 0인 경우(이미지 모델의 "적용 없음" 표시)는 그대로 전달하며, 공급자가 최종 판단을 맡습니다. 버퍼링 요청과 스트리밍 요청을 똑같이 검사합니다.

Removed

  • src/services/backend_service.rs의 중복 타입 BackendStatistics와 LoadBalanceReport를 제거했습니다 (#2337). 어느 쪽도 임포트하는 곳이 없었습니다. 모든 BackendStatistics 사용자는 core::traits::BackendStatistics를 읽습니다. LoadBalanceReport와 그 모듈은 자체 테스트 밖에서 참조되지 않았습니다.

Compatibility

  • 2956 이후의 main 빌드는 관측 설정에 fallback.fallback_policy.trigger_conditions.context_window_exceeded를 보고합니다. continuum-hub #2038 이전의 Hub는 알 수 없는 경로 때문에 하트비트 전체를 거부하고, 해당 레지스트리를 포함한 Hub는 v2.0.0 미만 라우터에 이 리프를 전달하지 않으므로, 그런 라우터의 폴백 섹션은 desired 다이제스트와 일치하지 않습니다. 따라서 이 복원은 해당 Hub 릴리스와 함께 v2.0.0에서만 배포합니다. 이 변경이 들어간 뒤에 main에서 v1.36.x 릴리스를 만들면, 이전 Hub에서는 하트비트가 끊기고 새 Hub에서는 폴백 섹션이 영구 드리프트로 남습니다.

  • 2877 이후의 main 빌드에서 file_search.vector_store_ids나 이름을 지정한 code_interpreter.container를 전달하던 배포는 운영자가 responses_tools.deny_resource_fields: []를 설정할 때까지 400 unsupported_request_parameter를 받습니다 (#2891). 릴리스에서 업그레이드하는 클라이언트에도 영향이 있습니다. 릴리스된 버전은 이 필드를 버리고 정상 응답했지만, 이제 같은 요청은 모든 백엔드 유형에서 400을 받습니다. 대신 이 업그레이드로 노출되는 리소스는 없습니다.

  • 2850 이후의 main 빌드에서 mcp, image_generation 같은 모델링되지 않은 도구 유형을 전달하던 배포는 해당 유형을 responses_tools.allow에 추가할 때까지 400 unsupported_request_parameter를 받습니다 (#2873). 릴리스된 버전은 이런 유형에 422로 응답했으므로, 릴리스에서 업그레이드할 때 기본으로 새로 열리는 경로는 없습니다.

  • 각 http_request_duration_seconds 시계열이 내보내는 _bucket 샘플이 6개 늘어납니다 (+Inf를 포함해 10개에서 16개). 따라서 _sum과 _count를 포함하면 레이블 조합마다 저장되는 시계열이 12개에서 18개로 늘어납니다 (#2260, PR #2469).
  • 설정 파일 없이 시작한 라우터는 이제 프로세스 로그를 한 줄 텍스트 대신 JSON 줄로 남깁니다 (#2259). 텍스트 출력을 유지하려면 logging.format: pretty를 지정한 설정 파일로 라우터를 시작하십시오.

v1.36.1 - 2026-10-27

Added

  • stable-diffusion.cpp sd-server용 선택적 백엔드 설정 request_extensions.sd_cpp_image_parameters를 추가했습니다 (#2233, PR #2247). /v1/images/generations에서 라우터는 negative_prompt, seed, clip_skip, steps, sampler, scheduler, cfg_scale을 prompt 안의 <sd_cpp_extra_args> JSON 블록으로 변환합니다. 기존의 유효한 블록은 요청 필드를 우선해 병합하고, 생성한 JSON의 <를 이스케이프하며, 재시도와 폴백 홉마다 원본 본문에서 다시 구성하므로 태그가 붙은 프롬프트가 일반 백엔드로 전달되지 않습니다.

Changed

  • 호환성 변경(보안): admin.auth가 없으면 실패 시 차단하고, method가 없는 인증 블록은 공유 저장소 api_key를 기본값으로 사용하며, 인증하지 않는 개발 접근에는 method: none을 명시하도록 변경합니다 (#2234, PR #2242). admin.auth는 이제 시작 시 소유됩니다. Admin 섹션 기록과 전체 문서 가져오기, 적용, 롤백은 변경을 거부하고, 내보내기는 언제나 이를 생략하며 기존 include_sensitive: true 요청을 감사 로그에 기록한 뒤 무시합니다. 임시 프로브와 전체 설정 기록 경로를 포함해 Admin이 새로 작성하거나 바꾼 백엔드, 엔진 메트릭 및 외부 요청 OAuth 목적지는 검증 시점에 사설, 루프백, 링크 로컬, 그 밖의 비공개 주소이거나 Unix 소켓이면 거부합니다. 헬스 오버라이드는 같은 원점을 유지해야 하고 상태 목록 크기를 제한합니다. 신뢰할 수 있는 시작 파일은 로컬 또는 Unix 백엔드를 계속 선언할 수 있으며, 관련 없는 Admin 편집은 바뀌지 않은 사설 목적지를 보존합니다. URL 검사는 Tokio 작업자 밖에서 호스트 이름을 해석하고 검증 시점에 반환된 주소를 필터링합니다. 이후 DNS 응답 변경까지 막아야 하는 배포에서는 네트워크 계층의 egress 제한도 적용해야 합니다. Admin 자격 증명 마스킹은 유니코드에 안전합니다.
  • 선택적 관측 및 Dynamo 이벤트 표면을 강화합니다 (#2236, PR #2241). 설정된 /metrics 엔드포인트는 metrics.auth가 없으면 이제 401을 반환합니다. 익명 Prometheus 스크레이프를 의도적으로 허용하는 배포는 metrics.auth.method: none을 지정해야 하며, auth: {}는 Bearer 인증을 선택하므로 bearer_token이 없으면 검증에 실패합니다. Bearer 및 Basic 자격 증명 비교에는 상수 시간 비교를 사용합니다. Dynamo 직접 모드 구독은 dynamo.load.direct_allow_cidrs를 받습니다. 이 목록을 설정하면 라우터는 플릿이 광고한 발행자를 한 번만 해석하고 설정된 워커 네트워크 안의 주소에만 연결합니다. 목록을 생략하면 기존 클러스터 내부 동작을 유지하며 config validate는 목록 추가를 제안합니다.

Fixed

  • 네이티브 /anthropic/v1/messages 요청이 이 필드를 거부하는 Anthropic 모델이나 API 버전으로 향하는 홉에서 클라이언트의 temperature, top_p, top_k를 제거하도록 수정했습니다. 버퍼링과 스트리밍 HTTP 및 Unix 소켓 요청에 모두 적용됩니다 (#2245, PR #2246). 클라이언트의 anthropic-version이 백엔드 버전보다 우선합니다. 다른 공급자는 클라이언트 값을 유지하고 Bedrock 동작은 바뀌지 않으며, 이후 폴백 홉을 위해 원래 값을 보존합니다. 이 필드에 대한 정책 기본값과 오버라이드는 계속 실패 시 차단하며, 정책이 쓴 값을 버리거나 기본 홉의 제한에서 숨겨진 값을 전달하게 되는 폴백 홉은 거부합니다.
  • Admin 조회, 내보내기, 이력 스냅샷, CLI 및 MCP 비식별화에서 health_check.headers와 tracing.otlp.headers의 모든 값을 헤더 이름은 유지한 채 마스킹하고, 기록 시 마스킹된 값을 헤더 이름 기준으로 복원하며, 디버그 출력에서 헬스 체크 헤더 값을 가리도록 수정했습니다 (#2235, PR #2239).
  • AppProxy 시작 로그와 설정 디버그 출력에서 Redis URL 자격 증명을 마스킹하고, 공유 코디네이터 클라이언트가 루프백 밖에서 평문 HTTP를 사용하면 두 워커 모드 모두 경고하도록 수정했습니다 (#2237, PR #2238).
  • make clean이 자체 target/ 디렉터리를 가진 crates/ 아래의 독립 크레이트와 perf/ 하네스도 정리하도록 수정했습니다.

Compatibility

  • admin.auth가 없는 배포는 이제 /admin/*에서 401을 받습니다. 업그레이드 전에 인증 방식을 설정하거나, 격리된 개발 환경에서만 method: none을 명시하십시오 (#2234).
  • metrics.auth 없이 /metrics를 수집하는 배포는 이제 401을 받습니다. 익명 수집을 유지하려면 metrics.auth.method: none을 설정하고, 그렇지 않으면 Bearer 또는 Basic 자격 증명을 설정하십시오 (#2236).
  • Admin API는 새로 작성하거나 변경한 사설, 루프백, 링크 로컬 또는 Unix 소켓 백엔드 목적지를 더 이상 받지 않습니다. 의도한 로컬 백엔드는 시작 설정 파일로 옮기십시오 (#2234).

v1.36.0 - 2026-10-27

Added

  • 백엔드별 anthropic_version으로 2026-10-21 API 버전을 선택할 수 있도록 추가했습니다. Haiku 5.5 호환성과 모델 메타데이터도 갱신했습니다 (PR #2187, #2191, #2205).
  • 여러 Router 인스턴스가 Hub 티어의 분당 제한을 공유하는 기능을 추가했습니다 (PR #2193).
  • 키별 Anthropic 사용자 프로필의 WebUI 설정과 Hub 동기화를 추가했습니다 (PR #2203, #2221). 두 인증 모드 모두 로컬 키와 같은 사용자 귀속 규칙을 적용합니다.

Fixed

  • 할당된 비용 센터의 요청, 토큰 또는 판매 금액 한도가 소진되면 제공자 배치 제출을 거부하도록 수정했습니다. 완료 시 집계와 같은 할당 기준을 사용하며 사용량은 완료 전에 차감하지 않습니다 (#2240, PR #2243).
  • 키별 허용량과 전역 용량에 따라 제공자 배치 입력 검사를 제한했습니다. 요청이 실패하거나 취소되면 예약을 해제하도록 수정했습니다 (#2240, PR #2243).
  • Anthropic 요청의 모델 접근 권한과 제공자 배치의 모델·백엔드 접근 권한을 적용했습니다 (PR #2200, #2202).
  • 호출자가 Router가 관리하는 Anthropic 사용자 프로필 귀속을 덮어쓰지 못하도록 수정했습니다 (PR #2189, #2204).
  • 외부 WebSocket 연결에서 운영체제 CA를 다시 신뢰하도록 수정했습니다 (PR #2201).
  • 모델별 include_usage를 적용하고 어시스턴트 추론을 Dynamo로 전달하도록 수정했습니다. 스트리밍 오류 본문은 문자 경계에서 자르도록 수정했습니다 (PR #2211).
  • Dynamo의 유휴 워커 처리, 동일 비용 순환 선택, 통합 워커 점수, 구독 재시작 시 부하 상태와 prefill/decode 워커 조합을 수정했습니다 (PR #2218, #2219, #2225, #2232).
  • 속도 제한 식별자 마스킹의 문자 경계를 수정하고 유휴 로그 쓰기 회귀 테스트를 동기화했습니다 (PR #2215, #2207).

Changed

  • 소스 빌드에 필요한 최소 Rust 버전을 1.97로 올렸습니다. 벤더링한 Hub 프로토콜의 최소 버전도 맞췄습니다 (PR #2199, #2210).
  • Rust와 WebUI 의존성을 갱신했습니다. RUSTSEC-2026-0385를 해결한 ctutils 수정도 포함했습니다 (PR #2223, #2230, #2231).

CI

  • Windows 서명 작업의 오래된 디렉터리를 정리하고 Redis 공유 상태 테스트 공간을 확보했습니다. 테스트 워크플로를 전용 실행기 풀로 옮기고 초안 PR의 워크플로 실행을 생략했습니다 (PR #2190, #2214, #2226, #2227).
  • astral-sh/setup-uv를 10.3.2로 갱신했습니다 (PR #2229).

Compatibility

  • 이전 Router 릴리스는 Anthropic 사용자 프로필이 포함된 키 테이블 파일을 복원하지 못합니다. Hub 프로필 동기화를 활성화하기 전에 다운그레이드 복구 방법을 준비하세요 (PR #2221).
  • 여러 인스턴스의 비용 센터 제한은 비동기로 적용됩니다. 다음 예산 갱신 전까지 동시 요청으로 한도를 초과할 수 있습니다.

v1.35.1 - 2026-10-22

Fixed

  • llama.cpp llama-server와 mlxcel-server의 모델이 다시 목록에 나타납니다 (#2181, PR #2182). 두 서버는 GET /v1/models에 Ollama 형식 models 배열과 OpenAI data 배열을 함께 담아 응답하는데, v1.35.0은 이런 본문을 모두 data 필드 중복으로 거부해 집계된 /v1/models에서 해당 모델이 사라졌습니다. 이제 모델 탐색은 data가 있으면 항상 data를 읽고 models는 건너뜁니다. System One TypeSafe models[].name 카탈로그는 data가 없을 때만 읽습니다.
  • 사용 중단된 timeouts.health_check 블록을 쓰지 않았을 때 충돌 경고를 내지 않습니다 (#2179, PR #2180). 블록을 생략해도 30초/5초 기본값으로 로드됐기 때문에, health_checks에 기본값이 아닌 간격이나 타임아웃을 쓰면 잘못된 충돌 경고가 기록됐습니다. 이제 블록은 설정했을 때만 존재하고 직렬화된 설정에서도 빠집니다. 블록을 직접 쓰면 검증과 충돌 경고는 그대로 동작합니다.

CI

  • v1.35.0에서 실패한 Windows x86_64 릴리스 빌드를 고쳤습니다 (PR #2178). ARM64 서명 잡이 x86_64 빌드와 함께 쓰는 워크스페이스 루트에 희소 체크아웃을 남겼습니다. 이제 서명 스크립트를 signing-checkout/에 체크아웃하고, scripts/check_release_signing.py는 전용 경로 없이 희소 체크아웃하는 서명 잡을 거부합니다.
  • protoc 설치를 지원 중단 예정인 node20 런타임을 쓰는 arduino/setup-protoc 대신 고정된 36.2 릴리스를 내려받아 캐시하는 로컬 복합 액션(.github/actions/setup-protoc)으로 바꿨습니다 (PR #2177).

v1.35.0 - 2026-10-21

Added

  • 전용 기능을 위한 Gemini Native API 자동 라우팅과 양방향 변환을 추가합니다 (#85, #163, PR #2161, #2162). /v1/chat/completions, /v1/responses, /v1/images/generations, /v1/images/edits에서 type: gemini 백엔드로 라우팅할 때 Google Search 그라운딩(google_search 또는 web_search 도구, web_search_options, 이미지 엔드포인트의 google_search: true), Python 코드 실행(code_execution 또는 code_interpreter 도구), 이미지 출력 모달리티(modalities: ["text", "image"], image_config, Gemini 이미지 생성 모델), 멀티턴 어시스턴트 images 이력, extra_body.google 네이티브 옵션(grounding, response_modalities, safety_settings, media_resolution, cached_content), 또는 gemini_context_cache.prefer_native_api: true를 사용하는 요청은 x-goog-api-key(또는 OAuth / 서비스 계정 Bearer 인증)를 사용해 Gemini 네이티브 /v1beta/models/{model}:generateContent 및 :streamGenerateContent?alt=sse 엔드포인트로 자동 전달됩니다. 응답은 사고 과정을 reasoning_content로, executableCode와 codeExecutionResult를 code_execution으로, 그라운딩 링크를 annotations(http(s) 스킴만 허용하며 최대 128개 제한인 url_citation)와 grounding_metadata로, 생성된 이미지를 choices[].message.images로 정규화하고 도구 호출과 어시스턴트 턴의 왕복 thoughtSignature 메타데이터를 보존합니다. nano-banana-2(gemini-3.1-flash-image, 최대 4K)와 nano-banana-2-lite(gemini-3.1-flash-lite-image, 1K) 별칭을 추가하고 원격 이미지 URL을 인라인으로 가져오기 전에 SSRF 검증(UrlValidator::strict_without_dns)을 수행합니다.
  • model-metadata.yaml과 모델 탐색에 System One 의사결정 모델을 등록합니다 (#2163, PR #2166). TypeSafe jev-1.13.0(jev-latest) 및 jev-1.14.0(jev-preview), Azure AI Foundry microsoft-decision-1, laya, kev-0.8b, kev-4b, julia-1, lev, openjev, clef, pplx-decider-v1.2, bespoke-nimble-9b-v3, decision-2.0-{eos-0.8b,kai-0.6b,lux-9b,nox-4b,sol-2b,vega-27b}, d3 및 d3-{edge,lite,nano,mini,flash}, Liquid AI d1-3b 및 d1-omni-600m, 그리고 양쪽 기능을 모두 지원하는 granite-switch-4.1-{8b,30b}의 카탈로그 항목과 별칭을 추가합니다. 모델 탐색은 TypeSafe GET /v1/models 객체와 llama.cpp architecture.output_modalities를 파싱해 모델 메서드에 decisions를 표시하고 명시적 chat 기능이 있어야만 멀티모달 image나 audio 태그가 텍스트 생성을 뜻하도록 제한하며 Gemini 이미지 조회 경고 로그에서 URL 쿼리 문자열과 사용자 정보를 마스킹합니다.
  • System One 의사결정 대상을 위한 프로바이더 프로필, 멀티모달 미디어 지원, 토큰 계량을 추가합니다 (#2164, PR #2168). 공유 SystemOneTargetConfig(decisions.targets[], smart_routing.classifier.system_one, guardrails.providers[].systemone, safeguards.action_safety.systemone, fallback.quality_check.target, usage.quality_judgment.target)에 protocol(generic, typesafe, foundry, llama_cpp), auth(auto, bearer, api_key, entra), model_family(generic, openjev, lfm2_d1, lfm2_d1_omni, vllm_d3), typesafe_images, typesafe_long_context, max_questions(1..=256, 기본값 64), max_media_bytes(1..=16777216, 기본값 4 MiB)를 추가합니다. 비기본 프로필에서 구조화되거나 null이거나 생략된 질문 필드를 지원하고 jev-1.14.0(jev-preview)의 TypeSafe JSON assets와 box 위치 검출 질문, Azure AI Foundry /providers/microsoft/v1/systemone 엔드포인트 정규화와 TLS 및 자격 증명 검증, 지원되는 llama_cpp 계열의 인라인 files/images 데이터 URL과 state: null을 지원하며 무료 출력 단가($0)와 별개로 업스트림이 보고한 output_tokens를 사용량 레코드에 보존합니다.
  • 스마트 라우팅 분류기에 System One 프로바이더 프로필을 통합합니다 (#2165, PR #2169, #2170). smart_routing.classifier.system_one은 이제 프로필을 인식하는 System One 클라이언트를 통해 generic, typesafe, foundry, llama_cpp 대상으로 요청을 보내고 의사결정 전용 모델(chat 없는 decisions)을 채팅 후보 풀에서 제외하며(GET /admin/smart-routing/status의 excluded_incompatible_models로 보고) 분류기 캐시 키에 해석된 대상 프로필, 모델, 질문 세트, 백엔드 토폴로지 세대(cache_target_identity)를 반영합니다. 또한 잘못된 형식의 HTTP-2xx 의사결정 응답은 업스트림 오류로 기록하고 유효한 의사결정 응답에 confidence가 없어 규칙 또는 LLM 분류기로 폴백할 때도 정상 토큰 및 비용 계량을 보존합니다.
  • 출처 인식 모델 메타데이터 카탈로그 업데이트와 패키지된 model-metadata.d 벤더 조각을 추가합니다 (#2158, PR #2174). 메타데이터 문서는 이제 사용자 정보, 쿼리 문자열, 프래그먼트, 사설/루프백/링크 로컬 리터럴이 없는 https:// URL로 검증되는 최상위 선택 필드 source_url을 받습니다. continuum-router metadata download(별칭 metadata update)는 파일에 내장된 source_url에서 단일 파일을 갱신하거나 --all / --dir <DIR>로 model-metadata.d/ 안의 출처가 있는 모든 파일을 한꺼번에 갱신하면서 출처가 없는 운영자 파일은 보존하고 쓰기 전에 64개 파일 및 8 MiB 카탈로그 한도를 검사하며 심볼릭 링크 대상 쓰기를 거부합니다. 릴리스 아카이브, Debian 패키지, Docker 이미지에 model-metadata.yaml과 함께 model-metadata.d/ 아래 19개 벤더 및 개발사 카탈로그 조각(10-alibaba.yaml부터 28-zhipu-ai.yaml까지, Upstage Solar 모델 6종을 포함한 323개 모델)을 기본 탑재합니다.

Changed

  • Anthropic 기본 상태 검사 accept_status를 [200, 400, 401, 429]에서 [200, 400]으로 좁혀 인증 실패(401)와 속도 제한 응답(429)이 기본적으로 정상 상태로 판정되지 않게 합니다 (#2175, PR #2176). 이전 동작이 필요한 운영자는 backends[].health_check.accept_status를 명시하면 됩니다.
  • 영문과 국문 매뉴얼(docs/en/, docs/ko/) 전반을 간결한 기술 산문과 동일한 문서 구조로 정비하고 docs/manual_writing_rules.md 작성 규칙을 확립합니다 (PR #2167, #2172, #2173).

Fixed

  • 프로바이더 상태 검사 프로브 트래픽과 폴백 오버헤드를 제한합니다 (#2175, PR #2176). 기본 상태 검사 프로브가 매 주기 전체 모델 카탈로그를 내려받는 대신 작은 단일 모델이나 경량 엔드포인트(OpenAI는 /v1/models/gpt-4o-mini, Gemini는 /v1beta/models/gemini-2.5-flash, Anthropic은 /v1/models?limit=1, vLLM, SGLang, TensorRT-LLM, llama.cpp, mlxcel, Azure, generic, Continuum Router는 /health, Ollama는 /api/version)를 요청합니다. 카탈로그 폴백 프로브(/v1/models, /v1beta/models, /api/v1/models, /api/tags, /)는 기본 프로브가 HTTP 404 또는 405를 반환할 때만 실행되며 성공한 폴백 결과를 백엔드 및 검사 컨텍스트별로 5분 동안 캐시하고 지연 시간이나 임계값 카운터를 왜곡하지 않은 채 동시 폴백 프로브를 병합합니다. 또한 프록시 추론과 SSE 스트림에서는 원본 전송을 유지하면서 전용 프로브 및 모델 탐색 HTTP 클라이언트에는 자동 gzip 압축 해제를 켜고 Unix 소켓 상태 검사의 응답 헤더를 64 KiB로, 연결·요청 쓰기·헤더 읽기·본문 비우기 전체 시간을 설정된 타임아웃으로 제한합니다.

Security

  • Windows x64 및 ARM64 릴리스 바이너리에 서명하고 CI에서 실패 시 즉시 중단하는 서명 검증을 적용하며 TLS 루트 인증서를 업그레이드합니다 (#520, PR #2171). .github/workflows/release.yml이 이제 SSL.com KMS 기반 Authenticode(signtool.exe sign + signtool.exe verify /pa /tw)로 x86_64-pc-windows-msvc와 aarch64-pc-windows-msvc용 continuum-router.exe, continuum-kv-listener.exe, continuum-event-broker.exe에 서명하고 macOS Developer ID 서명, 공증, Homebrew OpenSSL 탐색을 강화하며 CI에서 scripts/check_release_signing.py로 릴리스 워크플로를 검증합니다. 또한 rustls-webpki를 0.103.16으로 올리고 아웃바운드 WebSocket TLS(tokio-tungstenite)를 명시적 ring 암호화 프로바이더와 번들 Mozilla 루트 인증서(webpki-roots 1.0.10)로 전환해 취약한 webpki-roots 0.26.11을 의존성 그래프에서 제거합니다.

v1.34.0 - 2026-10-20

Added

  • Files API 작업별 속도 제한 (#114, PR #2143). 새 선택 블록 files.rate_limiting(enabled, 기본값 false, uploads_per_minute, 기본값 10, downloads_per_minute, 기본값 100, operations_per_minute, 기본값 1000, 활성화 시 0보다 커야 함)은 files_upload(POST /v1/files), files_download(GET /v1/files/{id}/content), files_operations(GET /v1/files, GET /v1/files/{id}, HEAD /v1/files/{id}, DELETE /v1/files/{id})에 호출자별 토큰 버킷을 적용합니다. 호출자는 SHA-256 해시된 API 키, 인증된 사용자 ID, 또는 클라이언트 IP로 구분하며, 한도를 넘은 요청에는 Retry-After와 x-ratelimit-* 헤더가 붙은 429 Too Many Requests로 응답합니다.
  • 내장 모델 메타데이터 카탈로그(model-metadata.yaml)에 2026년 9월 말 및 10월 출시 모델을 추가합니다 (PR #2160): Google gemini-4-argon, Meta muse-wonder-1.0 및 muse-spark-open-816b-a42b, Mistral mistral-large-4, Upstage solar-pro-4 및 solar-mini-4, NVIDIA nemotron-3.5-super / nemotron-3.5-ultra / nemotron-labs-dory1.0-4b, Liquid AI lfm2.5-* 및 d1-*, Jina AI jina-ocr-v1, DeepSeek deepseek-v4.1-flash-1017, Alibaba qwen3.8-* 및 qwen-image-2.1-turbo, Tencent hy-memory-30b-a3b, Meituan longcat-*, Ant Group ling-3.*, Shanghai AI Lab atria-dawn-preview 및 intern-s2-*, StepFun step-5-preview.

Changed

  • 요청 중복 제거기가 진행 중인 결정론적 요청만 병합합니다 (#2157, PR #2156, #2159). 요청 중복 제거는 이제 리더 요청이 진행 중인 동안에만 동일한 동시 요청을 병합하며, 완료 뒤 30초 동안 성공 및 영구 오류를 저장하던 캐시를 제거합니다(완료된 응답의 재사용은 response_cache가 담당합니다). 채팅 및 레거시 완성(/v1/chat/completions와 /v1/completions)은 요청에 숫자 temperature: 0(0 또는 0.0)이 명시된 경우에만 병합 대상이 되며, /v1/images/generations는 병합하지 않습니다. 진행 중인 리더에 합류한 대기자 요청은 로컬 캐시 히트(backend: not_dispatched, cache_hit_type: exact, 리더의 프롬프트 토큰, 완성 토큰 0)로 계량되며 토큰 속도 제한이나 Hub 예산에 중복 차감되지 않습니다. 라이브러리 호환성 변경: Admission::Cached, DeduplicationEntry::{with_success, with_error, response, response_backend, error, in_flight}, DeduplicationManager::{cache_success_with_backend, cache_error}, DeduplicationStats::{total_entries, cached_responses, cached_errors}(waiting으로 대체)가 제거되었고, FileService::list_files_metadata(#133, PR #2140)는 Vec<FileMetadata> 대신 FileListPage를 반환하며, 쓰이지 않던 BatchRequest와 BatchOptions(#2135, PR #2145)가 core::models에서 제거되었습니다.

Fixed

  • 중복 제거 대기자에게 요청 예산 안에 리더 결과를 전달하고 폴백 홉 연결 대기 시간을 반영합니다 (#2133, #2149, PR #2141, #2151). DeduplicationManager::admit은 단일 원자적 DashMap::entry 검사(Lead 또는 Join)와 tokio::sync::watch 채널, 리더 취소 시 진행 중 표시를 지우는 드롭 가드를 사용하며, 대기자 예산은 각 시도의 요청 타임아웃과 재시도 백오프 상한(폴백 홉에서는 timeouts.connection 포함)을 포화 연산으로 합산합니다.
  • 닫힌 헤더 허용 목록과 정규화된 JSON으로 중복 제거 키를 생성합니다 (#97, #2150, PR #2142, #2154). 중복 제거 키의 헤더 부분은 이제 자격 증명 헤더(authorization, x-api-key, x-goog-api-key), 프로바이더 동작 헤더(anthropic-version, anthropic-beta, openai-beta, openai-organization, openai-project), 라우터 제어 헤더(x-backend, x-continuum-debug), 그리고 활성 설정 스냅샷의 routing.rules[].when.header나 sticky_key가 참조하는 헤더만 정렬된 길이 접두사 형식으로 해시하므로, 요청별 트레이싱 및 SDK 텔레메트리 헤더(X-Trace-ID, x-request-id, x-stainless-*, user-agent) 때문에 동일한 요청이 별도 업스트림 호출로 갈라지지 않습니다. 중복 제거 키와 응답 캐시 키의 JSON 객체는 키를 정렬하고 -0.0을 0.0으로 정규화합니다.
  • Anthropic 런타임 등록, URL, 모델 매칭, 캐시, 상태 검사 버그를 수정합니다 (#2139). Anthropic 백엔드 url이 /v1로 끝날 때 /v1/v1/messages로 경로가 중복되던 문제를 막고, Admin 백엔드 생성, 수정, 프로브 요청에서 url을 생략하면 기본 프로바이더 URL을 채우며, 날짜가 없는 allowed_models 항목과 모델 별칭을 날짜 접미사가 붙은 실시간 Anthropic 모델 ID(가장 최신 날짜 우선)와 매칭하고, 재검증 시 빈 탐색 응답으로 캐시된 모델 목록을 덮어쓰지 않으며, Anthropic 상태 검사에 x-api-key와 anthropic-version: 2023-06-01을 전송합니다.
  • after와 limit을 적용하기 전에 소유자와 용도로 파일 목록을 먼저 필터링합니다 (#133, PR #2140). GET /v1/files와 GET /admin/files는 페이지를 자르기 전에(limit 범위 1..=10000, 기본값 10000) purpose와 호출자 소유권으로 먼저 거르고, 필터링된 집합에서 has_more를 계산하며, 존재하지 않거나 다른 소유자의 파일인 after 커서는 빈 페이지로 처리해 다른 소유자의 파일 ID 존재 여부를 탐색할 수 없게 합니다.
  • POST /v1/images/edits에서 다중 편집 이미지를 허용하고 편집 지원 검사 전에 모델 별칭을 먼저 해석하며 Nano Banana 문서를 수정합니다 (#2134, PR #2144). /v1/images/edits는 이제 모델별 개수 한도(gpt-image-*는 16, nano-banana, nano-banana-pro, Gemini 이미지 모델은 14, dall-e-2와 /v1/images/variations는 1) 안에서 최대 16개의 image 또는 image[] 멀티파트 필드를 받아 OpenAI 호환 백엔드에는 image[] 파트로, Gemini에는 여러 inlineData 파트로 전달하며, SUPPORTED_EDIT_MODELS를 확인하기 전에 model_aliases를 먼저 해석합니다.
  • POST /v1/batches에서 라우터 로컬 파일 ID를 거부합니다 (#2135, PR #2145). files.enabled가 켜져 있고 input_file_id가 라우터 로컬 Files API에 저장된 파일을 가리키면, 라우터 로컬 ID를 업스트림 프로바이더로 전달하는 대신 라우팅 보류나 배치 승인 전에 400 Bad Request(invalid_request_error, 코드 router_local_batch_input_file)로 즉시 거부합니다.
  • 실제 vLLM 0.32.0 및 SGLang 0.5.22 토크나이저 픽스처를 캡처하고 SGLang이 무시하거나 잘못 렌더링하는 옵션을 거부합니다 (#2020, PR #2153). SGLang /v1/tokenize는 return_token_strs, add_generation_prompt, add_special_tokens, continue_final_message, chat_template을 조용히 무시하고 tools가 없을 때 모델 채팅 템플릿 대신 역할과 콜론 줄로 messages를 이어 붙였으므로, 이제 라우터는 SGLang에서 지원되지 않는 옵션을 400 tokenize_option_unsupported로 거부(또는 처리 가능한 형제 백엔드로 라우팅)하며 엔진이 보내지 않은 토큰 문자열을 합성하지 않습니다. 캡처한 SGLang 0.5.22 노출 픽스처에 대한 엔드투엔드 Prometheus 메트릭 스크레이퍼 테스트도 추가했습니다 (#1486, PR #2148).

CI

  • .github/workflows/release.yml, .github/workflows/ci.yml, scripts/local-ci.sh, Cargo.toml 사이의 릴리스 바이너리 피처 합집합 일치를 검사합니다 (#1311, PR #2152). scripts/assert-release-feature-parity.sh와 변이 테스트 스위트가 shell-syntax 잡에서 실행됩니다.
  • /.github/actions/rust-toolchain의 dtolnay/rust-toolchain을 업데이트합니다 (PR #2147).

Dependencies

  • 루트 및 독립 크레이트 락파일에서 lru 0.16.3을 0.19.0으로, tokio 1.53.2를 1.54.0으로, aws-smithy-types 1.6.1을 1.9.0으로 업데이트합니다 (PR #2155).
  • 마이너 및 패치 그룹 2건을 업데이트합니다 (PR #2146): arc-swap 1.9.2에서 1.9.3, tokio 1.53.2에서 1.54.0.

v1.33.0 - 2026-10-15

Added

  • proxy.internal_streaming으로 생성 도중 취소된 버퍼링 클라이언트 요청을 계량합니다(#2043, 에픽 #1624). 새 proxy 섹션의 internal_streaming(기본값 false)이나 백엔드별 backends[].internal_streaming 재정의를 켜면, 조건을 만족하는 버퍼링 /v1/chat/completions 또는 /v1/completions 요청을 HTTP(S) 위의 OpenAI 와이어 백엔드(vllm, sglang, tensorrt-llm, llamacpp, mlxcel, ollama, lmstudio, openai, generic, continuumrouter)에 stream: true와 stream_options.include_usage: true(#2041 작성기를 거쳐 vllm에는 continuous_usage_stats도 추가하므로, 그곳의 취소는 엔진 자체의 개수를 engine_continuous로 기록)로 보내고, 스트림으로 읽은 뒤 라우터가 조립한 일반 버퍼링 본문(content, reasoning_content, refusal, 도구 호출만 스트리밍되면 content: null인 tool_calls, 선택지별 finish_reason, 엔진의 usage)으로 응답합니다. 생성 도중 연결을 끊은 클라이언트는 이전에는 아무 기록도 남지 않았지만, 이제 스트리밍된 델타를 라우터가 센 값, stream: false, http_status: 200으로 outcome: client_cancelled 레코드 하나가 남습니다. 엔진 사용량 없이 완료되면 usage_missing을 기록하고, 출력을 생성한 뒤 실패한 스트림은 부분 개수로 upstream_error(또는 timeout_mid_stream)를 기록하고 502(또는 504)로 응답하며, 재시도도 폴백 홉도 없는 최종 실패입니다. 출력 전에 실패한 스트림은 실패한 버퍼링 읽기처럼 재시도하고 폴백합니다. 클라이언트 chat 및 completions 경로만 대상이므로 /v1/decisions 에뮬레이션, Anthropic Messages와 Responses 인그레스, 임베딩, 이미지, 프로브, 라우터가 시작하는 호출은 바이트 단위로 그대로 버퍼링되며, n > 1, 로그 확률, json_schema 응답 형식, 레거시 functions, 오디오 출력, web_search_options를 쓰는 요청도 마찬가지입니다. anthropic, gemini, bedrock, azure, unix: 백엔드에서 true는 로드 오류이고, 두 필드 모두 즉시 재로드되며, Admin API가 재정의를 받고 Hub 로컬 내보내기는 이를 지원하지 않는 필드로 보고합니다. 스트리밍 캐시 재생도 같은 조립기를 쓰므로, 버퍼링 클라이언트에 재생되는 캐시된 스트림도 도구 호출과 추론 내용을 담습니다. docs/ko/configuration/index.md의 "버퍼링 요청의 내부 스트리밍"을 참고하세요.
  • 엔진 생성 카운터와 사용량 대조 (#2046, 에픽 #1624). 새 선택 블록 usage.reconciliation(enabled, window 10초부터 24시간, 기본값 5m, max_drift (0, 1] 범위, 기본값 0.05, history 1부터 1000, 기본값 24)은 백엔드별, 윈도별로 사용량 원장이 기록한 출력 토큰과 engine_stats가 이미 스크레이프하는 엔진 누적 생성 카운터의 증가분을 비교합니다(vLLM, SGLang, llama.cpp, mlxcel이 이 카운터를 보고하며, 다른 유형은 no_engine_data를 보고합니다). 각 윈도는 within_tolerance, drift(경고 한 번), no_engine_data, reset 중 하나로 닫히며, 첫 엔진 값은 기준값만 정하고, 새 스크레이프가 없는 윈도는 원장 토큰을 다음 윈도로 이월합니다. 결과는 GET /admin/usage/reconciliation과 usage_reconciliation_drift_ratio, usage_reconciliation_ledger_tokens_total, usage_reconciliation_engine_tokens_total, usage_reconciliation_windows_total{result}, usage_reconciliation_dropped_samples_total 메트릭으로 제공됩니다. backends[].reconciliation: false는 백엔드를 대조에서 제외합니다(다른 트래픽과 공유하는 엔진용). 대조기는 항상 컴파일되고 control-plane과 무관하며, 사용량 내보내기와 같은 두 기록 지점에서 같은 규칙으로 값을 받습니다. 범위를 벗어난 값은 로드 오류(reconciliation_window_out_of_range, reconciliation_max_drift_out_of_range, reconciliation_history_out_of_range)이며, 엔진 쪽을 읽을 수 없거나 window가 대조 대상 백엔드의 실제 engine_stats.interval(백엔드별 재정의, 없으면 전역 값)의 두 배보다 짧으면 config validate가 경고합니다. 대조 대상이 아닌 백엔드는 8192칸 샘플 큐의 자리를 차지하지 않으므로, 대조에서 뺀 바쁜 엔진이 대조 대상 백엔드의 샘플을 버리게 만들 수 없습니다. tests/usage_reconciliation_load_test.rs가 에픽의 혼합 부하 증거로, 완료, 취소, 스트림 중간 폴백, 청크 시간 초과 스트림이 두 모의 엔진과 1% 안에서 일치함을 확인합니다.
  • 최종 usage 블록 전에 끊긴 스트림에 엔진이 보고한 부분 사용량을 기록합니다 (#2041, 에픽 #1624 유닛 D). OpenAI 와이어 채팅 완성으로 나가는 모든 스트리밍 디스패치(/v1/chat/completions의 각 시도와 폴백 홉, 네이티브 Gemini 스트림, /anthropic/v1/messages 변환, /v1/responses 변환)는 이제 클라이언트의 stream_options 객체를 바꿔치기하지 않고 병합하는 헬퍼 하나로 이를 만듭니다. include_usage는 항상 켜고, vllm 백엔드에는 continuous_usage_stats(모든 청크의 누적 usage, 클라이언트 취소나 타임아웃으로 끊긴 스트림에서 engine_continuous로 기록)도 요청하되 둘 이상의 choice를 요청한 경우(n이 1이 아님)는 예외입니다(vLLM의 청크별 usage는 choice 하나만 세기 때문). 그 밖의 백엔드 유형과 모든 다중 choice 요청에서는 클라이언트가 보낸 continuous_usage_stats를 지웁니다. SGLang과 TensorRT-LLM은 업스트림 소스에 옵션이 있지만 캡처한 스트림 없이는 주입하지 않습니다. 엔진 표와 캡처 절차는 아키텍처 문서와 tests/fixtures/continuous_usage_stats/README.md에 있습니다. 새 선택 블록 usage.tokenize_correction(enabled false, timeout_ms 2000(100-30000), max_concurrent 4(1-256))은 라우터가 직접 센 델타 수로 기록되는 스트림을 다시 셉니다. 클라이언트 스트림이 끝난 뒤 라우터가 받은 텍스트를 서빙 엔진의 토크나이저 경로(vllm, sglang, llamacpp, mlxcel, continuumrouter, add_special_tokens: false)에 한 번 보내고, 레코드는 최대 timeout_ms 뒤에 결과(outcome)를 바꾸지 않은 채 engine_tokenize로 한 번 기록됩니다. 구간 사이의 템플릿 제어 토큰은 텍스트에 없으므로 이 값은 하한입니다. 엔진 호출은 백엔드 서킷에 영향을 주지 않으며 디스패치 때 포착한 홉 수를 싣습니다. usage_tokenize_corrections_total{result}는 다룬 스트림마다 한 번 셉니다(applied, timeout, unsupported, skipped_truncated, shed, error). 1 MiB를 넘는 엔진 응답, 또는 비어 있지 않은 텍스트에 대한 0이나 텍스트의 UTF-8 바이트 길이보다 큰 집계는 적용하지 않고 error로 셉니다. 대체된 미드스트림 폴백 시도는 바로 기록되며 보정하지 않고, 정상 종료가 시작될 때 아직 진행 중인 보정은 컨트롤 플레인 에이전트의 마지막 푸시 뒤에 발행되어 사라집니다(timeout_ms로 제한되는 알려진 제한입니다). /anthropic/v1/messages 변환은 청크별 사용량 스트림의 마지막 usage 청크에 대해 똑같은 message_delta를 두 번 보내지 않습니다.
  • 사용량 원장 변조 증거 (#2047, 에픽 #1624). control-plane 에이전트가 만드는 모든 사용량 배치에 세 개의 추가 UsageBatch 필드가 실립니다. batch_hash(prev_batch_hash, router_id, batch_id, sent_at_ms, chain_gap, 각 레코드의 정규 JSON에 대한 SHA-256), prev_batch_hash(전달 여부와 관계없이 직전에 만든 배치), chain_gap(그 뒤 잃은, 이미 만들어진 배치 수. 예: 413이나 스풀 삭제 뒤)입니다. sent_at_ms는 이제 배치가 만들어질 때 고정되므로 재시도한 배치는 바이트가 같습니다. 체인 헤드는 <state_file>.usage-chain.json(0600, 등록된 router_id에 묶임)에 저장되며, 쓰기나 이름 변경에 실패하면 이 파일을 지우므로 다음 쓰기 전에 프로세스가 죽어도 체인이 갈라지지 않고 새로 시작됩니다. 새 usage.ledger_export 블록(path, max_bytes, rotate)은 만들어진 모든 배치를 잘라내지 않는 소유자 전용 JSONL 파일에 추가하고, <path>.<sent_at_ms>로 회전할 때 앵커 줄을 남깁니다. 체인이 시작된 뒤에 시작한 파일도 앵커 줄로 시작하므로 라우터가 쓰는 모든 파일은 그 파일만으로 검증되며, scripts/verify-usage-ledger.py가 Python 표준 라이브러리만으로 파일을 검증합니다(--prev로 앞 파일에 이어 붙임). 빈 path, 제어 문자가 든 path, 1 MiB보다 작은 max_bytes, 그리고 control_plane.state_file이나 그 이름을 딴 파일을 가리키는 path(ledger_export_path_conflicts_with_state_file, writer도 파일을 열 때마다 검사)는 로드 오류입니다. 이 블록은 다음 푸시 주기에 사용량 루프를 멈추지 않고 재로드됩니다. 새 writer는 블로킹 풀에서 이전 writer를 기다리며(최대 5초, 그 뒤에는 이전 writer가 시작하지 않은 줄을 버림), 따라서 두 writer가 한 파일에 동시에 덧붙이는 일이 없습니다. 새 메트릭: usage_batches_hashed_total, usage_chain_gaps_total, usage_ledger_export_failures_total{target}, usage_ledger_export_bytes. 프로토콜은 Hub PR #1397(continuum-hub#1393)과 동기화되었으며, Hub보다 앞서 유지하는 파일은 없습니다.
  • 라우터가 시작한 LLM 호출을 계량합니다 (#2051, 에픽 #1624). 라우터가 클라이언트 요청을 대신해 하는 모든 호출은 재시도까지 포함해 디스패치마다 사용량 레코드 하나를 만들며 purpose를 담습니다. guardrail(LLM 가드레일 프로바이더와 systemone 프로바이더, chunked 윈도 검사마다 레코드 하나), classifier(스마트 라우팅 LLM 분류기와 System One 분류기, 파싱 실패 후 재시도 포함), action_safety(액션 안전 LLM 평가기와 System One 평가기), quality_judgment(사용량 품질 판정과 폴백 품질 검사)입니다. 레코드는 호출을 일으킨 요청의 key_id, request_id, 그리고 자신의 attempt_seq를 담고 parent_attempt는 담지 않으며, 고정된 백엔드는 backends[] 이름으로, 인라인 엔드포인트는 host[:port]로 표시합니다. usage 없는 응답은 char_estimate 수로 usage_missing을, 성공이 아닌 상태는 그 상태로 upstream_error를, 디스패치가 시작된 뒤 버려진 호출(가드레일 서비스의 timeout_ms, 중단된 클라이언트 요청)은 timeout_first_byte를 기록하고, 디스패치 전에 거부된 호출은 아무것도 기록하지 않습니다. 이 호출들은 라우터의 로컬 키 예산을 차감하지 않으며(차감 없는 PolicyStore::hub_identity_for_token으로 귀속함), /v1/decisions는 여전히 클라이언트 레코드만 남깁니다. 순번 1은 클라이언트의 첫 시도 몫으로 예약되고, 내부 호출은 attempts_total에 세지 않으며, GET /admin/stats에 by_purpose 분할이 추가됩니다. client 항목은 클라이언트 요청을, 나머지 항목은 내부 호출을 세며, 내부 호출의 토큰은 overall과 클라이언트 키 아래의 llm_tokens_total에 들어가고, 기간별 보기는 내부 호출을 별도의 크기 제한 링에 보관하므로 부하 요약이나 백분위수에서 클라이언트 행을 밀어내지 않습니다. 내부 레코드는 control-plane 사용량 채널의 마지막 10분의 1을 쓰지 않으므로, 허브 푸시가 느린 동안에는 클라이언트 레코드를 밀어내는 대신 먼저 버려지고(usage_records_dropped_total{reason="channel_full"}로 집계), 스트림 중간 폴백 홉(#2039)은 같은 할당기에서 요청의 다음 순번을 받으므로 가드 호출의 attempt_seq를 다시 쓰지 않으며 부모 바로 다음 순번이 아닐 수 있습니다. 허브 과금에 반영됩니다. docs/ko/architecture.md의 "라우터가 시작한 호출"을 참고하세요.
  • 기간, 모델, backend_id, outcome, purpose별 Admin 사용량 내보내기 (#2045, 에픽 #1624). GET /admin/usage/export는 라우터가 보관한 시도 단위 사용량 레코드를 JSON 또는 CSV로 돌려줍니다. 원시 레코드((completed_at_ms, request_id, attempt_seq) 순 정렬, limit과 커서로 페이지 나눔, CSV에서는 커서를 X-Next-Cursor로 전달)나 period:1h/period:1d, model, backend_id, outcome, purpose, key_id별 그룹(requests, attempts, 토큰 합계)으로 받을 수 있고, outcome, purpose, model, backend_id로 거를 수 있습니다. CSV는 CRLF 줄 끝과 최소한의 RFC 4180 인용을 쓰며, 스프레드시트가 수식으로 계산할 필드(=, +, -, @, 탭, CR로 시작)나 이미 '로 시작하는 필드는 앞에 '를 붙여 씁니다. share_of는 FAQ 12의 출력 토큰 점유율을 더하며, 로컬 캐시 재생은 제외하고 모든 outcome과(purpose 필터가 없으면) 모든 purpose를 셉니다. 레코드는 admin.stats.usage_export 아래의 크기 제한 링에 보관됩니다(enabled 기본값 false이며 admin.stats.enabled도 필요, retention 기본값 7d, max_records 기본값 1,000,000이며 1,000에서 10,000,000 사이, 잘못된 값은 로드 오류, 모든 필드는 즉시 다시 로드, 두 스위치 중 하나라도 꺼져 있으면 엔드포인트는 404 usage_export_disabled로 응답). 백엔드에 도달한 모든 시도, 중계된 모든 스트림, 모든 로컬 캐시 재생(backend: local-cache, cache_hit_type 설정, 출력 없음)마다 추가되며, 백엔드에 도달하지 못한 요청은 추가되지 않습니다. 영속화가 켜져 있으면 레코드당 약 300바이트로 통계 스냅샷에 함께 저장되고, usage_export_records_evicted_total{reason}이 링에서 제거된 레코드를 셉니다. 내보내기는 라우터 단위이므로 여러 라우터 환경에서는 모든 라우터의 내보내기를 합산하거나 허브를 사용하세요. docs/ko/admin-api.md의 "사용량 내보내기"를 참고하세요.
  • 허브 장애와 재시작에 대한 사용량 원장 내구성 (#2040, 에픽 #1624). 선행 기록 시도 로그 <state_file>.usage-wal.jsonl(0600)은 버퍼링과 스트리밍 /v1/chat/completions 백엔드 디스패치마다(재시도와 폴백 홉마다 하나) 시작 줄을, 끝나면 종료 줄을 기록합니다. 강제 종료나 충돌 뒤에는 다음 부팅이 끝나지 않은 시도마다 라우터의 입력 추정치, output_tokens: 0, token_source: none, estimated: true를 담은 outcome: router_restart 레코드 하나를 푸시하며, 이 레코드는 다른 레코드와 똑같이 시퀀스가 매겨지고 과금됩니다. 경계가 있는 디스크 스풀 <state_file>.usage-spool/(0700)이 푸시 버퍼의 오래된 것부터 버리기를 대체합니다. 넘치는 버퍼는 가장 오래된 배치를 스풀에 쓰고, 푸시 루프는 원래의 batch_id와 router_usage_seq 그대로 스풀을 먼저 비우며, 종료 시에는 확인받지 못한 배치와 버퍼를 다음 프로세스를 위해 스풀에 기록합니다. 핫 리로드되는 두 키가 크기를 제한합니다. control_plane.usage_wal_max_bytes(기본 64 MiB, 1 MiB~1 GiB)와 control_plane.usage_spool_max_bytes(기본 256 MiB, 0이면 오래된 것부터 버리기 유지, 그 외 1 MiB~64 GiB)입니다. 남는 모든 손실은 usage_records_dropped_total{reason}(channel_full, buffer_overflow, spool_overflow, batch_rejected_413, ack_oversized, wal_dropped)으로, 건너뛴 손상 파일은 usage_durability_corrupt_total{kind}로, 스풀은 usage_spool_records와 usage_spool_bytes로 계수되며, 스풀된 레코드는 하트비트 백로그와 컨트롤 플레인 상태의 큐 깊이에 포함됩니다. /v1/responses, /anthropic/v1/messages, 패스스루 인그레스는 아직 WAL 줄을 쓰지 않습니다. 심볼릭 링크이거나 일반 파일이 아니거나 소유자 전용이 아닌 WAL 또는 스풀 파일은 건너뛰며(스풀 파일은 삭제하고 계수), 다른 라우터 등록으로 쓰인 스풀 배치는 폐기하고 계수합니다. 정상 종료는 에이전트가 스풀을 기록하고 WAL을 정리할 때까지 최대 허브 요청 타임아웃에 5초를 더한 시간만큼 기다립니다. docs/ko/architecture.md의 "장애와 재시작에 대한 내구성"을 참고하세요.
  • 모델 메타데이터와 사용량 레코드에 모델 아티팩트와 드래프트 모델 식별 정보를 추가합니다 (#2048, 에픽 #1624). ModelMetadata에 운영자가 선언하는 artifact(sha256: 또는 blake3: 뒤에 16진수 64자리가 오는 digest로 소문자로 정규화됨, registry_version, revision, 사용자 정보를 거부하는 URI인 source)와 draft_model(name, 선택 사항인 artifact)이 추가되며, 백엔드와 모델별로 키 하나씩 해석됩니다. 백엔드의 model_configs 항목이 model-metadata.yaml과 드롭인보다 우선하며 공유 아티팩트를 통째로 대체하고, 내장 카탈로그는 참조하지 않습니다. 백엔드에 도달한 모든 사용량 레코드는 추가 전용 필드 model_digest와 draft_model을 담으며(폴백 홉은 실제로 응답한 백엔드의 식별 정보를, 배치 완료는 제출 시점에 잡은 식별 정보를 담고, 캐시 재생은 둘 다 담지 않음), continuum-hub#1385 반영 후 허브 프로토콜과 동기화되었습니다. PROTOCOL_VERSION은 0을 유지합니다. draft_model은 드래프트의 이름을 밝힐 뿐 아무것도 세지 않으며, 수락된 토큰은 대상 모델에 귀속됩니다. GET /v1/models/{model}은 첫 번째 서빙 백엔드의 식별 정보를 보여 주고, GET /admin/models는 모든 서빙 백엔드의 식별 정보를 artifacts에 나열합니다. 잘못된 식별 정보는 model_configs에서는 로드 오류이고, 메타데이터 계층에서는 경고와 함께 버려지며(metadata show --resolved가 나열함), metadata download는 이를 거부합니다. config validate는 vllm, sglang, tensorrt-llm 백엔드에서 다이제스트 없이 드래프트를 선언한 경우와 한 모델 id가 서로 다른 다이제스트로 선언된 경우에 경고합니다. docs/ko/configuration/index.md의 "모델 아티팩트 식별 정보"를 참고하세요.
  • 시도 단위 사용량 원장 스키마 (#2053, 에픽 #1624). 이제 모든 컨트롤 플레인 사용량 레코드는 추가 전용 필드 열네 개를 담습니다. outcome(시도가 어떻게 끝났는지: completed, length, 그리고 예약된 종료 원인들), token_source(출력 토큰 수를 얻은 방법, 현재는 engine_final), estimated, stream, http_status(시도의 업스트림 상태), request_id(클라이언트 요청의 모든 시도가 공유하는 라우터 발급 req_ id로, 클라이언트의 x-request-id가 아님), attempt_seq와 parent_attempt, purpose(현재는 client), reasoning_tokens(Chat Completions와 Responses의 usage 세부 객체에서 TokenUsage로 읽음), 그리고 보내고 받은 텍스트의 UTF-8 input_chars, input_bytes, output_chars, output_bytes입니다. 벤더링된 continuum-protocol 크레이트는 이 필드를 정의한 허브 리비전으로 다시 동기화했으며(continuum-hub#1380) PROTOCOL_VERSION은 0을 유지합니다. 모든 프로듀서가 API 라우트 그룹에 설치된 요청 범위 원장을 통해 첫날부터 이 값을 찍고, 라우터는 전송 전에 허브의 폐기 규칙을 적용합니다. 로컬에서는 GET /admin/stats에 by_outcome이 추가되고(업그레이드 전에 저장된 통계 스냅샷은 윈도 행을 completed로 복원하지만, 전체 기간 by_outcome 분할은 비어 있는 상태로 시작해 업그레이드 이후 기록된 요청만 셈), llm_tokens_total에 outcome 레이블이 추가되며, usage_records_total{outcome, token_source, purpose}가 모든 레코드를 셉니다. docs/ko/architecture.md의 "사용량 레코드의 결과와 시도 식별자"를 참고하세요.
  • 라우터가 직접 세는 출력 토큰과 하나의 스트림 사용량 파이널라이저 (#2052, 에픽 #1624 유닛 C1). 백엔드가 usage 블록을 보고하지 않는 스트리밍 응답(stream_options.include_usage가 없는 엔진, 이를 제거하는 프록시, /v1/completions 엔진, 유실된 종료 프레임)이 이제 아무것도 기록되지 않는 대신 과금됩니다. 모든 스트리밍 릴레이가 첫 이벤트부터 StreamUsageFinalizer를 소유하고 정상 종료 시점에 정확히 하나의 사용량 이벤트를 기록하며, 이 이벤트는 outcome=usage_missing, token_source=router_delta_count, estimated=true, 라우터가 센 스트리밍 콘텐츠·추론·도구 인자 델타 수(청크당 하나, vLLM 계열 엔진의 단위)를 출력으로, 요청의 추정 입력(엔진이 프롬프트만 보고했으면 그 값)을 입력으로, 그리고 UTF-8 output_chars/output_bytes를 담습니다. 채팅 파이프라인 탭은 마지막 값이 이기고 한 번만 마무리하며(클라이언트가 끝나기 전에 떠나면 client_cancelled), 미드스트림 폴백의 min_accumulated_tokens는 이제 문자 수를 4로 나눈 값이 아니라 델타 이벤트 수를 세고 추론만 있는 부분 응답은 이어가지 않으며, 새 usage_missing_total{backend} 카운터가 라우터 추정 레코드의 비율을 보여 줍니다. docs/ko/architecture.md의 "라우터가 직접 세는 출력 토큰과 스트림 사용량 파이널라이저"를 참고하세요.
  • 실시간 세션 사용량 계량 (#2044, 에픽 #1624). 이제 GET /v1/realtime 세션은 백엔드가 보고하는 사용량을 계량합니다. 라우터가 중계하는 서버 response.done 하나가 공유 통계 기록기를 거쳐 사용량 레코드 하나가 되며, /admin/stats, 키별 llm_tokens_total 카운터, 컨트롤 플레인 사용량 레코드에 반영됩니다. 레코드에는 stream: true, http_status: 101, 세션의 request_id, 세션별 attempt_seq, 그리고 response.status에서 매핑한 결과가 담깁니다(max_output_tokens 사유의 incomplete는 length, cancelled는 client_cancelled, failed는 upstream_error). 레코드는 와이어의 response.model이나 session.model이 아니라 세션이 디스패치된 모델에 기록하며, 이 값들은 백엔드가 작성하고 라우터가 검증하지 않기 때문입니다. 사용량이 없는 실패나 취소 응답도 토큰 0과 token_source: none으로 기록합니다. 중계되는 바이트는 바뀌지 않습니다. 전송이 끝난 뒤 256 KiB 이하인 백엔드 텍스트 프레임의 사본만 파싱하며, 바이너리 프레임과 클라이언트 프레임은 들여다보지 않습니다. 새 realtime_usage_events_total{backend, result} 카운터는 recorded, oversized, unparsed, session_closed_in_flight(response.done 전에 세션이 끝난 응답으로, 아무것도 기록하지 않음)를 셉니다. docs/ko/configuration/realtime.md의 "사용량 계량"을 참고하세요.
  • 서빙 중인 백엔드를 참조하는 Hub 선언 가드레일 프로바이더 행 (#2082, PR #2128). control-plane 라우터는 이제 guardrail_provider_rows_v1을 광고하고, Hub 정책의 provider_rows(llamaguard, llm_judge, backend:로 참조하는 openai_moderation)를 로컬 행 뒤에 선언 순서대로 합성합니다. 이름이 같은 로컬 행이 우선하며 overrides_hub: true로 보고됩니다. Hub 행은 가드레일 인벤토리와 GET /admin/guardrails에 source: "hub"로 보고되고, 로컬 가드레일이 없는 라우터에서 가드레일을 켜며, PATCH /admin/guardrails가 디스크에 쓰지 않습니다. 실효 설정(Hub 동기화 백엔드 포함, internal: true 허용)에서 backend가 없거나 비활성인 행은 guardrail_backend_missing, guardrail_backend_disabled, guardrail_providers_unavailable로 거부됩니다. 벤더링한 continuum-protocol 크레이트는 Hub 리비전 47e111b0으로 옮겨집니다.
  • Hub 티어 스냅샷에서 가드레일 라우트를 선택합니다 (#2096, PR #2129). 라우터는 guardrail_route_v1을 광고하고 TierLimits.guardrail_route를 읽으므로, 키 발급 시 config.yaml을 고치지 않아도 티어가 다른 키는 같은 모델에 서로 다른 가드레일 정책을 받습니다. 실효 정책은 전역 기본값, guardrails.routes[<티어 라우트>], guardrails.routes[<모델>] 순으로 겹치며(더 엄격한 mode가 이기고, 단계는 합집합이 되며, 카테고리별 임계값은 낮은 쪽이 이깁니다), Hub 테넌트 가드레일 정책은 여전히 모든 로컬 라우트 위에 가장 엄격한 쪽으로 합성됩니다. 설정에 없는 라우트를 가리키는 티어 라우트는 전역 정책으로 돌아가고 guardrail_route_unknown_total을 올립니다. 티어 라우트는 응답 캐시와 도구 판정 캐시 범위에 포함되고, 저장된 키 테이블에서도 유지되며, GET /admin/control-plane/policy, GET /admin/guardrails, POST /admin/guardrails/test에 표시되고, guardrail_blocks_total과 guardrail_verdicts_total에 상한이 있는 route 레이블(16개 값, 이후 other)을 붙입니다.
  • 정제 후 검사 방식의 가드레일 단계 구성 (#2100, PR #2127). 가드레일 프로바이더는 phase: sanitize | inspect(기본값 inspect)를 받습니다. 변환이 가능한 프로바이더(pii, bedrock_guardrail/aws_bedrock)만 sanitize를 쓸 수 있으며, 시작과 핫 리로드 시 검사합니다. 입력 문서, 비스트리밍과 buffer_full 출력, chunked 스트림 윈도, 드라이런은 deny, allow, sanitize, 마스킹, 마스킹된 내용에 대한 inspect, 최종 검증 순으로 평가되므로, inspect 프로바이더는 sanitize 프로바이더가 가린 텍스트를 보지 못합니다. 실패한 sanitize 프로바이더는 on_error를 따르며(fail_closed는 어떤 inspect 프로바이더보다 먼저 차단), monitor 모드와 드라이런에서도 sanitize 차단이나 마스킹 실패가 있으면 가리지 않은 텍스트가 원격 inspect 프로바이더에 닿기 전에 멈춥니다. 측정한 단계 구성 오버헤드는 p50 기준 약 0.2ms입니다.
  • 라우트별 가드레일 스트리밍 모드와 윈도 설정 (#2099, PR #2112). guardrails.routes[] 항목은 streaming_mode, streaming_chunk_size, streaming_context_size, streaming_stream_first를 받으며, 지정하지 않으면 전역 값을 상속합니다. 따라서 같은 라우터에서 지연에 민감한 라우트는 chunked로, 엄격한 라우트는 buffer_full로 돌릴 수 있습니다. 라우트의 실효 모드가 monitor가 아니면 그 라우트의 streaming_mode: passthrough는 거부됩니다. GET /admin/guardrails는 라우트별로 결정된 값을 보여주며, Hub 합성은 이 값을 라우터 로컬로 남겨 둡니다.
  • 내장 pii 프로바이더에 한국 개인식별번호와 사용자 정의 정규식 엔티티를 추가합니다 (#2095, PR #2115). 주민등록번호, 외국인등록번호, 사업자등록번호를 위한 새 kr_rrn, kr_frn, kr_brn 스캐너가 credit_card와 phone보다 먼저 실행되므로, 이 번호들은 더 이상 카드나 전화번호로 보고되지 않습니다. 하이픈이 없는 13자리 번호는 가까이에 문맥 단어가 있어야 합니다. options.custom_entities(name, pattern, 선택적 context_words와 context_window, 최대 64개 엔티티, 패턴당 1024바이트)는 운영자 엔티티를 정의하며, 동작은 actions[name] 또는 default_action에서 가져옵니다. GET /admin/guardrails는 실효 엔티티를 보여주고 PUT /admin/guardrails/providers/{name}으로 options를 갱신할 수 있습니다.
  • vLLM 백엔드에 Hub가 전달한 요청 우선순위를 적용합니다 (#2093, PR #2126). Hub 티어는 TierLimits.request_priority(-100부터 100, vLLM --scheduling-policy priority에서 낮은 값이 먼저 실행)를 실을 수 있으며, request_priority_v1로 광고되고 키 테이블 사이드카에 저장됩니다. vllm 백엔드로 가는 /v1/chat/completions와 /v1/completions에서 라우터는 request_extensions 적용 뒤 최상위 priority 필드와 X-Vllm-Priority를 주입하거나 제한하고, 기존 /v1/embeddings의 priority도 제한하므로, 클라이언트나 백엔드 기본값 모두 티어보다 앞서 스케줄될 수 없습니다. 우선순위가 적용된 응답은 x-continuum-request-priority를 싣고, 티어 우선순위가 있는 요청이 vLLM이 아닌 백엔드로 가면 continuum_request_priority_skipped_total{reason="backend_type"}을 올립니다.
  • 우선순위 레이블이 붙은 요청 및 TTFT 시계열 (#2094, PR #2130). continuum_request_priority_requests_total{priority, status_class}와 continuum_request_priority_ttft_seconds{priority}로 여러 티어가 하나의 vllm 백엔드를 공유하더라도 Hub가 전달한 우선순위 등급별로 요청률과 라우터가 관측한 첫 토큰 시간을 비교할 수 있습니다. priority 레이블은 16개 값(이후 other)으로 제한되며, 우선순위가 적용되지 않은 요청은 unmanaged로 표시됩니다.
  • OpenAI 오디오 전사 및 번역 라우트 (#2102, PR #2120). POST /v1/audio/transcriptions와 POST /v1/audio/translations는 multipart/form-data 업로드를 선택된 백엔드로 전달하고, 모든 response_format에 대해 상태, Content-Type, 본문을 그대로 돌려줍니다. model 폼 필드는 모델별 속도 제한, Hub 티어 모델 허용 목록, 비용 센터 검사에 쓰입니다. stream=true는 400 streaming_not_supported로 답하며, 업로드는 재시도 없이 한 번만 디스패치됩니다. 새 제한: proxy.audio.max_body_size(기본값 25 MiB, 넘으면 본문을 버퍼링하지 않고 413), proxy.audio.max_concurrent_uploads(기본값 8), timeouts.request.audio(첫 바이트 60초, 전체 300초), timeouts.limits.max_audio_timeout(기본값 600초, 상한 1800초). 두 라우트 모두 write 스코프가 필요합니다.
  • 사용량 레코드와 Hub 가격표에 오디오 초 단위 계량을 추가합니다 (#2103, PR #2125). 사용량 레코드에 선택 필드 audio_seconds가, Hub 가격 항목에 선택 필드 audio_microusd_per_second가 생깁니다. 전사나 번역 응답은 usage.seconds(usage.type이 duration일 때) 또는 verbose_json의 duration을 초 단위로 올림한 값으로 과금 길이를 보고하며, 토큰 필드는 0으로 남으므로 키 TPM 윈도, 토큰 예산, 비용 센터 토큰 예산은 움직이지 않습니다.
  • backends[].record_usage_missing (#2101, PR #2117). 이 백엔드별 스위치(기본값 false, 별칭 record_missing_usage, 즉시 리로드)를 켜면, 쓸 수 있는 usage 블록이 없는 2xx 비스트리밍 응답도 outcome: usage_missing, token_source: none, 토큰 0으로 사용량 레코드를 남깁니다. /v1/embeddings와 /v1/rerank는 이제 요청 헤더를 사용량 기록기로 넘기므로 키, 사용자, 팀 귀속이 유지됩니다. 이 필드는 Admin 백엔드 생성, 갱신, 프로브 요청에서 받습니다.
  • 백엔드 시도 전에 거부된 요청의 구조화 로그 줄 (#2107, PR #2121). auth, rate_limit, policy, admission, guardrail_input, routing 단계의 모든 거부는 event=request_rejected인 WARN 줄 하나를 남기며, trace id, x_request_id, client_ref, 원장 범위가 있으면 request_id, 메서드, 라우트, 상태, 오류 type과 code, limit_type, stage, key_id(키 자체는 절대 아님), 지연 시간을 담습니다. logging.rejections.max_per_minute(기본값 600)이 줄 수를 제한하며, 넘친 줄은 request_rejected_log_suppressed_total로 세고 윈도마다 한 번 event=request_rejected_summary로 요약합니다.
  • 인증, 정책, 입력 가드레일 단계를 자식 span으로 내보냅니다 (#2105, PR #2118). OTLP 내보내기가 켜져 있으면 router.auth, router.policy, router.guardrail_input이 router.request의 순서 있고 겹치지 않는 자식이 되며, 각각 router.stage_outcome(allowed, blocked, error)을 싣습니다. 가드레일 span은 router.guardrail.provider도 싣습니다. 내보내기가 꺼져 있으면 span을 만들지 않습니다.
  • 활성 span 컨텍스트에서 아웃바운드 traceparent를 만듭니다 (#2106, PR #2119). OTLP 내보내기가 켜져 있으면 백엔드로 보내는 traceparent와 tracestate가 router.backend_call span을 가리키고, 인바운드 traceparent가 없는 router.request는 라우터 자신의 trace id를 OpenTelemetry trace id로 씁니다. 샘플링 플래그(-01 또는 -00)는 내보내기 여부와 관계없이 결정된 샘플링 판정을 따르며, 유효하지 않은 인바운드 tracestate는 전파 전에 버려집니다.
  • 비밀 정보 없이 재시작 후에도 설정 이력을 유지합니다 (#2098, PR #2113). admin.history_dir를 설정하면 파일 편집 핫 리로드마다 ${VAR} 자리표시자를 그대로 둔 원본 설정 텍스트(해석된 비밀 정보는 절대 아님)를 history-<version>.json(0600, 디렉터리 0700)에 쓰며, admin.max_history_entries(기본값 100)와 admin.max_history_bytes(기본값 10 MiB)로 정리합니다. GET /admin/config/history와 POST /admin/config/rollback/{version}은 저장된 항목과 메모리 항목을 합치고, 롤백은 자리표시자를 다시 해석합니다. POST /admin/config/apply로 적용한 변경은 메모리에만 남습니다.
  • GET /v1/models/extended와 GET /v1/models/{model}에 모델이 받는 reasoning_effort 수준을 보고합니다 (#2081, PR #2109). model_configs나 model-metadata.d/ 어느 쪽도 선언하지 않으면 metadata.reasoning_efforts를 내장 OpenAI 추론 계열에서 채우며(none은 rejects_reasoning_effort_none이 다루므로 제외), 단일 모델 엔드포인트도 이제 reasoning_efforts와 rejects_reasoning_effort_none을 돌려줍니다.
  • llama.cpp와 mlxcel, 그리고 연결된 라우터를 통해 채팅 messages를 토큰화합니다 (#2002, PR #2019). messages를 담은 POST /tokenize는 엔진의 POST /apply-template으로 채팅을 렌더링한 뒤 그 결과를 하나의 승인과 하나의 30초 예산 안에서 토큰화합니다. 엔진이 무시할 옵션(요청별 chat_template, 텍스트가 아닌 콘텐츠 파트, 지원하지 않는 스위치)은 그 이름을 담은 400 tokenize_option_unsupported로 답하며, 선택은 그 옵션을 지킬 수 있는 백엔드를 우선합니다. continuumrouter 백엔드는 이제 토크나이저 유형입니다. 본문은 model을 바꿔 전달되고, 하위의 404나 501은 코드가 model_not_found가 아닌 한 501 tokenization_not_supported로 답합니다.

Security

  • 홉 카운터로 라우터 간 포워딩 루프를 제한합니다 (#2025). 백엔드 URL이 라우터 자신의 리스너이거나 라우터가 순환하면(A에서 B, 다시 A), 각 홉이 다음 홉을 기다리는 동안 자신의 요청을 붙잡은 채 홉마다 인바운드 요청이 하나씩 끝없이 중첩되어, 순환에 속한 모든 라우터의 연결과 메모리를 소진시킬 수 있었습니다. 이 루프는 type: continuumrouter만의 문제가 아니었습니다. 라우터를 가리키는 유형 미지정(generic) 또는 openai 백엔드도 똑같이 루프를 만들었고, Hub가 관리하는 백엔드 토폴로지도 이런 백엔드를 만들 수 있었습니다. 이제 모든 요청은 x-continuum-router-hops를 지니며(없으면 0), 이 값은 인증, 레이트 리밋, 동시 처리 한계보다 먼저 읽습니다. 8 이상으로 도착한 요청은 어떤 핸들러도 시작되기 전에 508 router_loop_detected로 거부하고(stream: true여도 JSON 응답), 그 밖의 요청은 수를 1 늘려 전달합니다. 이 값은 클라이언트 헤더를 전달하는 모든 경로로 나가고, 토크나이저, count_tokens의 tokenize, 이미지 편집, realtime 핸드셰이크, /v1/decisions 패스스루 호출에는 명시적으로 설정됩니다. 숫자가 아닌 값은 400 invalid_router_hops로 답합니다. 이 508은 재시도하지 않고, 폴백 체인으로 넘어가지 않으며, 서킷 브레이커에 집계되지 않으므로, 자기 자신을 가리키는 루프는 중첩 요청 9개 뒤에 끝납니다. 이 헤더는 request_extensions.headers에 예약되어 있어 어떤 철자로 써도 로드 오류가 되며, config validate와 설정 로드는 백엔드 URL이 server.bind_address 항목과 같으면 경고합니다. docs/ko/configuration/backends.md의 "포워딩 루프와 홉 제한"을 참고하세요.
  • 라우터가 요청을 대신해 하는 호출에도 라우터 홉 수를 실어 보냅니다 (#2032). #2025의 홉 카운터는 클라이언트 헤더를 전달하는 모든 경로를 다뤘지만, 일부 내부 호출은 헤더를 직접 만들었기 때문에 같은 호출을 다시 하는 라우터가 대상이면 단계마다 수가 다시 0부터 시작되어 끝없이 중첩되었습니다. 이제 다음 호출은 자신이 처리하는 요청의 수를 보냅니다. LLM 전송 가드레일(self_hosted_classifier, classifier, custom_classifier, endpoint와 backend 모두)의 가드 호출, 모든 System One 결정 호출(systemone 가드레일, 스마트 라우팅 system_one 분류기, evaluator: systemone인 action_safety, fallback.quality_check), 컨트롤 플레인 프로바이더 배치 호출(/v1/batches의 생성, 조회, 목록, 취소, 결과), 그리고 usage.quality_judgment 호출입니다. 마지막 호출은 응답 뒤에 실행되며 판정 대상 요청에서 미리 잡아 둔 수를 보내므로, 두 라우터 사이의 판정 순환은 판정 네 번 안에 끝나고 클라이언트 요청 하나가 유료 판정을 끝없이 이어 가지 않습니다. 가드레일 분류기 엔드포인트의 508은 더 이상 재시도하거나 브레이커에 집계하지 않으며, 가드 대상의 508은 이제 on_error와 무관하게 fail-closed로 처리합니다. 홉 수는 클라이언트가 보낸 값에서 시작하므로, 그렇지 않으면 fail_open에서 클라이언트가 x-continuum-router-hops: 7을 보내 다른 라우터를 거치는 가드레일을 건너뛸 수 있습니다. /v1/decisions 대상의 508은 이제 508 router_loop_detected로 응답하며 폴백 체인으로 넘어가지 않습니다. 이전에는 502 decision_upstream_error로 응답해 상위 모든 단계가 체인의 에뮬레이션 홉으로 넘어갔습니다. 이 검사는 인증보다 먼저 실행되어 어떤 클라이언트든 매 요청마다 로그 한 줄을 쓰게 할 수 있었으므로, 508 거부 로그에 빈도 제한을 둡니다(1분에 WARN 한 번, 반복은 DEBUG).
  • 가드레일 차단 응답에 고정된 error.message를 돌려줍니다 (#2097, PR #2110). block_behavior: error에서 차단 응답은 판정의 reason을 error.message에 넣었기 때문에, 주입된 텍스트나 개인정보를 담을 수 있는 custom_classifier 프로바이더의 모델 작성 추론이 호출자에게 전달되었습니다. 이제 /v1/chat/completions, /v1/completions, /v1/responses, /anthropic/v1/messages의 모든 차단은 입력이든 출력이든, 버퍼링이든 스트림 도중이든 This request was blocked by a content-safety policy and cannot be completed.로 답하며, 상태, 봉투의 type과 code, x-guardrail-* 헤더는 그대로입니다. 프로바이더 문구로 판별하던 클라이언트는 code로 판별해야 합니다.

Changed

  • control_plane.policy.enabled가 true이면 control_plane.policy.unmatched_key의 기본값을 reject로 바꿉니다 (#2092). Hub 정책이 한 번 적용된 뒤(또는 <state_file>.policy-keys.json에서 만료되지 않은 키 테이블을 복원한 뒤)에는, 요청에 이미 로컬에서 인증된 AuthContext가 없는 한 자격 증명이 없거나 동기화된 Hub 항목과 일치하지 않는 API 키를 보낸 요청이 401(authentication_error, invalid_api_key)을 받습니다. 따라서 만료되거나 폐기된 Hub 키가 Hub의 30일 보존 기간을 지나 정책 봉투에서 빠지더라도 허용적인 로컬 인증으로 넘어가지 않습니다. 정책이 아직 적용되지 않은 콜드 스타트(policy_sync.source: none)는 여전히 fail-open이고, 로컬에 설정한 키는 영향을 받지 않습니다. 새로 발급한 Hub 키도 라우터가 그 키를 담은 정책을 적용하기 전까지(기본 폴링 5초) 이 거부를 받는다는 점에 유의하십시오. 전환이나 업그레이드 전에 control_plane_unmatched_key_total{action}으로 영향을 미리 측정할 수 있습니다. 마이그레이션 참고: Hub 정책 집행과 함께 api_keys.mode: permissive로 인증되지 않은 키나 Hub에 없는 키를 받아들이던 배포는 control_plane.policy.unmatched_key: fail_open을 명시해야 하며, 이 경우 시작 시 WARN 로그와 그 결과를 설명하는 continuum-router config validate 경고가 나옵니다.
  • 사용량을 백엔드 시도 단위로 기록합니다 (#2049, 에픽 #1624). 이제 백엔드에 도달한 모든 디스패치는 request_id, attempt_seq, parent_attempt로 클라이언트 요청에 연결된 사용량 레코드 하나가 됩니다. 버퍼링 재시도, 폴백 체인 홉, 첫 바이트 이전 스트리밍 홉, 품질 검사 홉, web_search 도구 루프의 모든 턴이 해당합니다. 이에 따라 과금 대상이 달라집니다. 오류 본문에 토큰을 보고한 실패 시도는 이제 그 토큰을 과금하고, 토큰이 없는 실패 시도(5xx, 429, 연결 오류, 다이얼 타임아웃)는 outcome: upstream_error(또는 timeout_first_byte), 그 시도의 http_status, 출력 0, estimated: true로 표시된 추정 입력을 담은 별도 레코드로 나타납니다. 품질 검사 홉은 /admin/stats에서 두 번째 요청이 아니라 같은 요청의 시도 하나로 집계되며, web_search 루프는 마지막 턴만이 아니라 모든 턴을 과금합니다. GET /admin/stats의 overall, models[], backends[]에 attempts_total, successful_attempts, failed_attempts가 추가되고 total_requests는 계속 클라이언트 요청을 셉니다. 디스패치된 시도의 통계 행과 fallback_attempts_total/fallback_success_total 레이블은 더 이상 unknown이 아니라 시도가 다이얼한 백엔드를 표시하며, 백엔드에 도달하지 못한 요청은 not_dispatched로 기록됩니다. docs/ko/architecture/model-fallback.md의 "시도별 레코드"를 참고하십시오.
  • 설정에 선언한 형식과 레벨로 프로세스 로그를 기록합니다 (#2042). 생성되는 모든 샘플(Config::default(), 설정 빌더, config.yaml.example)처럼 format: "json"을 선언한 파일은 이제 한 줄짜리 텍스트 대신 줄마다 JSON 객체 하나를 출력합니다. 따라서 텍스트 줄을 파싱하던 로그 파이프라인은 JSON을 파싱하거나, 이전 줄을 바이트 단위로 그대로 유지하는 format: pretty를 설정해야 합니다. RUST_LOG는 이제 프로세스 subscriber를 설치하는 모든 경로(서버, 그리고 config, metadata, auth login, mcp-serve, 마이그레이션 하위 명령)에서 CONTINUUM_LOG_LEVEL과 logging.level을 오버라이드하는 완전한 tracing 필터 지시문으로 적용되며, 해석할 수 없는 값은 stderr에 알린 뒤 무시합니다. 이전에는 RUST_LOG가 아무 효과가 없었기 때문에, 설치 가이드의 systemd 유닛이 설정하던 RUST_LOG=continuum_router=info처럼 continuum_router 타깃만 지정한 지시문은 이제 admin_audit, guardrail::audit 줄과 모든 의존성의 경고까지 숨깁니다. 기본 레벨을 함께 적거나(info,continuum_router=debug) 변수를 제거하십시오. Admin 감사 로그가 켜져 있는데 필터에 걸러지면 라우터가 시작 시 경고합니다. 샘플 docker-compose.yml은 더 이상 기본값으로 RUST_LOG=info를 설정하지 않으므로(빈 값을 전달하며 이는 미설정으로 취급됩니다), 마운트한 파일의 logging.level이 적용됩니다.

Fixed

  • 사용량 원장의 병합 후 공백을 메웁니다 (PR #2073, 에픽 #1624). 종료된 Chat 및 Responses 스트림과 중간 시도는 이제 요청 수를 늘리지 않고 전체와 기간별 토큰 통계에 들어가며, Responses 스트림은 키별 통계 없이도 내보내지고, 응답 헤더 뒤 첫 이벤트 전에 취소된 내부 스트림도 기록되며, 내부 스트리밍 SSE 응답은 16 MiB로 제한되고 거부 시 부분 사용량을 보존하며 재시도나 폴백을 하지 않습니다.
  • 네이티브 Anthropic 스트림의 조기 종료에서 TTFT 샘플을 남기지 않습니다 (PR #2072, 에픽 #1624). 클라이언트가 취소한 패스스루 스트림이 continuum_backend_ttft_seconds를 샘플링했는데, 이는 완료된 시도만 첫 토큰 시간을 보고한다는 원장 규칙에 어긋났습니다.
  • W3C trace id를 X-Request-ID에서 분리하고 tracing.headers.client_ref와 x-continuum-request-id를 추가합니다 (#2104). 기본 미들웨어 체인은 클라이언트가 x-request-id를 보내지 않았을 때 하이픈이 들어간 UUID를 더 이상 붙이지 않으며, TraceContext는 X-Request-ID나 X-Correlation-ID를 trace id로 쓰지 않습니다. trace_id는 항상 소문자 16진수 32자([0-9a-f]{32}, 전부 0은 아님)이며, 유효한 인바운드 traceparent, 그다음 32자 16진수 X-Trace-ID, 그것도 없으면 새로 생성한 값에서 가져옵니다. 그래서 백엔드로 보내는 아웃바운드 traceparent는 항상 W3C 형식(^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$)이고, 유효한 인바운드 traceparent는 trace id와 tracestate를 유지한 채 새 아웃바운드 span id를 받습니다. 유효한 클라이언트 X-Request-ID 값([A-Za-z0-9._:/-]{1,128})은 x_request_id로 로그에 남고 응답의 x-request-id 헤더로 되돌아가며, 32자 16진수 trace_id를 싣는 아웃바운드 X-Request-ID/X-Trace-ID/X-Correlation-ID를 덮어쓰지 않습니다. 새 선택 필드 tracing.headers.client_ref(기본값 "", 비활성, 예: X-Client-Ref)는 ^[A-Za-z0-9._:-]{1,128}$에 맞는 호출자 상관 참조를 받아 백엔드로 전달하고, client_ref로 로그에 남기며, router.request에 router.client_ref로 내보냅니다. 유효하지 않은 값은 요청을 실패시키지 않고 제거되며 client_ref_rejected_total{reason}(empty, too_long, control_char, invalid_char)을 올립니다. 사용량 원장 범위 안의 모든 응답(버퍼링과 스트리밍 200, 4xx/5xx 오류)은 이제 요청의 사용량 레코드 request_id와 router.request_id span 속성에 일치하는 x-continuum-request-id: req_<32 hex>를 싣고, x-request-id는 클라이언트의 유효한 X-Request-ID를 되돌려주거나 없으면 x-continuum-request-id를 씁니다(원장 범위 밖의 더 이른 거부는 x-continuum-request-id를 생략하고 유효한 클라이언트 X-Request-ID만 되돌려줍니다). 마이그레이션 참고: 이전에 trace_id나 router.trace_id 안의 16진수가 아니거나 UUID인 X-Request-ID로 조인하던 로그/트레이스 파이프라인은 구조화 로그의 x_request_id, 응답의 x-request-id를 읽거나, 호출자 상관 참조용으로 tracing.headers.client_ref: "X-Client-Ref"(client_ref / router.client_ref)를 설정해야 합니다. docs/en/trace-export.md와 docs/en/configuration/advanced.md를 참고하십시오.
  • 중간에 끊긴 스트림이 이제 생성한 만큼 과금됩니다(#2039, 에픽 #1624 유닛 C2). OpenAI 와이어 스트리밍 릴레이(채팅 트랜스포머의 OpenAI 및 thinking 경로, Unix 소켓 릴레이 세 개, SSE 파이프라인, 미드스트림 폴백)의 모든 조기 종료는 종료 시점까지 센 출력을 담은 사용량 레코드를 정확히 하나 기록합니다. 클라이언트가 취소하면 client_cancelled(usage 청크가 이미 왔다면 엔진의 수치, 아니면 라우터의 델타 수), first_byte 타임아웃은 출력 0의 timeout_first_byte, 전체 예산이나 비활성 타임아웃은 timeout_mid_stream, 핸드셰이크 오류 상태나 반복된 업스트림 오류는 상태 코드를 담은 upstream_error, usage 청크 이전의 가드레일 차단은 게이트가 보류한 델타까지 센 guardrail_blocked를 기록합니다. 이전에는 조기 반환이 아무것도 기록하지 않았고 타임아웃이나 차단은 정상 종료로 기록되었습니다. 미드스트림 폴백 홉은 실패한 시도의 부분 출력을 fallback_source로(아무것도 생성하지 않았다면 출력 0의 upstream_error로) 기록하고, 후속 시도는 자신이 생성한 출력만 parent_attempt 1인 attempt_seq 2로 기록합니다. 직접(체인 없는) 스트림도 이제 원장 시도를 시작하므로 /admin/stats 행이 백엔드 이름을 담고, 비성공 상태로 응답한 핸드셰이크는 레코드 하나와 함께 실패한 시도 하나로 집계되며, streaming_fallback_total에 시도가 실패한 백엔드를 가리키는 backend 레이블이 추가됩니다. docs/ko/architecture.md의 "스트림 종료 경로와 사용량 결과"를 참고하세요.
  • 나머지 릴레이에서도 중간에 끊긴 스트림이 생성한 만큼 과금됩니다(#2050, 에픽 #1624 유닛 C3). /anthropic/v1/messages 스트림 경로(HTTP와 Unix 소켓 네이티브 패스스루, Bedrock runtime, OpenAI 와이어 및 Responses 와이어 변환), /v1/responses 패스스루와 그 Anthropic, Gemini, Chat Completions 변환, responses_only 브리지, 채팅 트랜스포머의 TCP Anthropic 소스 및 Gemini 경로의 모든 조기 종료는 이제 종료 시점까지 센 사용량 레코드를 정확히 하나 기록합니다. 떠난 클라이언트는 client_cancelled, 가드레일 차단은 guardrail_blocked, 읽기 타임아웃은 프레임 도착 여부에 따라 timeout_first_byte 또는 timeout_mid_stream, 그 밖의 읽기 오류나 response.failed 종료는 upstream_error이며, 수치는 종료 전에 엔진 사용량이 도착했으면 그 값, 아니면 라우터의 델타 수입니다. Anthropic 인그레스에서 클라이언트가 떠난 뒤 업스트림이 멈추거나 실패하면 타임아웃이나 오류가 아니라 client_cancelled로 기록되고 error 이벤트를 받지 않습니다. 엔진의 입력은 밝히지만 출력은 밝히지 않는 보고(Anthropic의 message_start, 또는 Gemini가 마지막 청크에만 보내는 candidatesTokenCount가 없는 usageMetadata)는 그 입력과 라우터의 델타 수로 과금되므로, 출력 수가 오기 전에 끊긴 Anthropic이나 Gemini 소스의 /v1/responses 스트림이 더 이상 출력 0의 engine_final로 기록되지 않습니다. 이전에는 엔진이 끊긴 시점까지 토큰을 생성했는데도 이런 종료가 아무것도 기록하지 않거나 토큰 0의 통계 행만 남겼습니다. 과금에 드러나는 변경: 사용량 보고가 없는 스트림의 가드레일 차단은 아무것도 기록하지 않는 대신 라우터의 수치를 기록하고(Responses 변환과 브리지에서 #1833 규칙을 뒤집음), 엔진 사용량 없이 끝난 /v1/responses의 Chat Completions 변환은 usage_missing을, max_output_tokens로 인한 response.incomplete는 length를 기록하며, 데이터 수준의 오류 이벤트 뒤에 끝난 Anthropic 인그레스 스트림은 토큰을 그대로 세는 success: false 통계 행을 씁니다. SSE 파이프라인의 가드레일 게이트가 [DONE]이나 소스 끝에서 내린 차단(/v1/chat/completions의 Gemini와 Bedrock 경로)은 이제 정상 종료가 아니라 guardrail_blocked로 기록됩니다. 실패한 시도의 행(결과가 completed, length, usage_missing이 아닌 경우)은 TTFT를 보고하지 않고 response_headers TTFT 샘플도 남기지 않으며, 스트림 릴레이 태스크에서 쓰는 /admin/stats 요청 행은 이제 스트림 이전에 실패한 시도를 셉니다. docs/ko/architecture.md의 "스트림 종료 경로와 사용량 결과"를 참고하세요.
  • 스트리밍 출력 가드레일 검사와 스트리밍된 action-safety 판정에도 라우터 홉 수를 실어 보냅니다 (#2054). 모든 스트리밍 경로(/v1/chat/completions, /v1/responses, /anthropic/v1/messages, responses_only 브리지)는 출력 게이트를 별도로 spawn한 릴레이 태스크로 옮기는데, tokio task-local은 spawn한 태스크에 상속되지 않습니다. 그래서 chunked 윈도 검사, buffer_full 스트림 종료 검사, monitor 모드의 최종 검사가 요청의 홉 수 스코프 밖에서 실행되어 x-continuum-router-hops 헤더를 보내지 않았습니다. /anthropic/v1/messages와 responses_only 브리지에서 스트리밍된 도구 호출의 System One action-safety 판정도 마찬가지였습니다. 이 때문에 라우터인 가드 대상은 홉 수를 0부터 다시 셌고, #2032의 508 fail-closed 규칙은 스트리밍 경로에서 동작하지 않았으며, 스트리밍 경로의 가드 자기 루프는 MAX_ROUTER_HOPS + 1이 아니라 MAX_ROUTER_HOPS + 2개의 인바운드 요청 뒤에야 끝났습니다. 이제 StreamingOutputGate와 ActionSafetyEvaluator는 요청 태스크에서 만들어질 때 홉 수를 캡처하고, 릴레이에서 하는 모든 프로바이더 호출을 그 스코프 안에서 실행합니다.
  • 자체 호스팅 OpenAI 호환 백엔드를 type: openai로 등록했을 때 자동 선택 스트리밍 경로에서도 클라이언트가 보낸 reasoning_effort를 유지합니다 (#2030). #2015는 normalize_openai_streaming_payload에서만 제거를 OpenAI 클라우드와 Azure로 한정했는데, stream_with_auto_backend_selection은 자체적으로 페이로드를 만들면서 여전히 unscoped normalize_openai_reasoning_request를 호출했습니다. 그래서 그 경로에서는 모든 type: openai 백엔드에 대해 값이 제거되어, 로컬 엔진은 그 경로에서만 값을 잃고 직접 스트리밍 경로와 비스트리밍 경로는 유지했습니다. 이제 이 경로도 같은 엔드포인트 판정을 사용합니다 — Azure는 항상, Openai는 URL에 api.openai.com이 포함될 때만 — 따라서 세 경로가 일치합니다. 이 경로는 라이브러리 전용이며(인크레이트 호출자가 없고 src/http/streaming/mod.rs·src/streaming.rs로 pub 노출), 영향은 임베더에게 있습니다. #2015가 unscoped로 남긴 다른 지점인 OpenAIBackend::transform_request는 그대로입니다.
  • Anthropic Messages 및 Responses 인그레스에서 Dynamo shim으로 보내는 요청에 pin 적용 (#2029). 두 인그레스는 요청을 chat 본문으로 변환해 --router-mode direct shim 백엔드로 보낼 때 x-dynamo-* 워커 pin을 붙이지 않아 프론트엔드가 요청을 거부했습니다. shim 풀 백엔드로 가는 모든 전송(버퍼링, 스트리밍, web_search 루프의 각 라운드, 재시도, 폴백 시도, /v1/responses 패스스루와 responses_only 전송 포함)에 chat 경로와 같은 pin이 붙고, 이 전송에서 클라이언트의 nvext.token_data는 항상 제거되며, direct 모드 거부는 인그레스 고유의 오류 형식으로 503 disaggregated_prefill_decode_unavailable로 응답합니다. 적용되지 않는 경우: 이미 열린 200 SSE 스트림 안의 거부, Unix 소켓 스트리밍 백엔드의 2xx가 아닌 응답, /v1/responses/compact.
  • /tokenize와 /detokenize에서 하위 라우터의 구조화된 4xx를 그대로 전달합니다 (#2024). continuumrouter 백엔드가 param: continue_final_message와 함께 보낸 400 tokenize_option_unsupported 같은 거부는 고정 메시지에 param이 없는 tokenize_upstream_rejected로 클라이언트에 전달되었습니다. 이제 본문이 라우터 오류 객체인 하위 4xx는 상태 코드, 코드, 유형, param을 유지하고 메시지는 256바이트로 자르므로, 체인 배포도 직접 연결한 라우터와 같은 응답을 줍니다(하위 404 model_not_found는 이제 404 model_not_found로 답합니다). 하위 401, 403, 407은 여전히 502로 답하고, 그 밖의 본문은 tokenize_upstream_rejected로 남으며, 다른 엔진 유형은 바뀌지 않습니다.
  • 라우터가 검증하고 문서화했지만 실제로는 읽지 않던 logging.level, logging.format, admin.audit.include_body를 적용합니다 (#2042). 파일 내용과 관계없이 모든 배포가 INFO 레벨의 텍스트로 로그를 남겼고 CONTINUUM_LOG_LEVEL도 아무것도 바꾸지 못했습니다. 이제 로깅 키가 시작 시 프로세스 subscriber를 설정하며(재시작 필요는 그대로), 세 번째 형식으로 compact를 지원합니다. include_body: true는 이제 Admin POST, PUT, PATCH, DELETE 요청의 JSON 본문을 16 KiB까지, 설정 내보내기 마스커로 키 이름 기준 마스킹하고(POST /admin/api-keys의 새 키 값과 POST /admin/config/import, /admin/config/validate의 설정 텍스트도 마스킹) 그 뒤 admin_audit 줄에 추가합니다. 기록하지 않은 본문은 body_omitted(not_json, body_bytes_read를 동반한 too_large, invalid_json)로 표시하고, 마스킹한 형태가 16 KiB를 넘으면 잘라서 body_truncated로 표시하며, 핸들러는 여전히 모든 바이트를 받습니다. 기본값인 include_body: false에서는 줄이 바뀌지 않습니다.
  • 중계되는 스트림의 회계 이음새를 완성합니다 (#2078, PR #2087). /v1/responses, responses_only 채팅 브리지, /anthropic/v1/messages, SSE 파이프라인은 이제 각 스트림을 GET /admin/stats by_outcome에 실제 종료 결과로 한 번만(completed로 중복하지 않고) 기록하고, 동시 스트림을 request_id로 연결 시점 행과 맞추며, admin.stats.track_tokens가 false여도 GET /admin/usage/export와 전체 토큰 카운터를 채웁니다.
  • 일반 스트리밍 릴레이에 chunk_interval을 적용하고 업스트림이 조용한 동안 클라이언트 연결이 끊기면 멈춥니다 (#2079, PR #2083). 채팅 트랜스포머, thinking, Unix 소켓 릴레이는 이제 모델의 스트리밍 chunk_interval이 지나면 멈춘 스트림을 미드스트림 타임아웃으로 끝내고, Anthropic, Gemini, 채팅 소스 릴레이는 다음 바이트를 기다리지 않고 클라이언트가 떠나는 즉시 업스트림을 끊습니다. 종료가 아닌 /v1/responses 이벤트의 사용량은 스트림이 끝날 때까지 잠정값으로 남습니다.
  • 로드 시 usage 섹션을 검증하고 Admin 백엔드 API에서 reconciliation을 반영합니다 (#2080, PR #2085). 이제 usage.quality_judgment와 usage.tokenize_correction 규칙이 시작, 핫 리로드, 설정 동기화, Admin 설정 쓰기에 적용되며, continuum-router config validate는 이를 점으로 구분된 경로로 보고합니다. POST /admin/backends, POST /admin/backends/probe, PUT /admin/backends/{name}은 reconciliation을 받고(생성 시 기본값 true), 리로드 시 그 변경을 백엔드 변경으로 셉니다.
  • /v1/responses 채팅 브리지에서 incomplete, 거부, 추론, 실패 결과를 보존합니다 (#2122, PR #2124). incomplete, failed, cancelled로 끝났거나 거부나 추론 항목을 담은 Responses 응답이 502 responses_parse_failed나 빈 finish_reason: "stop" 완성으로 답해졌습니다. 이제 버퍼링과 스트리밍 응답 모두에서 거부는 message.refusal과 finish_reason: "content_filter"로, 추론은 reasoning_content로, 콘텐츠 필터에 의한 잘림은 content_filter로, 잘린 함수 호출은 length로 옮겨집니다. 업스트림이 종료 이벤트 전에 끊은 스트림은 이제 합성된 stop 청크와 [DONE] 대신 event: error 프레임(upstream_error)으로 끝납니다. responses_only 모델의 Chat Completions 클라이언트는 이제 Anthropic과 Gemini 백엔드가 이미 그랬듯 추론 요약을 reasoning_content로 받습니다. 출력 가드레일은 guardrails.inspect_reasoning: true일 때만 이 텍스트를 검사합니다.
  • GET /v1/models에서 Anthropic 모델을 실시간으로 찾습니다 (#2089, PR #2114). Anthropic 백엔드는 Claude 3.5 ID 고정 쌍을 돌려줬고 그중 하나는 존재하지 않는 ID였습니다. 이제 탐색, 후보 프로브, 모델 집계는 x-api-key와 anthropic-version으로 키의 카탈로그를 페이지 단위로 읽고, 호출이 실패할 때만 갱신된 내장 카탈로그로 돌아갑니다(오류는 프로브 결과에 기록). 내장 Haiku 3.5 ID는 claude-3-5-haiku-20241022로 바로잡았습니다.
  • continuum-kv-listener가 vLLM 0.29 이후의 맵 인코딩 KV 캐시 이벤트를 받습니다 (#2091, PR #2108). vLLM 0.29는 EventBatch와 그 이벤트를 배열 대신 태그 붙은 맵으로 인코딩하는데, 리스너는 모든 배치를 거부했습니다. 이제 두 인코딩 모두 같은 출력으로 디코딩됩니다. 리스너는 /healthz 옆에 /health에도 답합니다.
  • Hub 설정 동기화에서 로컬 전용 kv_routing 필드를 유지합니다 (#2090, PR #2111). Hub kv_routing 섹션을 적용하면 prefix_routing.salt_echo가 false로, kv_cache_index.backend가 memory로 초기화되어, kv_cache_index.backend: redis를 쓰는 라우터가 동기화마다 KV 인덱스를 메모리에 다시 만들었습니다. 이제 두 필드는 로컬에서 설정한 대로 남습니다.
  • 웹 검색 도구 루프의 사용량을 서빙한 백엔드에 귀속합니다 (#1968, PR #2016). web_search가 켜진 자체 호스팅 백엔드의 비스트리밍 채팅 요청은 백엔드 unknown, backend_id 없음으로 기록되었습니다. 이제 사용량 이벤트, 프로바이더, x-served-backend가 마지막 턴을 서빙한 백엔드를 가리킵니다.
  • 토크나이저 라우트와 count_tokens 토큰화 경로를 강화합니다 (#2001, PR #2017). count_tokens 엔진 호출은 공용 16 MiB 상한으로 읽고 너무 큰 본문이면 로컬 추정으로 돌아가며, 토크나이저 요청은 프로브 슬롯을 차지하지 않고 half-open 회로를 통과하고(전송 실패는 여전히 회로를 다시 엽니다), token_strs는 오프셋이 있는 하나의 버퍼로 보관합니다.
  • 자체 호스팅 type: openai 백엔드로 가는 스트리밍 요청에서 reasoning_effort를 유지합니다 (#2000, PR #2015). 스트리밍 경로는 OpenAI 추론 계열이 아닌 모든 모델에서 이 필드를 제거했습니다. 이제 제거는 이 필드를 거부하는 api.openai.com과 type: azure에만 적용됩니다.
  • all-features 디버그 빌드가 기본 2 MiB 스레드 스택 안에서 돌게 합니다 (#2136, PR #2137). 채팅 인그레스가 스트리밍, 분리 서빙, 시맨틱 캐시 future를 391 KB짜리 poll 프레임 하나에 인라인해서 all-features 테스트 타깃 네 개가 스택 오버플로로 중단됐습니다. 이제 가장 큰 await 대상은 async가 아닌 래퍼 뒤에 박싱되고, task-local 스코프 헬퍼는 스코프된 future를 돌려주며, ContinuumRouterBuilder::build는 박싱된 future를 돌려줍니다. 호출하는 쪽의 .build().await는 그대로입니다.

CI

  • 셀프 호스티드 러너에서 실행될 수 있는 모든 서드파티 액션을 전체 커밋 SHA로 고정하고, 그곳에 변경 가능한 ref가 있으면 CI를 실패시킵니다 (#1488, PR #2037).
  • 스크립트나 워크플로가 바뀌면 툴체인 없이 도는 shell-syntax 잡을 실행합니다 (#1489, PR #2035).
  • 프로젝트 보드 워크플로를 pull_request_target에서 실행해 Dependabot 풀 리퀘스트를 프로젝트 보드에 동기화합니다 (#2022, PR #2023).
  • astral-sh/setup-uv를 10.1.0에서 10.3.0으로(PR #2012, #2075), actions/setup-go를 6.5.0에서 7.0.0으로(PR #2013), Azure/setup-kubectl을 5.1.0에서 5.2.0으로(PR #2076) 올리고, 로컬 툴체인 액션의 dtolnay/rust-toolchain 고정값을 갱신합니다(PR #2077).

Documentation

  • mlxcel 토크나이저 픽스처를 커밋 1e561f1e의 mlxcel-server에서 다시 캡처했습니다 (#2020). mlxcel#2127 이후 mlxcel은 /detokenize에서 어휘 범위를 벗어난 ID에 llama.cpp와 같이 500 server_error로 답하므로, 이제 라우터는 mlxcel 백엔드로 가는 그런 요청에 그 ID가 조용히 빠진 텍스트를 담은 200을 돌려주는 대신 502 tokenize_upstream_error로 답합니다. 라우터 자체는 바뀌지 않았습니다. 그 이전의 mlxcel 빌드는 여전히 200으로 답하고 그 ID를 빼며, 그 응답은 여전히 200으로 클라이언트에 전달됩니다. docs/ko/api.md 토크나이저 절의 mlxcel 검증 설명과 502 행에 이 내용을 반영했습니다.

Dependencies

  • 루트 및 독립 크레이트(continuum-event-broker, continuum-kv-listener) 전반에서 serde_yaml을 serde_norway 0.9로 교체하여 유지보수가 중단된 unsafe-libyaml(RUSTSEC-2024-0449) 의존성을 제거하고 deny.toml의 권고 예외를 삭제했습니다 (#2084).
  • 벤더링한 continuum-protocol 크레이트를 사용량 배치 해시 체인을 완성한 Hub 리비전 0165a2b8로 다시 고정하고 LOCAL-AHEAD 항목 네 개를 지웠습니다. 프로토콜 바이트는 바뀌지 않았습니다 (PR #2086).
  • minor-and-patch 그룹 5건 갱신 (PR #2014): lazy_static 1.5.0에서 1.5.1, redis 1.7.0에서 1.7.1, rmcp 3.4.1에서 3.5.0, tokio-rustls 0.26.5에서 0.26.6, tokio-test 0.4.5에서 0.4.6.
  • minor-and-patch 그룹 3건 갱신 (PR #2038): tokio 1.53.1에서 1.53.2, uuid 1.26.1에서 1.27.0, libc 0.2.189에서 0.2.190.
  • minor-and-patch 그룹 6건 갱신 (PR #2074): toml 1.1.6에서 1.1.7, rmcp 3.5.0에서 3.5.1, hyper 1.11.1에서 1.12.0, dynamo-kv-hashing 1.5.0에서 1.5.1, h2 0.4.19에서 0.4.20, rust-embed 8.12.0에서 8.13.0.
  • 루트와 독립 크레이트 lockfile의 rustls를 0.23.45에서 0.23.46으로 올려 RUSTSEC-2026-0346, RUSTSEC-2026-0347, RUSTSEC-2026-0348, RUSTSEC-2026-0349를 해결하고, 나머지 semver 호환 의존성을 갱신했습니다 (PR #2137).
  • etcd-client를 0.20에서 0.21로, minijinja와 minijinja-contrib를 2에서 3으로(Value::from_serialize를 대체하는 serde 기능 포함), tokenizers를 0.22에서 0.23으로 올리고, 독립 크레이트의 hyper, http, http-body 요구 버전을 높였습니다 (PR #2137).

v1.32.1 - 2026-10-05

Fixed

  • 클라이언트 연결이 끊기면 조용한 /v1/chat/completions 스트리밍 릴레이를 취소합니다 (#2008). OpenAI 와이어, 사고 패턴, Anthropic, Gemini, Unix 소켓, responses_only 채팅 릴레이는 다음 업스트림 바이트만 기다렸기 때문에, 제공자가 조용한 동안(긴 추론 구간) 떠난 클라이언트의 업스트림 요청은 제공자가 다시 내보내거나 timeouts.request.streaming.total이 지날 때까지 열려 있었고, 단일 슬롯 로컬 모델은 아무도 읽지 않는 응답을 계속 생성했습니다. 이제 각 릴레이는 모든 업스트림 읽기와 함께 다운스트림 종료를 감시하고 업스트림을 즉시 끊습니다. 미드스트림 폴백이 켜져 있으면 조용한 구간에 떠난 클라이언트는 비활성 실패가 아니라 연결 종료로 보고되고, 그 클라이언트를 위해 폴백 백엔드에 연결하지 않습니다. 연결된 느린 클라이언트는 여전히 마지막 사용량 청크로 과금됩니다 (#1815).
  • continuum-kv-listener 푸시 데이터 스트림이 라우터의 응답 헤더가 도착하기 전에 실시간 피드에서 뒤처지면 스트림을 깔끔하게 끝냅니다 (#2010). 리스너는 헤더만 기다린 뒤 스트림을 버렸기 때문에, 라우터는 buffer_overflow 종료와 함께 요청의 정상 종료 대신 리셋을 볼 수 있었습니다. 이제 헤더가 이미 도착한 경우와 마찬가지로, 같은 2초 유예 안에서 라우터가 자기 쪽을 닫을 때까지 기다립니다.

CI

  • 기능 조합마다 라이브러리를 다시 빌드하던 테스트를 전체 기능과 기본 기능 두 빌드에서 실행하도록 바꿨습니다. make ci-local과 호스티드 ci.yml 모두에 적용됩니다 (#2005, PR #2007). 로컬 전체 실행은 94단계 3616초에서 35단계 2387초로 줄었습니다. 호스티드 게이트가 건너뛸 잡의 단계를 로컬에서도 건너뛰는 선택형 실행 make ci-local-gated(--gated)를 추가했고, 테스트 탐색 감사는 묶은 단계가 기능 게이트가 걸린 모든 테스트 대상을 포함하는지 검사합니다.

v1.32.0 - 2026-10-03

Added

  • 요청한 모델을 서빙하는 엔진의 토크나이저에 도달하는 POST /tokenize와 POST /detokenize(별칭 /v1/tokenize, /v1/detokenize)를 추가합니다 (#1998). vllm, sglang, llamacpp, mlxcel 백엔드를 지원합니다. 클라이언트 계약은 vLLM과 SGLang의 형식이며, llama.cpp와 mlxcel에는 라우터가 변환해 보냅니다(이때 add_special_tokens 기본값은 true이고 messages는 거부합니다). 디스패치는 정확한 모델에 대한 단일 호출입니다. 재시도, 폴백 체인, 캐시, 모델 실험, 사용량 레코드, 모델별 레이트 리밋 차감이 없습니다. 두 경로 모두 read 스코프가 필요하고, 다른 백엔드 타입만 서빙하는 모델은 501 tokenization_not_supported로 답합니다. /anthropic/v1/messages/count_tokens는 이제 같은 와이어 테이블에서 엔진 경로와 페이로드를 가져옵니다. http://host:8000/v1처럼 /v1로 끝나는 백엔드 URL은 엔진 경로를 붙이기 전에 끝의 /v1 하나를 제거하므로, vllm, llamacpp, mlxcel은 /tokenize, /detokenize로, sglang은 /v1/tokenize, /v1/detokenize로 호출됩니다. 이전에는 이런 URL이 count_tokens에서 엔진이 서빙하지 않는 /v1/tokenize 또는 /v1/v1/tokenize를 만들었습니다. 이 경로에서 엔진의 모든 5xx는 502 tokenize_upstream_error로, 엔진의 401, 403, 407은 502로 답하고, 엔진의 429는 Retry-After를 유지합니다. 백엔드의 서킷 브레이커에는 이 경로에서 엔진이 돌려준 HTTP 응답이 상태 코드와 관계없이 중립입니다. 따라서 토크나이저의 2xx가 생성 경로가 실패 중인 백엔드의 서킷을 닫거나 닫힌 상태로 유지할 수 없고, 클라이언트가 유발한 5xx가 서킷을 열 수도 없습니다. 연결 실패와 타임아웃만 실패로 집계하며, 열린 서킷은 지금처럼 요청을 거부합니다. 엔진 응답은 16 MiB로 제한한 본문에서 JSON 트리 없이 읽으므로, 조밀한 ID 목록에서 가장 큰 할당은 256 MiB 트리가 아니라 64 MiB ID 벡터입니다.
  • 셀프 호스팅 백엔드가 Responses API를 직접 구현할 때 type: openai로 선언하지 않고도 패스스루로 제공되도록 backends[].responses_api: native를 추가했습니다 (#1987). OpenAI 와이어 유형이면 모두 허용하며, anthropic, bedrock, gemini, unix: URL에서는 로드 시 거부합니다. 유형이 결정하는 다른 동작은 바뀌지 않으며, 자격 증명은 백엔드 자체 방식(auth.type: basic이면 Basic)으로 보냅니다. 패스스루에서 라우터는 이제 모델링하지 않는 요청 필드(conversation, background, service_tier, 이후 추가되는 필드)와 모델링하지 않는 유형의 입력 항목(mcp_approval_response)을 전달합니다. 이전에는 필드를 버리고 해당 항목에는 422로 응답했습니다. background: true는 패스스루에서만 전달하며, 대기 중인 POST는 아무것도 기록하지 않고 라우터를 거친 최종 결과의 첫 GET이나 cancel이 사용량을 한 번 기록하고 가드레일이 있으면 결과를 검사합니다(가드레일이 있으면 스트리밍 백그라운드 요청은 거부하며, 라우터를 거쳐 가져오지 않은 결과는 미터링되지 않습니다). request_params나 허브 티어 제한이 적용되는 동안 이 경로로 보낸 top_k, min_p, max_tokens, max_completion_tokens는 거부합니다. 변환 전략은 conversation과 이런 항목을 400 unsupported_request_parameter로 거부하며, 모델링하지 않는 필드가 있는 요청은 응답 캐시에서 제공되지 않습니다. 패스스루 백엔드가 처리한 응답에 대한 GET, DELETE, 새 POST /v1/responses/{id}/cancel은 이제 404 대신 그 백엔드에 도달합니다. 라우터는 인증 주체 기준으로 응답 ID와 백엔드의 바인딩을 라우터별로 제한된 크기만큼 보관하고, 매 호출마다 현재 설정을 기준으로 백엔드와 호출자의 allowed_backends를 다시 검사하며, 기록하지 않은 ID는 어디에도 보내지 않습니다. 이 옵션은 즉시 리로드되며, Hub 로컬 내보내기에서는 지원되지 않는 필드로 보고됩니다.
  • 스마트 라우팅 정책 조건 input_tokens를 추가합니다 (#1988). when.input_tokens 범위(gt, gte, lt, lte)는 폴백 예산 계획이 이미 사용하던 요청 전체의 일반(typical) 입력 토큰 추정치와 비교하며, 이 추정기는 이제 core::prompt_estimate에서 공유됩니다. 최신 턴만이 아니라 대화의 모든 메시지와 tools, functions, response_format을 셉니다. 긴 프롬프트가 작은 컨텍스트의 기본 모델에서 400 context_length_exceeded로 실패하는 대신 첫 시도부터 긴 컨텍스트 티어로 갈 수 있습니다. 추정치는 요청당 한 번, 로드된 정책이 읽을 때만 계산되며, X-Smart-Route-Input-Tokens 디버그 헤더, Routing policy matched 디버그 로그, POST /admin/smart-routing/simulate에 보고되고, GET/PUT /admin/smart-routing/policies를 그대로 왕복합니다. 검증은 빈 범위, 하한이나 상한을 두 개 지정한 범위, 추정값을 하나 이하만 허용하는 범위를 거부합니다. 스마트 라우팅을 실행하는 유일한 인그레스인 /v1/chat/completions에 적용되며, 허브 정책 계약에는 해당 멤버가 없으므로 크기 기반 규칙은 로컬에 둡니다.
  • 에이전트 런타임처럼 요청에 부수 효과가 있는 백엔드를 위한 backends[].replay: enabled | disabled를 추가합니다 (#1993). disabled이면 디스패치가 요청을 전달했을 가능성이 생긴 뒤의 연결 끊김, 타임아웃, 재시도 가능한 상태 코드는 그 실패로 요청을 끝냅니다. 같은 백엔드나 다른 백엔드로 재시도하지 않고, 폴백 체인으로 홉하지 않으며(해당 시도는 결코 홉하지 않는 새 replay_disabled 트리거로 보고됨), 스트림 시작 전 순회나 스트림 도중 전환을 하지 않고, 그 백엔드가 제공한 응답에서 품질 검사 홉을 하지 않으며, 분리형 서빙의 통합 폴백으로 넘어가지 않습니다. TCP 또는 TLS 연결 실패는 전달되지 않았음이 증명되므로 버퍼링 경로에서는 계속 재시도하고 폴백합니다. 이 백엔드로 선택된 스트리밍 요청은 한 번만 디스패치되고, 이 백엔드로 홉한 스트리밍 체인은 연결 실패가 아니면 거기서 멈추며, Responses와 Anthropic 인그레스는 디스패치를 확정한 뒤의 모든 실패에서 멈춥니다. 이런 백엔드가 제공할 수 있는 요청은 요청 중복 제거를 건너뛰므로, 트레이스 ID를 공유하는 독립된 요청들이 더 이상 한 번의 실행으로 응답받지 않습니다. 응답 캐시는 이들의 응답을 저장하거나 제공하지 않고, Hub 합성 프로브는 이 백엔드를 건너뛰며, 에뮬레이션된 /v1/decisions 체인은 이 백엔드 이후 홉하지 않습니다. 디스패치마다 읽고(즉시 리로드), Admin API와 WebUI 백엔드 폼이 받아들이며, Hub 로컬 내보내기에서는 지원되지 않는 필드로 보고됩니다. RetryState::backend_attempted와 관련 메서드 두 개는 사용 중단(deprecated)되었습니다. 라우터는 시도한 백엔드를 기록한 적이 없어서 이 목록을 읽던 선택 필터가 아무것도 제외하지 못했고, 이제는 이 목록을 읽지 않습니다.
  • smart_routing.unprofiled_models를 추가합니다 (#1975). default_tier(기본값)는 기존 동작을 유지하고, exclude는 model_profiles 항목(정확한 이름 또는 패턴, 로컬, Hub 지정, Admin API로 추가한 항목)과 매칭되는 모델만 후보로 받아들이므로 자동 추론이나 default_tier로 티어에 들어갈 모델은 대상이 되지 않고, 정책이 고른 티어에 프로필이 있는 모델이 없으면 기존 티어 폴백을 따릅니다. 이 옵션은 즉시 다시 로드되며, model_profiles가 비어 있는데 exclude이면 config validate가 경고하고, GET /admin/smart-routing/status는 후보 풀을 candidate_models(각 모델의 티어와 프로필 출처 포함)로, 제외된 모델을 excluded_unprofiled_models로 보여 줍니다.
  • 클러스터 내부 이벤트 브로커 터널의 종단 간 테스트와 실제 플릿 점검 런북을 추가합니다 (#1919, epic #1703). tests/dynamo_broker_tunnel_test.rs는 라우터의 이벤트 버스 리스너, 실제 continuum-event-broker 바이너리(crates/continuum-event-broker를 그 크레이트 자신의 target/이나 $CARGO_TARGET_DIR/continuum-event-broker에 빌드하거나 CONTINUUM_EVENT_BROKER_BIN에서 가져옴), kv_metrics와 kv-events를 발행하는 zeromq 발행자를 함께 실행하고, 브로커에는 가짜 Kubernetes API 서버가 DynamoWorkerMetadata를 제공합니다. 부하 저장소와 KV 인덱스가 같은 프레임을 직접 연결로 받았을 때와 정확히 같은 상태로 끝나는지, SIGKILL로 종료된 브로커가 새 브로커 프로세스가 다시 구독할 때까지 모든 구독을 pending으로 두고 부하는 오래되었지만 보관된 채 어떤 워커도 제외하지 않는지, 메타데이터가 광고하지 않은 발행자는 거부되는지 확인합니다. scripts/dynamo-broker-live-check.sh와 Dynamo 통합 가이드의 새 "브로커 터널 실환경 점검" 절은 kbm-g-np-kt-a에서의 실환경 점검(Pod가 버스에 닿는지, dynamo.load.max_staleness 대비 부하 신선도, 릴레이 지연)을 설명합니다. 이 점검은 아직 실행하지 않았습니다.
  • 여러 브로커에 걸쳐 Dynamo 네임스페이스에 따라 브로커 구독을 배치합니다 (#1920, epic #1703). dynamo.load.transport: broker에서 kv_metrics나 kv-events 구독은 이제 스트림이 가장 적은 세션 대신, 퍼블리셔의 네임스페이스를 포함하는 살아 있는 브로커 세션 가운데 고정된 랑데부 순위(퍼블리셔 대상과 토픽을 각 브로커 ID와 함께 SHA-256으로 해시)로 고른 세션으로 갑니다. 그래서 구독은 똑같이 자격이 있는 브로커들에 퍼지고, 재연결은 모든 실행과 라우터 레플리카에서 같은 브로커로 돌아가며, 브로커를 추가해도 열린 스트림은 옮겨지지 않고, 브로커를 잃으면 그 브로커의 구독만 각자의 다음 순위 브로커로 옮겨 갑니다. 브로커 세션이 끝난 구독은 백오프의 최솟값(최대 500ms, 늘어나지 않음) 뒤에 남은 포함 브로커로 다시 연결하므로 부하 신선도가 재연결 한 번 안에 회복되며, kv-events 커서는 이동하는 동안 이어집니다. pending에서 깨어난 구독은 첫 연결 전에 최대 500ms의 무작위 지연을 두고, 60초 동안 연결되지 못한 kv-events 구독은 랭크를 KV 인덱스에서 비웁니다(로그 기록). broker에서는 GET /admin/dynamo/workers에 broker_coverage(설정된 네임스페이스마다 covered, 포함하는 브로커 ID, 패밀리별 대기 구독 수)가 추가되고, 두 게이지가 설정된 네임스페이스마다 같은 내용을 보고합니다. 16개 네임스페이스 상한으로 제한되며 네임스페이스가 집합을 떠나면 제거됩니다. continuum_dynamo_broker_namespace_sessions{namespace}(0이면 포함하는 브로커 없음)와 continuum_dynamo_broker_pending_subscriptions{family, namespace}입니다. 라우터 레플리카끼리는 여전히 조율하지 않으므로, 포함하는 세션이 없는 레플리카는 구독을 대기 상태로 둡니다. 게이지는 250ms에 최대 한 번만 다시 계산되므로 대규모 장애 조치 중에도 보고 태스크가 과도하게 돌지 않습니다.

  • kv_metrics 부하 구독과 kv-events KV 구독이 함께 쓰는 dynamo.load.transport: direct | broker를 추가합니다 (#1917, epic #1703). 기본값은 direct입니다. broker이면 각 구독은 허가된 네임스페이스가 퍼블리셔의 Dynamo 네임스페이스를 포함하는 활성 dynamo.event_bus 브로커 세션으로 전달되고, 라우터의 유계 ZMTP 클라이언트, 디코더, 퍼블리셔 ID 검사, 시퀀스 중복 제거, kv-events 갭 처리가 직접 접속할 때와 똑같이 중계된 바이트 위에서 실행됩니다. 포함하는 세션이 없는 구독은 pending입니다. 아무 곳에도 접속하지 않고, 그 워커는 오래된 부하로 읽혀 라운드 로빈으로 선택될 뿐 제외되지 않으며, 포함하는 세션이 환영되는 즉시 시작합니다. 브로커 세션이 끝나면 그 구독은 평소의 백오프로 다시 연결하고, 포함하는 브로커가 남지 않으면 pending으로 끝납니다. broker에는 dynamo-event-bus 빌드 기능이 필요하며, 이 기능이 없는 바이너리는 시작 시 경고하고 직접 접속하는 대신 구독을 pending으로 유지합니다. 이 필드는 즉시 반영되며 구독만 다시 시작하고 탐색은 재시작하지 않습니다. GET /admin/dynamo/workers에 subscriptions(패밀리, 퍼블리셔 ID, 네임스페이스, 컴포넌트, 상태, 전송 방식, 전달 중인 브로커 ID; 퍼블리셔 엔드포인트는 절대 포함하지 않음)가 추가되고, continuum_dynamo_broker_subscriptions{family, broker}가 브로커별로 전달 중인 연결된 구독 수를 셉니다. 수신 오류 뒤의 소켓 닫기는 이제 1초로 제한되어, 멈춘 터널이 구독 태스크를 붙잡아 둘 수 없습니다. 구독은 broker 역할 세션만 운반하며 kv_listener 세션은 절대 사용하지 않고, 배치 가능한 브로커 세션 집합이 바뀔 때만 대기(pending) 중인 구독을 깨웁니다. 같은 퍼블리셔의 kv-events 구독이 다시 시작되면(전송 방식 전환 포함) 랭크별 이벤트 커서를 이어받으므로, 중지되었거나 대기 중이던 동안 놓친 이벤트는 갭으로 처리되며, 다시 이어지지 않을 퍼블리셔의 랭크는 KV 인덱스에서 비워집니다.

  • 이벤트 버스에 kv_listener 푸시 역할을 추가하여, 라우터가 도달할 수 없는 continuum-kv-listener가 라우터로 바깥 방향 연결을 맺고 KV 캐시 인덱스에 이벤트를 공급합니다 (#1921, 에픽 #1703). dynamo.event_bus.brokers[]에 role: broker | kv_listener(기본값 broker, 직렬화 시 생략되어 기존 파일이 그대로 왕복됨)와 backends(kv_listener 전용: 필수이며 비어 있지 않아야 하고, 각 항목은 [A-Za-z0-9_-] 1~64바이트, 중복 불가, 최대 256개)가 추가됩니다. namespaces는 계속 브로커 전용이며 각각 반대 역할에서는 로드 오류입니다. kv_cache_index.event_sources[]에는 transport: sse | event_bus(기본값 sse)가 추가되고, endpoint는 YAML에서 선택 항목이 되어 sse에서는 필수, event_bus에서는 금지이며 event_bus 소스의 backend_name은 유효한 소스 id여야 하고 다른 소스와 공유할 수 없습니다. continuum-kv-listener에는 선택적인 event_bus 섹션(url, tls_server_name, ca_file, client_cert_file/client_key_file, client_id, credential_file/credential_env, reconnect_backoff, drain_deadline, connect_timeout)이 추가되며 SSE 서버는 그대로 유지됩니다. 푸시 스트림은 GET /events/{backend}와 같은 바이트를 전달하고(공유 골든 픽스처로 검증), 라우터는 재연결 후 마지막으로 전달한 이벤트 id부터 이어받습니다(살아 있는 리스너 세션이 없는 소스는 reconnect_interval_ms 간격으로 재시도하며 더 늘리지 않고, SSE 레코드 하나의 data:는 줄 버퍼처럼 1 MiB로 제한됩니다). 와이어 계약은 프로토콜 버전 1을 유지하며 두 가지 추가 변경만 있습니다: AnnouncedSourceTarget.last_event_id(재개 위치)와 소스가 없는 kv_listener hello를 거부하는 Hello::validate 규칙입니다. 보안: 이벤트 버스는 계속 TLS 전용이며 토큰 해시 또는 mTLS로 인증하고, hello는 자격 증명의 항목이 설정된 역할에 대해서만 수락되며, 리스너는 자신의 항목이 허용한 백엔드에 대해서만 소스를 알리고 명령받을 수 있고(허용 범위를 좁히거나 역할을 바꾸는 리로드는 forbidden으로 세션을 닫음), 토큰은 YAML에 기록되거나 로그에 남지 않습니다. config validate와 시작 로그는 버스가 꺼져 있거나 허용되지 않은 버스 소스, 그리고 어떤 소스도 소비하지 않는 허용 항목에 대해 경고하며, 버스 소스는 컨트롤 플레인 허브 설정 동기화로 내보내지 않습니다. docs/ko/architecture/kv-cache.md, docs/ko/architecture/dynamo-integration.md, config.kv-listener.yaml.example을 참고하십시오.

  • 클러스터 내부 이벤트 브로커의 배포 자산을 추가합니다(#1918, 에픽 #1703). deploy/helm/continuum-event-broker는 GPU 클러스터의 플릿 네임스페이스에 설치하는 독립 차트이고, deploy/kubernetes/event-broker/는 이에 해당하는 독립 Kustomize 번들입니다. 라우터는 브로커 id마다 세션을 하나만 유지하고 같은 id의 두 번째 세션이 첫 번째를 대체하므로, 둘 다 브로커 식별자마다 Pod가 정확히 하나인 Deployment 하나를 실행합니다(예제에서는 둘). 감시하는 네임스페이스마다 dynamoworkermetadatas.nvidia.com에 대한 get, list, watch만 부여하는 Role을 렌더링하고, 각 브로커의 bearer 토큰은 기존 Secret에서(렌더링하지도, 값으로 받지도 않습니다), 라우터 CA 번들과 함께 마운트하며, /readyz와 /healthz를 프로브하고, 노드 드레인 중에도 브로커 하나를 유지하며, egress가 DNS, API 서버, 라우터의 버스 주소, 워커 발행자에만 도달하는 기본 거부 NetworkPolicy를 적용합니다. egress 목록을 비우면 모든 egress를 허용하는 규칙이 렌더링되지 않고 그 규칙이 사라집니다. 차트는 버스 URL이 없거나 평문일 때, 자격 증명 Secret이 없는 브로커가 있을 때, 브로커 id가 반복될 때 렌더링을 거부합니다. 라우터 쪽에서는 라우터 차트의 dynamoEventBus가 dynamo.event_bus 리스너를 노출하며(컨테이너 포트, 별도 Service, 읽기 전용 설정 디렉터리 밖에 마운트하는 TLS Secret, NetworkPolicy ingress 규칙), values-dynamo-event-bus.yaml이 완성된 오버레이입니다. scripts/validate-deployment-assets.sh는 이 모두를 린트하고 렌더링하여 RBAC, 자격 증명, 프로브, 식별자, egress 계약과 차트의 거부 동작을 검사하고, 라우터 리스너 포트, TLS 마운트, 설정된 경로가 일치하는지 확인합니다. 배포 가이드에 라우터 리스너, 브로커 자격 증명, 클러스터에서 라우터로의 도달성을 다루는 "클러스터 내부 이벤트 브로커" 절을 추가했습니다.

  • 클러스터 내부 Dynamo 이벤트 브로커 터널의 라우터 측을 추가합니다 (#1915, epic #1703). dynamo 위에 얹히는 새 dynamo-event-bus Cargo 기능(default/full에는 없고 공식 릴리스 바이너리에 포함)과, 기본적으로 꺼져 있고 dynamo.enabled와 독립적이며 항상 검증되는 dynamo.event_bus 섹션 뒤에 있습니다. 이 기능 없이 빌드된 바이너리는 시작 시 브로커 리스너가 비활성이라고 로그를 남깁니다. Kubernetes 클러스터 밖의 라우터는 워커의 Pod IP ZMQ 퍼블리셔에 접속할 수 없으므로, 클러스터 내부의 continuum-event-broker(#1916)가 dynamo.event_bus.listen의 전용 리스너로 바깥 방향 연결을 맺습니다. 이 리스너는 server.bind_address와 겹칠 수 없는 별도 포트이며 crates/continuum-event-bus 와이어 규약(#1913)의 POST /bus/v1/session과 POST /bus/v1/streams/{stream_id}를 제공합니다. 리스너는 ALPN h2의 TLS 1.3만 사용하며 첫 바이트에서 평문을 거부합니다(HTTP처럼 보이는 연결에는 최선의 노력으로 403 tls_required를 먼저 씁니다). 브로커는 SHA-256 값을 brokers[].token_hash로 설정한 bearer 토큰(토큰 자체는 설정하지 않음)을 상수 시간 비교로 인증받거나, tls.client_ca로 이어지고 DNS SAN이 자신의 id와 같은 mTLS 클라이언트 인증서로 인증합니다. hello는 역할 broker, 그 id와 같은 client_id, 그리고 브로커의 namespaces 허용 목록에 있는 Dynamo 네임스페이스만 선언해야 합니다(빈 허용 목록은 모든 네임스페이스를 받아들이며 config validate와 시작 로그가 경고합니다). 브로커당 세션 하나, 세션은 최대 max_sessions개(기본 64, 초과 시 429 too_many_sessions)와 연결 4 x max_sessions개(연결 후 10s 안에 수락된 요청이 없는 연결은 닫고 auth_timeout으로 집계하며, 마지막으로 수락된 요청이 끝나고 30s 뒤에도 닫습니다. 거부된 요청은 연결을 유지시키지 않습니다), 스트림은 최대 max_streams_per_session개(기본 1024)이며, 데이터 스트림은 해당 세션에서 라우터가 명령한 스트림 id에 대해서만 받고, 스트림별 버퍼와 HTTP/2 윈도는 max_session_buffer_bytes(기본 64 MiB, 스트림·방향당 32 KiB)에서 도출되므로 세션이 예산보다 많은 바이트를 전송 중에 보유하지 않습니다. 모든 필드는 재시작 없이 반영됩니다. enabled는 리스너를 시작하거나 드레인 후 중지하고, listen은 리스너를 재시작하며, TLS 파일은 새 연결을 위해 다시 읽히고, 브로커 제거, 해시 변경, client_ca 변경, 허용 목록 축소는 영향받는 세션만 닫으며(credential_revoked 또는 forbidden), client_ca가 바뀌면 이전 CA로 검증된 연결도 다시 인증할 수 없습니다. 시작에 실패한 리스너는 30초마다 다시 시도합니다. EventBus::open_stream은 ZMTP 커넥터(#1917)가 사용할 명령 API입니다. 새 닫힌 라벨 /metrics 패밀리: continuum_dynamo_event_bus_connections_refused_total{reason}, continuum_dynamo_event_bus_sessions, continuum_dynamo_event_bus_session_refusals_total{reason}, continuum_dynamo_event_bus_sessions_closed_total{reason}, continuum_dynamo_event_bus_streams, continuum_dynamo_event_bus_streams_opened_total, continuum_dynamo_event_bus_stream_refusals_total{reason}, continuum_dynamo_event_bus_streams_closed_total{reason}, continuum_dynamo_event_bus_bytes_total{direction}. docs/ko/architecture/dynamo-integration.md와 dynamo-event-bus 설정 템플릿을 참고하세요.

  • 이벤트 버스 터널의 클러스터 내부 쪽 끝인 continuum-event-broker를 추가합니다(#1916, 에픽 #1703). GPU 클러스터 밖의 라우터는 워커 Pod IP에 도달할 수 없으므로, 브로커가 클러스터 안에서 실행되어 HTTP/2와 TLS로 라우터 버스에 바깥 방향 연결을 맺고(https://만 허용, bearer 자격 증명은 파일이나 환경 변수에서 읽으며 로그에 남기지 않음), subscribe마다 Dynamo ZMQ 발행자의 원시 바이트를 ZMTP를 해석하지 않고 HTTP/2 스트림 하나로 중계합니다. 담당하는 Dynamo 네임스페이스의 DynamoWorkerMetadata event_channels에 광고된 tcp://host:port 발행자에만 접속하며, 이는 브로커 자체의 읽기 전용 list·watch로 확인합니다(RBAC: dynamoworkermetadatas.nvidia.com에 대한 get/list/watch만 필요). 선택적으로 이름 해석 이후에 검사하는 allowed_cidrs로 더 제한할 수 있고, 명시적으로 허용하지 않는 한 루프백, 링크 로컬 등 특수 용도 주소에는 접속하지 않습니다. 그 밖의 대상은 target_not_advertised, target_not_allowed, unsupported_target으로 거부하고 로그와 메트릭에 남깁니다. 스트림마다 라우터 방향으로 세션의 max_stream_buffer_bytes까지, 한 세션의 모든 스트림을 합쳐 최대 64 MiB까지만 버퍼링하고 청크마다 고정 오버헤드를 더해 계산하며, 어느 한도든 넘게 되는 스트림은 발행자를 멈추게 하는 대신 buffer_overflow로 닫습니다. 세션은 지터가 있는 지수 백오프로 다시 접속하고, SIGTERM을 받으면 draining을 보내고 열린 스트림을 drain_deadline까지 정리합니다. 스트림 열기는 라우터의 30초 스트림 열기 타임아웃 안에 들어오도록 제한됩니다(업스트림 연결은 해석된 모든 주소를 합쳐 최대 10초, 빈 HTTP/2 스트림 슬롯은 5초, 라우터의 응답 헤더는 10초). 기한이 지나면 업스트림 소켓을 닫고 스트림을 거부 또는 종료로 보고합니다. rebalance drain 뒤에는 즉시가 아니라 작은 지터가 있는 최소 대기 후 다시 접속하며, 반복되는 rebalance는 일반 백오프로 전환됩니다. 거부마다 남기던 경고 로그는 오류 코드별로 1분에 한 번과 생략 횟수로 제한하고, 거부 메트릭은 모든 거부를 셉니다. allowed_cidrs를 Pod CIDR로 설정하기를 강력히 권장합니다. 목록이 비어 있으면 특수 목적 주소가 아닌 모든 광고된 주소를 신뢰하기 때문입니다. Prometheus 메트릭(continuum_event_broker_*)과 /healthz, /readyz를 metrics_listen에서 제공합니다. Linux 릴리스 아카이브와 Debian 패키지에 continuum-event-broker(1) 매뉴얼 페이지와 함께 포함되며, ghcr.io/lablup/continuum-event-broker 이미지로도 배포됩니다. config.event-broker.yaml.example과 docs/ko/architecture/event-broker.md를 참고하십시오.

  • 백엔드별 http_version(auto, http1, http2)과 스트림 제한 관측 기능을 추가합니다 (#1925, epic #1703). HTTP/2에서는 라우터가 한 호스트로 가는 모든 요청을 하나의 연결에 다중화하므로, 서버의 SETTINGS_MAX_CONCURRENT_STREAMS(일반적인 인그레스에서 100, 128 또는 250)가 동시 요청 수의 상한이 되고, 엔진이 놀고 있는 동안에도 새 요청이 HTTP 클라이언트 안에서 대기합니다. http_version: http1은 해당 백엔드에 HTTP/1.1 전용 클라이언트를 따로 주어 이 상한을 없애며, auto(기본값)는 공유 클라이언트를 유지하므로 기존 설정은 그대로 동작하고, http2는 HTTP/2 prior knowledge를 강제합니다. unix: 백엔드 URL에 http2를 지정하면 로드 오류입니다. 이 필드는 관리자 백엔드 API에서 받아들여지고 설정 스키마에 나열되며 디스패치마다 읽히므로, 핫 리로드는 진행 중인 요청을 끊지 않고 새 요청부터 전환합니다. 새 /metrics 패밀리: continuum_backend_http_in_flight{backend, http_version}(스크레이프 시점에 샘플링한 진행 중 요청 수)와 continuum_backend_response_wait_seconds{backend, http_version, outcome}(요청을 HTTP 클라이언트에 넘긴 시점부터 전송이 끝날 때까지의 시간, outcome은 response, error, cancelled)로, 정체는 서버의 스트림 제한 수준에서 진행 중 요청 수가 평평해지고 대기 시간 꼬리가 길어지는 모습으로 나타납니다. docs/ko/configuration/backends.md와 docs/ko/metrics.md를 참고하세요.

  • KV 오프로딩 효과를 백엔드별로 /metrics에 보고합니다 (#1565, #1877). 새 continuum_backend_cached_prompt_tokens_total{backend}는 요청을 처리한 백엔드가 보고한 캐시 프롬프트 토큰(prompt_tokens_details.cached_tokens, Responses input_tokens_details.cached_tokens, Anthropic cache_read_input_tokens)을 더하고, 새 continuum_backend_ttft_seconds{backend, source}는 프로바이더 디스패치부터 라우터가 잰 첫 토큰까지의 시간을 source로 streamed_first_token과 response_headers(비스트리밍 호출의 헤더 도착)로 나눠 관측하며, 버킷은 25 ms부터 60 s까지로 로컬 프리픽스 적중, KV 커넥터 적중, 콜드 프리필을 구분합니다. 두 패밀리는 control-plane 기능이 없는 기본 릴리스 빌드에서도 기록되며, 이를 위해 스트림 종료 시점에 쓰는 디스패치 시각을 모든 빌드에서 기록합니다. 요청은 한 번만 세며 로컬 캐시 재생, 실패, usage가 없는 응답은 아무것도 더하지 않습니다. 스트리밍 /v1/responses는 이제 Hub 에이전트나 정책이 활성일 때만이 아니라 모든 빌드에서 스트림 종료 usage 컨텍스트를 만들므로 두 패밀리를 모두 기록하며, 스트리밍 채팅 완성과 마찬가지로 토큰을 llm_tokens_total과 model_experiment_tokens_total에도 더합니다. backend 레이블은 기존 정제기와 카디널리티 가드를 쓰며, /metrics 스크레이프가 풀에 없는 백엔드의 시리즈를 지웁니다. vLLM은 --enable-prompt-tokens-details를 켜야 캐시 토큰을 보고합니다. vLLM 엔진 통계 어댑터는 이제 vllm:external_prefix_cache_queries와 vllm:external_prefix_cache_hits도 읽어 새 backend_engine_external_prefix_cache_hit_ratio 게이지(LMCache 같은 KV 커넥터 적중, 로컬 GPU backend_engine_prefix_cache_hit_ratio와 별도)로 내보내며, 커넥터 없이 실행된 엔진에서는 이 시리즈가 없습니다. SGLang에는 대응하는 카운터 쌍이 없어 이 게이지는 vLLM 전용입니다. continuum_kv_index_routing_decisions_total에는 선택된 백엔드가 프리픽스를 보유한 위치를 그 백엔드를 고른 스코어링 호출이 함께 돌려주어 동시 요청이 지울 수 없는 tier 레이블(gpu_hot, storage_warm, none)이 추가되며, 패밀리 전체를 합산하는 쿼리는 영향을 받지 않습니다. Grafana 라우터 개요 대시보드에 네 항목의 패널을 추가했습니다.

  • Dynamo 플릿을 위한 KV 오버랩 인지 prefill 선택 (#1702, epic #1703). dynamo 위에 얹히는 새 dynamo-kv Cargo 기능(공식 릴리스 바이너리에 포함, default/full에서는 꺼짐, embed 그래프에서는 거부)과, 모든 필드가 즉시 반영되는 새 상시 컴파일 dynamo.kv 섹션 뒤에 있습니다. kv.enabled: true이면 라우터는 각 워커의 kv-events 채널(kv_metrics와 같은 v1/event_channels/ 레코드에서 탐색하고, 같은 유계 ZMTP 클라이언트로 블록 배치용 1 MiB 프레임 상한 아래에서 읽으며, ActiveLoad처럼 위치 허용적으로 디코딩)을 구독해 플릿별 유계 radix 인덱스에 넣습니다. 인덱스는 루트부터 tokens_hash로 키가 잡히고 rank별 소유를 기록하며(전체 1,048,576 블록, rank당 262,144 블록, 레지스트리의 라이브 워커 집합과 각 워커 카드가 선언한 data-parallel rank로 게이트, 블록 소유는 엔진 해시마다 세어 두 번 저장된 블록이 한 번의 제거로 사라지지 않음, 워커가 떠나면 제거; event_id 갭은 그 rank를 비우고 gap으로 집계하며, 요청 플레인 스냅샷 복구가 없으므로 사라진 부모 아래의 배치는 체인이 다시 루트에 붙을 때까지 orphan_parent로 집계), 각 요청을 모델 카드의 tokenizer / prompt_formatter / chat_template_file 슬롯에서 해석한 플릿 자체 토크나이저와 채팅 템플릿으로 렌더링·토큰화하고(kv.artifact_cache_dir 아래 blake3 검증·내용 주소 캐시; 로컬 경로는 항상 허용, 워커의 메타데이터 URL은 kv.allow_http_artifacts(기본 켜짐), Hub는 kv.allow_hf_artifacts(기본 꺼짐)), 그것도 파일의 체크섬이 운영자의 kv.pinned_artifacts에 있을 때만 그렇게 합니다(기본값은 비어 있음: 채팅 템플릿은 플릿이 작성한 코드이고 minijinja는 그 메모리를 제한할 수 없으므로(fuel은 명령을 셀 뿐이고 문자열 연결 한 번이 무제한으로 할당 가능), 핀되지 않은 파일은 가져오지도, 파싱·컴파일·렌더링하지도 않습니다. 그 모델은 KV 항 없이 artifact_unpinned로 채점되고, 로그는 검토를 위해 슬롯·URI 종류·카드가 선언한 체크섬을 모델과 아티팩트당 한 번 남기며, 핀이 제거되면 그 파일로 만든 로드된 토크나이저는 즉시 버려지고, config validate는 kv.enabled인데 아무것도 핀되지 않았으면 경고하고 잘못된 항목은 인덱스를 지목하며 거부합니다), dynamo-kv-hashing 1.5.0으로 프롬프트를 해시하여(전체 블록마다 little-endian u32 id에 대한 XXH3-64, 요청의 cache_salt와 LoRA 이름으로 시드, 블록 크기는 카드에서, 요청당 16에서 65536 사이의 크기 최대 두 개, 렌더링과 함께 블로킹 풀에서 해시) 워커의 tokens_hash와 바이트 단위로 같은 해시를 얻고, kv.overlap_score_credit, kv.prefill_load_scale, kv.router_temperature로 Dynamo 자체 비용 공식을 적용합니다(0에서는 커서로 동점을 나누는 argmin, 그 위에서는 upstream의 softmax 샘플링). rank의 백로그는 dynamo.load.max_staleness보다 새로운 샘플이 있을 때만 셈합니다. 그런 샘플이 있는 rank가 없는 rank보다 우선하고, 아무 rank에도 없는 플릿(dynamo.vllm 플릿은 active_prefill_tokens를 게시하지 않음)은 오버랩만으로 정렬되며, 아무것으로도 구별되지 않는 rank는 round_robin_stale로 순환합니다. decode는 kv.conditional_disaggregation이 켜지지 않는 한 부하 전용으로 남고, 켜지면 isl_bounding / prefill_load / isl_or_load 우회와 decode-busy 게이트를 구현해 x-dynamo-worker-instance-id만 보냅니다. Dynamo 1.5.x direct 모드 frontend는 prefill 워커가 등록된 동안 이를 거부하므로 config validate가 경고합니다. kv.send_token_data는 렌더링한 id를 nvext.token_data로 보냅니다(클라이언트가 보낸 값을 덮어씀). 프롬프트는 shim 풀에 실제로 보내질 본문(스트리밍의 global_prompts와 단일 선두 system 병합 포함)으로 렌더링하며, 풀의 레플리카마다 보내질 본문이 다르면 id를 보내지 않습니다. 아직 로드되지 않은 토크나이저, 1 MiB 렌더 상한을 넘는 프롬프트, 빈 인덱스, 용량 없는 카드는 어떤 워커도 제외하지 않으며, 그 요청은 #1699의 부하 전용 규칙이 처리합니다. 새 닫힌 라벨 패밀리 continuum_dynamo_kv_subscriptions{state}, continuum_dynamo_kv_events_total{result}, continuum_dynamo_kv_index_blocks, continuum_dynamo_kv_selection_total{result}, continuum_dynamo_kv_artifacts_total{result}가 추가되고, continuum_dynamo_selection_total{reason}에 kv_aware와 conditional_bypass가 더해지며, GET /admin/dynamo/workers는 kv_event_publishers를 보고합니다. dynamo-kv-aware 템플릿이 내장됩니다. 의존성 결정은 Cargo.toml에 기록되어 있습니다. dynamo-kv-router는 측정했지만(새 크레이트 16개, 런타임 없음) 유계가 아닌 인덱서가 잘못된 플릿 작성 입력에 panic = "abort" 아래에서 패닉하므로 채택하지 않았고, dynamo-tokenizers / dynamo-renderer는 C·C++ 백엔드를 끌어오므로 채택하지 않고 이들이 감싸는 순수 Rust tokenizers와 minijinja를 직접 씁니다. kv-events 픽스처는 고정된 v1.5.0 소스에서 독립 인코더로 합성한 것이며, 사용 가능한 유일한 플릿이 use_kv_events=False로 돌기 때문에 실제 캡처를 요청합니다.

  • 워커가 쓰는 DynamoWorkerMetadata 리소스와 어느 것이 준비되었는지 알려 주는 EndpointSlice로 Kubernetes의 Dynamo 플릿을 탐색합니다 (#1701, epic #1703). dynamo.discovery.source: kubernetes는 kubernetes_pods와 dynamo.discovery.kubernetes 블록(namespace, dynamo_namespace, watch_reconnect_backoff; model은 kubernetes_pods에서만 필수이고 여기서는 무시되며 설정되어 있으면 config validate가 경고)을 공유하고, Dynamo 자체 탐색 데몬이 Kubernetes 탐색 모드에서 읽는 것을 읽습니다. 각 워커의 spec.data는 etcd 플릿이 등록하는 것과 같은 엔드포인트·모델 카드·이벤트 채널 레코드를 담으므로, 라우터는 이를 etcd가 보관했을 키로 etcd reducer에 넘기고, Kubernetes 플릿은 etcd 플릿과 같은 워커, 용량, data-parallel rank, 보존된 카드, kv_metrics 부하 게시자를 냅니다. GET /admin/dynamo/workers는 "signals": {"load": true, "capacity": true}를 보고합니다. 워커는 그 Pod를 가리키는 EndpointSlice 엔드포인트가 준비된 동안에만 게시되며, upstream 데몬처럼 targetRef.name으로 조인합니다. 엔드포인트나 카드의 id가 이름의 hash_pod_name과 다른 리소스는 통째로 거부되어 continuum_dynamo_discovery_events_total{kind="mismatch"}로 집계되고, container 탐색 모드의 컨테이너 단위 리소스(다른 이름의 Pod가 소유)는 거부되어 kind="refused"로 집계됩니다. 두 리소스 타입을 함께 list하고 watch하며, 어느 watch든 끝나면 둘 다 다시 list하고, reducer는 차분으로 공급되므로 바뀌지 않은 재 list는 아무것도 집계하지 않고 first_seen은 준비 상태가 오가도 유지됩니다. 레코드의 전송 주소(이 모드에서는 Pod IP)는 etcd에서처럼 보유하되 절대 내보내지 않습니다. 두 list는 500개씩 페이지로 읽다가 16384개에서 멈추고, kubernetes_pods처럼 watch마다 유휴 데드라인이 있으며 서버 타임아웃보다 한참 일찍 닫힌 watch는 오류이고, 두 watch가 모두 열리기 전에는 아무것도 reducer에 반영하지 않습니다. 1 MiB 보존 카드 상한을 넘는 모델 카드는 리소스를 읽을 때 타입 부분집합으로 줄이고, 보유한 리소스의 레코드 바이트는 합쳐서 최대 256 MiB이며(이를 넘는 새 리소스는 거부되어 malformed로 집계), 두 리소스가 함께 공급하는 reducer 키는 마지막 공급자와 함께만 빠지고, id를 담기에 너무 짧은 엔드포인트나 카드 경로는 mismatch이며, spec이 오브젝트가 아닌 리소스는 list 전체를 실패시키지 않고 malformed로 집계됩니다. Helm 차트는 dynamoPodDiscovery.metadata: true 뒤에서 같은 Role에 dynamoworkermetadatas.nvidia.com과 endpointslices.discovery.k8s.io에 대한 get/list/watch를 추가하고, deploy/kubernetes/overlays/dynamo-metadata-discovery/가 Kustomize 옵트인이며, 새 dynamo-kubernetes 템플릿이 내장됩니다. 와이어 형태는 upstream v1.4.2에 고정하고 v1.5.0과 대조했으며, 픽스처는 캡처가 아니라 실제 Pod 이름 네 개와 id를 중심으로 그 소스에서 합성한 것이고 실제 캡처를 요청합니다.

  • Kubernetes에서 Pod만으로 Dynamo 플릿을 탐색합니다 (#1869, epic #1703). dynamo.discovery.source: kubernetes_pods(기본값은 계속 etcd)와 dynamo.discovery.kubernetes 블록(namespace, dynamo_namespace, model, watch_reconnect_backoff)을 쓰면, 라우터가 DynamoWorkerMetadata나 EndpointSlice를 읽을 수 없는 클러스터에서 pods에 대한 get/list/watch만으로 Dynamo operator가 Kubernetes 탐색용으로 라벨을 붙인 Pod를 list하고 watch합니다. Pod는 Running이고 Ready이며 종료 중이 아닐 때 워커이고, 역할은 operator의 component-type 라벨에서, 인스턴스 ID는 upstream의 hash_pod_name(Pod 이름에 대한 키가 0인 SipHash-1-3을 53비트로 마스킹)에서 나오며, 이는 로컬로 구현되어 실제 1.4.2 플릿에서 읽은 ID로 고정됩니다. Pod에는 모델 카드도 부하 게시자도 없으므로 이 소스는 부하와 용량을 제공하지 않고 shim picker는 라운드로빈으로 순환합니다. config validate가 이를 경고하고, GET /admin/dynamo/workers는 이제 source, signals, 워커별 10진수 worker_id를 보고합니다. container 탐색 모드 Pod는 거부되어 continuum_dynamo_discovery_events_total{kind="refused"}로 집계되며, continuum_dynamo_discovery_connected에 source 라벨이 추가됩니다. 소스를 바꾸거나 활성 블록을 고치는 리로드는 watcher를 교체하고 레지스트리를 비웁니다. Helm 차트는 dynamoPodDiscovery.enabled(기본값 꺼짐) 뒤에서 네임스페이스 범위 읽기 전용 Role과 RoleBinding을 렌더링하고, deploy/kubernetes/overlays/dynamo-pod-discovery/가 Kustomize 옵트인입니다. dynamo 섹션의 필드 규칙은 이제 config validate뿐 아니라 로드와 핫 리로드 경로에서도 적용됩니다. dynamo 기능에 kube와 k8s-openapi(rustls, kube-runtime 없음)가 추가되며, 둘 다 embed 그래프에서 금지됩니다.

  • 여러 Dynamo 네임스페이스에 걸쳐 탐색하고 고정합니다 (#1910, #1884·#1870 후속, epic #1703). dynamo.discovery.etcd.namespaces와 dynamo.discovery.kubernetes.dynamo_namespaces는 라우터 하나가 앞에서 받는 모든 DynamoGraphDeployment의 DYN_NAMESPACE를 나열하고(중복 없이 최대 16개, 블록마다 스칼라와 목록 중 정확히 하나; 단일 네임스페이스 파일은 그대로), dynamo.shim.pools: [{namespace, backends}]는 네임스페이스마다 자기 --router-mode direct frontend 복제본을 묶습니다. 네임스페이스가 둘 이상이면 필수이며, 네임스페이스마다 풀 하나, 백엔드는 두 풀에 속할 수 없습니다. 탐색된 모든 워커는 발견된 네임스페이스(etcd 키 프리픽스, 메타데이터 레코드 경로, 또는 Pod의 nvidia.com/dynamo-namespace 라벨. Pod 소스는 이제 in (a,b)로 선택)를 지니고, 식별자는 (namespace, instance_id)이며, GET /admin/dynamo/workers에 namespaces와 워커별 namespace가 추가되고, continuum_dynamo_workers와 워커별 부하 게이지 셋에 namespace 라벨이 붙습니다. 요청은 고정된 워커가 속한 네임스페이스의 풀로만 디스패치되고, 풀에 쓸 수 있는 복제본이 없는 네임스페이스는 잘못 고정되는 대신 건너뛰며, prefill/decode 쌍은 항상 한 네임스페이스에서 나오고, 한 모델 이름을 서비스하는 네임스페이스들 사이에서는 picker의 평소 부하 비교가 결정합니다. kubernetes_pods의 models 항목에는 선택적 dynamo_namespace가 생기며 네임스페이스가 여럿이면 필수입니다. 네임스페이스를 핫 리로드로 넣거나 빼면 그 네임스페이스의 워커만 가지치기하고 나머지는 빈틈 없이 게시된 채 유지됩니다(전환 namespaces). 소스, 연결, Kubernetes 네임스페이스 변경은 여전히 레지스트리를 비웁니다. 리로드 중에는 실시간 설정보다 늦은 스냅샷에서 pin하지 않습니다. shim 경로는 둘 다에 있는 네임스페이스만 picker에 제공하고, 다른 탐색 소스의 스냅샷에서는 아무것도 pin하지 않습니다.

  • kubernetes_pods Dynamo 탐색 소스에서 워커 컴포넌트를 모델에 매핑합니다 (#1885, #1869 후속). 한 Dynamo 네임스페이스에 서로 다른 모델을 서빙하는 워커 풀이 여럿 있을 수 있고, 이를 구분하는 값은 오퍼레이터의 nvidia.com/dynamo-component Pod 라벨뿐이어서, 모든 Pod를 하나의 dynamo.discovery.kubernetes.model로 게시하면 한 모델의 요청이 다른 모델의 워커에 고정되었습니다. 새 dynamo.discovery.kubernetes.models 목록({component, model} 항목, model과 상호 배타적이며 kubernetes_pods에서는 둘 중 정확히 하나가 필요)은 준비된 각 워커 Pod를 대소문자까지 정확히 일치하는 nvidia.com/dynamo-component 값에 매핑된 모델로 게시합니다. 컴포넌트가 목록에 없거나 그 라벨이 없는 준비된 워커 Pod는 보유만 되고 게시되지 않으며, 나타날 때마다 한 번 debug로 로그에 남고 malformed나 refused로 집계되지 않습니다. 각 항목에는 유효한 라벨 값과 모델 이름이 필요하고, 같은 컴포넌트는 반복될 수 없으며(두 컴포넌트가 한 모델을 공유하는 것은 허용), 목록은 최대 256개입니다. 오류는 dynamo.discovery.kubernetes.models[1].component처럼 항목을 가리킵니다. models 변경은 model 변경과 같이 watcher를 교체하고 레지스트리를 비웁니다. model과 마찬가지로 models도 source: kubernetes(#1701)에서는 무시되며, config validate가 설정되어 있다고 경고하고 고쳐도 아무것도 재시작하지 않습니다. dynamo.shim.model_map은 피커가 찾는 모델 이름만 바꾸므로 대안이 아닙니다. 단일 model 파일은 이전과 똑같이 파싱되고 동작합니다.

  • control-plane 사용량 레코드에 요청별 품질 판정과 모델 실험 arm을 붙입니다 (#1728, epic #1719; 프로토콜 변경 continuum-hub#1264). 선택적 usage.quality_judgment 블록(새 최상위 usage 섹션)은 비스트리밍 /v1/chat/completions 응답이 모든 가드레일 단계를 통과한 뒤 System One 결정 모델에 그 응답이 요청에 답하는지, 거절하는지, 요청과 같은 언어로 쓰였는지(요청에 문자가 있을 때만 질문), 그리고 include_tool_results: true이고 현재 턴에 도구 결과가 있을 때만 그 결과와 일치하는지를 묻습니다. 답은 해당 요청의 사용량 레코드에 quality(answered_bps, refusal_bps, 선택적 grounded_bps와 language_match_bps, round(p * 10000)으로 만든 정수 basis point, 그리고 jev-1.13.0 같은 해석된 judge_model)로 실립니다. 답이 없거나 유한하지 않거나 QualityJudgment::validation_problem에 걸리면 판정만 버리고 레코드는 유지합니다. 클라이언트 응답은 기다리지 않으며, 레코드의 회계(router_usage_seq, cost-center와 모델 예산 차감)와 푸시도 기다리지 않습니다. 판정은 사용량 루프가 레코드를 푸시 배치로 만들 때 봉인하는 슬롯을 채우므로, 그보다 늦게 끝난 판정은 레코드의 idempotency_key로 다시 보내지 않고 버리며 late로 집계합니다. sample_rate(기본 1.0), models, 앞뒤를 남기는 상태 상한(512 / 1,536 / 1,024바이트), 라우터별 max_in_flight 상한(기본 32, 초과분은 대기열에 넣지 않고 버리며 집계)이 비용을 제한하고, 결정 모델에는 마지막 사용자 메시지와 응답(옵트인 시 도구 결과 포함)만 전달됩니다. 모델 실험 요청의 모든 사용량 레코드는 판정 여부와 관계없이 experiment_id와 experiment_variant_id를 함께 싣습니다. 새 메트릭 usage_quality_judgments_total{outcome}, 새 템플릿 usage-quality-judgment. 벤더링된 continuum-protocol 크레이트는 허브의 추가형 QualityJudgment와 UsageRecord 멤버 세 개를 바이트 단위로 그대로 반영하며, PROTOCOL_VERSION은 0으로 유지됩니다. 스트리밍 응답(#1801부터 판정), /anthropic/v1/messages, /v1/responses, /v1/completions 인그레스, disaggregated_serving 또는 Dynamo shim으로 제공되는 비스트리밍 채팅은 판정하지 않습니다. 늦은 판정은 가장 느린 판정이므로 late 비율이 높으면 실린 판정이 빠른 요청 쪽으로 치우치며, control_plane.usage_push_interval_secs를 늘리거나 더 빠른 자체 호스팅 타깃을 쓰면 줄어듭니다.

  • usage.quality_judgment가 스트리밍 /v1/chat/completions 응답도 판정합니다 (#1801, #1728 후속). 스트리밍 요청은 입력 가드레일 직후에 샘플링하고, 스트림 종료 사용량 레코드가 만들어지는 순간 클라이언트가 읽은 텍스트의 앞부분과 뒷부분으로 판정합니다. 이 텍스트는 max_reply_bytes로 제한된 메모리에 보관하며 버퍼링한 응답과 정확히 같은 방식으로 자르고, 추론 텍스트는 읽지 않습니다. 레코드와 전체 응답이 같은 순간에 존재하므로 control_plane.usage_push_interval_secs보다 충분히 빨리 답하는 타깃이면 late 판정이 거의 없습니다. 출력 가드레일이 스트림을 끊거나 바꿔 썼을 때(buffer_full 또는 chunked 차단, 마스킹), mid-stream 폴백 홉이 두 생성을 한 응답으로 이어 붙였을 때, 스트림에 오류나 시간 초과가 있었을 때, 클라이언트가 연결을 끊었을 때는 판정하지 않고 skipped로 셉니다. usage_quality_judgments_total에 stream 레이블("true" 또는 "false")이 추가되어 두 방식이 한 비율을 공유합니다. 네이티브 Anthropic, Gemini, Bedrock 백엔드, 유닉스 소켓, disaggregated_serving 또는 Dynamo shim으로 제공되는 스트리밍 채팅과 /anthropic/v1/messages, /v1/responses는 여전히 판정하지 않습니다 (#1803, #1804).

  • usage.quality_judgment가 /anthropic/v1/messages 응답을 버퍼링과 스트리밍 모두, 모든 백엔드 경로에서 판정합니다: HTTP 또는 유닉스 소켓의 네이티브 Anthropic, Bedrock mantle, runtime, converse, Gemini를 포함한 OpenAI 와이어 변환, responses_only 모델 (#1803, #1728의 후속). 이 와이어에서는 도구 결과가 user 메시지 안에 실리므로 판정할 사용자 턴은 tool_result가 없고 Claude Code의 <system-reminder> 블록 외에 공백이 아닌 텍스트가 있는 마지막 role: user 메시지이며, 액션 안전성 평가기와 같은 규칙입니다. reminder 블록, 최상위 system, role: system 메시지, thinking 블록은 라우터 밖으로 나가지 않으며, tool_result 텍스트는 include_tool_results일 때만 나갑니다. 응답은 클라이언트가 받는 text 블록입니다. tool_use나 server_tool_use 응답, pause_turn 종료, 출력 가드레일 차단, 마스킹되거나 끊긴 스트림, error 이벤트를 전달한 스트림, 클라이언트 연결 끊김(마지막 이벤트 뒤의 끊김 포함)은 skipped로 세고, max_tokens 종료는 판정합니다. web_search_20250305 서버 도구 에뮬레이션과 응답 캐시 적중은 판정하지 않습니다.

  • usage.quality_judgment가 /v1/responses 응답을 버퍼링과 스트리밍 모두, 모든 전략에서 판정합니다: responses_only 모델을 포함한 openai, azure 백엔드와 #1987 이후 responses_api: native인 모든 백엔드로의 패스스루와 채팅, Anthropic, Gemini 변환입니다 (#1804, #1728 후속). 판정할 사용자 턴은 요청 자신의 input에서 하네스 텍스트(<system-reminder>, 그리고 <user_instructions>, <environment_context>, # AGENTS.md instructions 같은 Codex 하네스 메시지) 외에 공백이 아닌 텍스트가 있는 마지막 user 메시지이므로, previous_response_id 이어가기 요청은 델타만으로 판정하고 input에 function_call_output 항목만 있는 요청은 skipped로 셉니다. instructions, system과 developer 메시지, 추론 항목은 라우터 밖으로 나가지 않으며, 도구 출력은 include_tool_results일 때만 나갑니다. 응답은 클라이언트가 받는 message 항목의 output_text이고(출력 가드레일이 마스킹한 버퍼링 응답은 마스킹된 텍스트로 판정), 스트리밍이면 종료 이벤트 response.completed 또는 response.incomplete에서 읽습니다. 클라이언트가 실행하는 호출 항목, failed나 cancelled 상태, 출력 가드레일 차단, 마스킹되거나 끊긴 스트림, 클라이언트 연결 끊김은 skipped이고, status: incomplete 응답은 판정합니다. responses_only 모델을 위해 업스트림 /v1/responses로 브리지되는 /v1/chat/completions 요청도 이제 버퍼링과 스트리밍 모두 판정합니다(스트리밍은 #1809가 추가한 스트림 종료 레코드에 실립니다). 응답 캐시 적중과 /v1/responses/compact는 판정하지 않습니다.

  • 클라이언트용 System One 결정 엔드포인트 POST /v1/decisions와 별칭 /v1/systemone을 추가했습니다 (#1726, 에픽 #1719). 새 decisions.targets[](이름과 공유 systemone 블록)에 등록된 model은 해당 System One 서버로 그대로 전달되며, usage.output_tokens를 0으로 보고하는 것 외에는 응답을 바꾸지 않습니다. 그 외 이름은 같은 이름의 채팅 모델에서 temperature: 0의 강제 emit_decision 도구 호출 한 번으로 에뮬레이션하고, one-hot 확률, confidence 없음, emulated: true로 응답합니다. 질문 맵은 디스패치 전에 검증하며(옵션이 256개인 choice는 422, 앞뒤 공백이 있는 model은 400), 차단 모드에서는 write 스코프가 필요하고, 키별·모델별 속도 제한, 허용 목록, 예산이 채팅 완료와 똑같이 적용됩니다. allowed_backends가 비어 있지 않은 키는 인라인 엔드포인트 타깃에 도달하지 않고, 에뮬레이션 홉은 채팅의 request_params 정책과 입력 가드레일을 거치며 별칭과 모델 실험도 채팅처럼 해석하고, 사용량은 실제로 응답한 모델 이름으로 호출자 키에 기록됩니다. 타깃 이름은 fallback.fallback_chains에서도 쓸 수 있어 실패한 실제 타깃이 에뮬레이션 채팅 모델로 폴백할 수 있습니다. 이 섹션은 선택 사항이며 즉시 반영되고, 없으면 에뮬레이션만 활성화된 상태입니다. 새 decisions-endpoint 설정 템플릿을 추가했습니다.

  • Continuum Hub에서 스마트 라우팅 정책과 가드레일 프로바이더 콘텐츠를 관리합니다 (에픽 #1706, 허브 에픽 continuum-hub#1212). 이번 릴리스인 v1.32.0이 둘을 모두 실행하는 첫 라우터 버전입니다. 새 smart_routing_policy_v1 기능 아래의 smart_routing과, 기존 guardrail_policy_v1 선언 및 그 guardrail_policy_digest 안에 있으며 새 guardrail_provider_content_v1 기능으로 광고되는 provider_content입니다 (#1818). v1.32.0보다 오래된 라우터는 smart_routing_policy_v1과 guardrail_provider_content_v1을 모두 광고하지 않으므로 허브는 둘 다 unsupported로 보고합니다. guardrail_provider_content_v1로 게이트하는 허브는 v1.17.0에서 v1.31.x 사이의 라우터에 프로바이더 콘텐츠를 보내지 않으므로, 이 라우터는 나머지 가드레일 정책(모드, 단계, 임계값, 라우트)을 계속 받습니다. 게이트 없이 프로바이더 콘텐츠를 보내는 허브에 대해서만 이 라우터가 가드레일 선언 전체를 digest_mismatch로 거부하고 이전 가드레일 정책을 유지합니다. hub#1212 이전의 허브에는 새 기능 문자열 외의 변화가 없습니다. 어느 스트림의 선언도 프로바이더나 백엔드의 엔드포인트, 자격 증명, api_key_env, bypass_api_keys, 스트리밍 버퍼 튜닝, 분류기 백엔드의 엔드포인트나 자격 증명에 닿을 수 없고, 로컬 enforce를 약화하거나 로컬 smart_routing.enabled: false를 덮어쓸 수 없습니다. 새 스마트 라우팅 거버넌스 문서, 가드레일 가이드의 프로바이더 콘텐츠 섹션, 아키텍처 가이드의 거버넌스 호환성 매트릭스가 이 표면을 설명하며, 새 통합 테스트 세 개가 이를 고정합니다. control_plane_governance_sovereignty_test, control_plane_governance_compatibility_test(재시작과 혼합 버전 동작), 그리고 문서의 실제 예시를 실행하는 control_plane_governance_docs_examples_test입니다 (#1713).

  • Continuum Hub에 guardrail_provider_content_v1을 광고합니다 (#1818, 허브 에픽 continuum-hub#1212). 가드레일 정책 리컨실러가 연결된 라우터는 등록과 모든 하트비트에서 RouterPolicyStatus.capabilities에 이 기능을 guardrail_policy_v1 바로 뒤에, 같은 래치로 싣습니다. 따라서 두 기능은 함께 나타나거나 함께 빠집니다. 허브는 provider_content를 이 기능으로 게이트합니다(hub#1221). 이 기능을 광고하지 않는 라우터는 provider_content를 뺀 가드레일 선언을 그 축소된 정책으로 계산한 다이제스트와 함께 받으며, 허브는 해당 라우터에서 콘텐츠를 unsupported로 보고합니다. 선언 형태, 다이제스트, #1709와 #1711의 거절 코드, PROTOCOL_VERSION(0)은 바뀌지 않았고, provider_content가 없는 선언도 그대로 수락되고 확인 응답됩니다. 벤더링한 continuum-protocol 크레이트는 업스트림 재동기화에 앞서 GUARDRAIL_PROVIDER_CONTENT_CAPABILITY를 로컬에 추가합니다 (hub#1215).

  • Continuum Hub에 smart_routing_policy_v1을 광고합니다 (#1712). 허브 리컨실러가 스마트 라우팅을 관리하는 라우터(control_plane.policy가 활성화된 설정 파일로 시작한 control-plane 빌드)는 이제 등록과 모든 하트비트에서 RouterPolicyStatus.capabilities에 이 기능을 싣습니다. 따라서 허브는 이 라우터를 더 이상 unsupported로 보고하지 않고, 확인 응답된 smart_routing_policy_digest와 타입이 있는 거절로부터 pending, active, error를 도출합니다. 기능은 확인 응답을 켜는 것과 같은 래치에서 광고되므로, #1711의 실행 기반 확인 응답과 타입이 있는 거절 없이 나타나는 일이 없습니다. 로컬 smart_routing 섹션이 enabled: false인 라우터도 이 기능을 광고하며 모든 허브 정책을 smart_routing_unavailable로 보고합니다(권위 있는 해제는 계속 확인 응답됩니다). 이는 가드레일이 정책을 실행할 수 없는 라우터에서도 guardrail_policy_v1이 계속 광고되는 방식과 같습니다. guardrail_policy_v1과 그 다이제스트 확인 응답은 바뀌지 않았으며, 같은 다이제스트에 실리는 provider_content를 이미 포함합니다. 라우터가 provider_content를 실행한다는 사실은 별도의 guardrail_provider_content_v1 기능(#1818)으로 허브에 알립니다. smart_routing을 보내지 않는 허브에는 다른 변화가 없습니다.

  • 거절된 허브 스마트 라우팅 및 가드레일 선언을 타입이 있는 코드로 보고하고, 다이제스트는 실제로 실행된 뒤에만 확인 응답합니다 (#1711). 봉투를 적용할 때 거절된 스마트 라우팅 본문은 RejectedPolicyRevision.smart_routing_policy_digest에 invalid_smart_routing_policy(검증 실패, 또는 이 버전이 해석할 수 없는 티어나 분류기 방식) 또는 digest_mismatch로 보고되며 합성 단계에 도달하지 않습니다. 유효하지만 이 라우터가 실행할 수 없는 선언은(로컬 섹션이 enabled: false인 경우 포함) 활성 다이제스트 대신 smart_routing_unavailable로 보고됩니다. 스마트 라우팅과 가드레일 모두, 수락된 다이제스트는 리컨실러가 그 실행을 기록한 뒤에만 활성으로 보고되고(해제는 허브 계층이 제거된 뒤), 봉투 적용과 다음 리컨실 사이에 보낸 하트비트는 해당 선언을 주장하지 않고 대기 중으로 보고합니다. 단일 거절 슬롯에는 가드레일 또는 스마트 라우팅 다이제스트가 해당 트랙 자신의 코드 아래에서만 실립니다. 실행 시점에 거절되면 두 트랙 모두 메모리에 남겨 둔 이전 허브 정책이 아니라 로컬 설정만으로 동작합니다.

  • 읽기 전용 스마트 라우팅 및 가드레일 프로바이더 인벤토리를 Continuum Hub에 보고합니다 (#1710). 등록 요청과 모든 하트비트에 이제 RouterInventory.smart_routing_inventory(enabled, 정책 수와 명시적 프로필 수, 분류기 방식, 방식이 LLM 분류기를 호출할 수 있을 때의 분류기 백엔드 이름, virtual_model, intercept_all)와 RouterInventory.guardrail_provider_inventory(설정된 각 프로바이더의 name, type, 단계, 순서, 실제 동작 여부)가 실립니다. 둘 다 동작 중인 파이프라인에서 읽으므로 리로드, Admin API 수정, 적용된 허브 정책이 다음 하트비트에 반영되며, 어느 기능에도 묶여 있지 않습니다. 멤버가 없으면 알 수 없음, []는 프로바이더 없음, enabled: false는 설정되었지만 동작하지 않음을 뜻합니다. 상한을 넘는 목록은 잘라 보내지 않고 경고 한 번과 함께 통째로 뺍니다. 프롬프트, 패턴, 라우팅 조건, 엔드포인트, 자격 증명 이름은 전송되지 않습니다. 업스트림 재동기화 전의 추가 로컬 프로토콜 변경이며(hub#1213), PROTOCOL_VERSION은 0으로 유지됩니다.

  • 허브가 작성한 가드레일 프로바이더 콘텐츠를 로컬 가드레일과 합성합니다 (#1709). guardrail_policy_v1 선언의 provider_content는 이 라우터가 이미 실행 중인 프로바이더 이름을 키로 합니다. policy_prompt는 custom_classifier 행의 로컬 프롬프트 뒤에 라우터 고정 연결 문구와 함께 덧붙고, pii default_action과 actions는 엔티티마다 더 엄격한 동작(block > mask > allow)을 따르며 선언된 placeholder_format이 이깁니다. 예약된 키 match_list_deny 아래의 deny_patterns는 모든 로컬 거부 규칙 옆에서 차단하고, match_list_allow 아래의 allow_patterns는 로컬 허용 목록을 좁히기만 합니다. 관리되는 패턴은 선언마다 한 번, 목록마다 한정된 RegexSet 하나로 컴파일되고(패턴당 256 KiB, 선언당 8 MiB, 지연 DFA 캐시 2 MiB), 설정에 기록되거나 GET /admin/guardrails에 표시되지 않으며, 허브가 해제하면 사라집니다. 이 라우터가 실행하지 않는 프로바이더를 가리키거나, 로컬 대상이 없는 멤버를 담거나, 결코 실행되지 않는 대상을 가리키는 콘텐츠는 통째로 guardrails_unavailable로 거부되고, 컴파일 한도를 넘는 패턴은 invalid_guardrail_policy로 거부되며, 그때 라우터는 로컬 설정만으로 실행합니다. match_list_deny와 match_list_allow는 이제 예약된 프로바이더 이름입니다. 이 이름을 쓰는 로컬 프로바이더 행은 reserved_provider_name으로 로드에 실패합니다.

  • 허브가 배포하는 스마트 라우팅 정책을 적용합니다 (#1708). control_plane.policy가 활성화된 control-plane 빌드에서 정책 봉투의 smart_routing 멤버를 검증하고 저장한 뒤 로컬 smart_routing: 섹션과 합성합니다. 허브가 선언한 라우팅 규칙과 모델 프로필이 적용되고, 로컬의 enabled: false는 항상 우선하며, intercept_all은 양쪽이 모두 켰을 때만 켜집니다. 허브의 분류기 백엔드는 기존 smart_routing.classifier.llm.backend 규칙으로 해석되는 이름일 뿐입니다. 라우터가 실행할 수 없는 선언은 통째로 거절되고 로컬 섹션이 계속 실행됩니다. 허브 정책이 스마트 라우팅을 관리하는 동안 로컬 리로드와 PUT /admin/smart-routing/policies는 합성을 거쳐 반영됩니다.

  • 벤더링한 프로토콜 크레이트에 스마트 라우팅 정책과 가드레일 프로바이더 콘텐츠 와이어 표면을 추가합니다 (#1707). 업스트림 재동기화 전에 로컬에서 추가한 것입니다(hub#1214, hub#1215). PolicyEnvelope.smart_routing은 guardrails와 같은 3상태로 SmartRoutingPolicy(라우팅 규칙, 모델 티어 프로필, 백엔드가 이름일 뿐인 분류기 선택)를 담으며, 자체 smart_routing_policy_digest, smart_routing_policy_v1 기능 문자열, AppliedPolicyRevision과 RejectedPolicyRevision의 smart_routing_policy_digest, 그리고 invalid_smart_routing_policy와 smart_routing_unavailable 코드를 함께 추가합니다. GuardrailPolicy.provider_content는 프로바이더 이름을 키로 정책 프롬프트, pii 동작과 플레이스홀더, 거부/허용 패턴을 담으며 기존 guardrail_policy_digest에 포함됩니다. 각 멤버는 정규 인코딩 기준 256 KiB로 제한되고 정책 동기화 응답 상한에도 대응하는 항이 있으며, URL이나 자격 증명처럼 보이는 policy_prompt는 선언 전체를 거부시키고, 두 멤버를 쓰지 않는 엔벨로프는 이전 허브의 엔벨로프와 바이트 단위로 같습니다. PROTOCOL_VERSION은 0으로 유지되며, 라우터가 작성한 와이어 픽스처는 다음 재동기화 때 허브의 사본으로 교체해야 합니다.

  • 사용 레코드에 ttft_ms가 없는 이유를 진단할 수 있게 했습니다 (#1763). 모든 컨트롤 플레인 사용 레코드는 이제 ttft_ms와 추가된 ttft_absent_reason 프로토콜 필드(TtftAbsentReason: batch, no_first_frame, transport_unobservable, not_observed) 중 정확히 하나를 담으므로, 허브가 의도된 부재와 라우터가 관측하지 못한 타이밍을 구분할 수 있습니다. into_usage_record가 이 불변식을 강제합니다. ttft_ms를 담은 레코드의 이유는 버리고, 둘 다 없는 레코드에는 not_observed를 붙입니다. 배치 완료 탭은 batch를, 프로바이더 프레임 없이 끝난 dispatch된 스트림은 no_first_frame을, 비스트리밍 탭은 업스트림 TTFT 셀을 보고 transport_unobservable 또는 not_observed를 기록합니다. 그동안 TTFT를 보고하지 않던 두 비스트리밍 seam이 이제 실제 response_headers 스팬을 보고합니다. Unix 소켓 전송은 버퍼링 클라이언트가 응답 헤더 블록이 도착한 시점(UnixSocketResponse::headers_received_at)을 기록하고, Bedrock Runtime / Converse는 SigV4 클라이언트가 자체 비스트리밍 전송 주변에서 표시합니다. PROTOCOL_VERSION은 0으로 유지되며, 이는 업스트림 재동기화 전의 추가 로컬 프로토콜 변경이고 허브 측 소비는 lablup/continuum-hub#1164입니다.

  • 비스트리밍 채팅 응답이 형식은 올바르지만 쓸 수 없을 때 다음 폴백 모델로 홉합니다 (#1727). 선택적 fallback.quality_check 블록은 마지막 사용자 메시지와 응답만으로(각각 앞뒤를 남겨 자르며, 기본값은 Laya 다국어 컨텍스트에 맞춘 512와 1,536바이트이고, system 프롬프트와 도구 결과는 라우터 밖으로 나가지 않음) System One 결정 모델(호스팅 Jev 또는 자체 호스팅 laya-serve)에 응답이 요청에 답하는지, 거절하는지, 잘렸는지를 묻고, 1 - answered, refusal, truncated 중 하나가 threshold(기본 0.8)에 도달하면 기존 체인을 전진합니다. 임계값이 아직 측정되지 않았으므로 홉 없이 판정하고 기록만 하는 mode: shadow로 제공되며, 홉했을 비율을 확인한 뒤 mode: enforce로 바꾸면 됩니다. 홉은 max_fallback_attempts에 포함되고, 자기 자신의 fallback_timeout_multiplier 시간 창으로 실행되며, max_extra_hops(기본 1)를 넘지 않고, 키의 allowed_models를 다시 적용하며, 비스트리밍 체인 예산(기본 모델의 standard.total에 streaming_fallback_budget_multiplier를 곱한 값) 안에 들어가야 합니다. 클라이언트 자신의 max_tokens에서 잘린 응답은 잘림으로 트리거하지 않습니다. 가드레일 차단은 검사 전에 반환되고, 홉의 응답도 같은 출력 게이트를 거치며, 모든 실패는 게이트를 통과한 가장 최근 응답을 제공합니다. 디스패치된 홉은 x-continuum-quality-fallback: 1을 붙이고 fallback_quality_triggered_total{reason}을 집계하며, 트리거된 요청은 생성마다 하나씩 사용량 행 두 개를 기록합니다. 질문 묶음과 상태 생성기는 재사용을 위해 services::answer_quality에 있습니다. 새 템플릿 fallback-quality-check.

  • 벤더링된 continuum-protocol 크레이트가 허브 리비전에서 벗어나면 CI를 실패시킵니다 (#1795, 에픽 #1796의 3단계). crates/continuum-protocol/UPSTREAM.sha256은 크레이트 Cargo.toml 출처 줄에 적힌 리비전에서 허브의 src/와 tests/ 아래 파일별 SHA-256 digest(와 허브 자체 크레이트 Cargo.toml의 digest)를 기록하고, scripts/check-vendored-protocol.sh는 리비전 불일치, 파일의 수정·삭제·추가, 또는 잘못되었거나 낡은 crates/continuum-protocol/LOCAL-AHEAD 항목(허브보다 앞선 파일의 허용 목록으로, 각 항목은 그 변경을 업스트림하는 허브 이슈를 명시)이 있으면 경로를 모두 나열하며 실패합니다. 이 비교 밖에서 빌드에 들어갈 수 있는 것도 거부합니다. 크레이트 루트의 다른 항목, 허용된 형태([package], [features], 레지스트리 의존성 테이블만 허용하고 따옴표 키, 점 표기 키, 타깃·빌드 스크립트·path·git 키는 불허)를 벗어난 크레이트 Cargo.toml, cargo metadata가 src/와 tests/ 밖에서 보고하는 타깃, 그리고 path = "crates/continuum-protocol"이 아닌 라우터 의존성이 대상입니다. --base <commit>은 # rev: 줄을 그대로 둔 채 바뀐 매니페스트, 즉 벤더링된 파일과 그 digest를 함께 고친 경우를 실패시키며, CI는 HEAD^1을, make ci-local은 origin/main과의 merge base를 넘깁니다. CONTINUUM_HUB_DIR를 설정하면 허브 클론에서 매니페스트를 다시 만들어 함께 비교합니다. 검사는 네트워크나 허브 자격 증명이 필요 없으며 ci-release-graph 잡과 make ci-local에서 가장 먼저 실행되고, 임시 트리에서 모든 실패 유형을 증명하는 scripts/test-check-vendored-protocol.sh가 함께 실행됩니다. 이제 crates/continuum-protocol/** 변경도 Rust CI 잡을 트리거하므로 픽스처만 바꾼 변경도 검사됩니다. scripts/refresh-vendored-protocol-manifest.sh --hub <clone> --rev <sha> [--sync]는 로컬 허브 클론에서 매니페스트를 다시 만들고 크레이트를 다시 벤더링하며, --force 없이는 커밋되지 않은 작업 위로 --sync하지 않습니다.

  • 허브가 전달한 사용자별 웹 검색 자격 증명을 사용합니다 (#1636, PR #1966). web_search 섹션이 있는 control-plane 빌드는 search_credentials_v1을 광고하고 정책 엔벨로프의 search_credentials 멤버를 적용합니다. 허브 키로 들어온 요청은 web_search.api_key보다 키 소유자의 제공자와 자격 증명(serper, brave, exa)을 우선해 검색하고, web_search_enabled: false인 티어는 검색 도구를 받지 않으며, 허브가 자격 증명 집합을 전달한 뒤에는 자격 증명이 없는 소유자도 배포가 인증할 수 없으면 검색 도구를 받지 않습니다. 자격 증명은 키의 사용자별로 메모리에만 보관하고 키 테이블 사이드카에는 절대 기록하지 않으며, 항목이 1,000개를 넘거나 사용자가 중복되거나 제공자를 알 수 없거나 다이제스트가 없거나 맞지 않는 문장은 통째로 거부합니다. 각 검색은 사용량 레코드에 web_search_queries, web_search_provider, web_search_credential_source로 보고됩니다. 허브 키가 없는 요청과 아무것도 전달하지 않는 허브에서는 동작이 이전과 같습니다.
  • 허브 장애에 대비해 동기화된 키 테이블을 영속화합니다 (#1453, PR #1965). 옵트인 control_plane.policy.persist_key_table: true(key_table_max_age_secs로 제한, 기본값 86400, 60~604800)를 켜면 라우터는 적용된 키 테이블의 허용 목록 투영(키 ID와 해시, 티어 바인딩과 한도, 소유자, 폐기, 로테이션 중첩, 사용량 스냅샷, 커서, 등록 신원)을 소유자 전용 <state_file>.policy-keys.json에 기록하고 에이전트 시작 시 복원합니다. 따라서 허브에 닿을 수 없는 동안 재시작한 라우터도 허브 키를 모두 거부하지 않고 계속 받아들입니다. 제공자, 백엔드 범위, 검색 자격 증명은 파일에 포함되지 않습니다. 다른 등록에서 온 사이드카, 수명 한도를 넘긴 사이드카, 권한이 0600보다 넓거나 형식이 잘못된 사이드카는 무시하며, 복원된 테이블은 허브에 적용됨으로 보고되지 않고 허브 전달보다 우선하지도 않습니다. GET /admin/control-plane/status에 policy_sync.source(synced, restored, none), policy_sync.restored, policy_sync.key_table_persistence가 추가됩니다.
  • Continuum Hub에서 모델 폴백 설정을 동기화합니다 (#1632, PR #1923). control_plane.config_sync에 request_params, kv_routing과 나란히 닫힌 스키마 1 fallback 섹션이 추가되며, 영속 적용, 롤백, 리스너가 열리기 전 재시작 시 복원, 값을 담지 않는 관측, 유효 체인 그래프 검증을 갖춥니다. 로컬 폴백 정책, 체인 레코드, 모델별 설정은 원자적 고정으로 남고, 원격 콘텐츠는 제한되며(모델 키 64개, 체인당 대상 10개, 트리거 코드 64개), fallback.quality_check는 로컬에 남습니다. 승인된 각 요청은 들어올 때의 설정 스냅샷을 유지합니다. 짝을 이루는 허브 변경: continuum-hub#1314.
  • 라우터 컨테이너 이미지에 continuum-kv-listener를 포함합니다 (#1626, PR #1912). Dockerfile.ci는 검증된 같은 musl 번들에서 라우터와 리스너를 복사하고 65532:65532 이미지 사용자를 유지하므로, 배포에서 리스너용 파생 이미지를 따로 관리할 필요가 없습니다. 배포 가이드에 아키텍처 선택, 엔트리포인트 재정의, 리스너 헬스 프로브를 설명했습니다.
  • 활성화되고 내부용이 아닌 백엔드가 하나 이상 활성 상태가 될 때까지 503을 응답하는 인증 없는 준비 상태 엔드포인트 GET /health/ready를 추가합니다 (#1558, PR #1808). Helm과 Kubernetes 예시는 이를 readiness 프로브로 쓰고 /health는 liveness 프로브로 남으며, /health처럼 속도 제한에서 제외됩니다. 로컬 백엔드 섹션이 빈 채로 부팅한 컨트롤 플레인 레플리카는 첫 허브 할당이 풀에 반영될 때까지 준비되지 않은 상태로 남습니다.
  • 이 체크아웃을 컨트롤 플레인 기능으로 빌드하고 Continuum Hub의 scripts/router-compat.sh를 그 바이너리에 대해 floor와 latest 모드로 실행하는 make hub-compat을 추가합니다 (PR #1858, 에픽 #1796). 벤더링된 프로토콜 리비전을 포함하는 허브 클론을 가리키는 CONTINUUM_HUB_DIR이 필요하며 GitHub 자격 증명은 필요 없습니다.

Changed

  • continuum_dynamo_load_subscriptions와 continuum_dynamo_kv_subscriptions에 transport 라벨(direct, broker)과 pending 상태가 추가되었습니다 (#1917). {state="connected"}를 고르는 쿼리는 이제 전송 방식마다 시리즈를 하나씩 돌려주므로, 시리즈 하나를 유지하려면 sum without (transport)로 감싸세요.
  • .github/workflows/dynamo-e2e.yml이 기본적으로 디스패치한 커밋에서 테스트할 라우터 이미지를 빌드합니다 (#1776, epic #1703). 최신 릴리스는 하네스가 검증하는 라우터 수정보다 앞서 있었고 ghcr.io/lablup/continuum-router:latest는 익명 pull을 거부해, 워크플로가 pull 실패 지점까지만 도달할 수 있었습니다. 새 build_router 입력(기본값 1)은 rust:<고정 채널>-alpine 안에서 릴리스 기능 집합으로 정적 musl 바이너리를 컴파일하고 Dockerfile.ci로 ghcr.io/lablup/continuum-router:e2e-local 이미지를 만든 뒤 빌드한 커밋을 출력합니다. build_router=0이면 이전처럼 .env에 고정된 이미지를 pull합니다. runner_labels 기본값은 고정된 Dynamo 이미지(압축 약 0.7 GB)를 몇 분 안에 받는 GitHub 호스팅 ubuntu-latest 러너로 바뀌고, 콜드 릴리스 컴파일을 감안해 작업 제한 시간은 90분으로 늘어납니다. SKIP_PULL=1은 Dynamo와 etcd pull까지 건너뛰므로, scripts/e2e-dynamo-shim.sh에 로컬에서 빌드한 라우터 이미지만 빼고 모두 pull하는 SKIP_ROUTER_PULL=1이 추가됩니다.

  • 기능 게이트가 걸린 모든 통합 테스트 타깃을 CI와 make ci-local에서 실행합니다 (#1898, #1792 대체). #![cfg(feature = ...)]로 게이트된 파일이나 게이트된 테스트 또는 인라인 mod는 기본 cargo test --tests 단계에서 아무것도 컴파일되지 않는데, control_plane 그룹의 보안 감사 테스트를 포함한 19개 타깃에 .github/workflows/ci.yml과 scripts/local-ci.sh 어디에도 cargo test --features <F> --test <target> 단계가 없었습니다. 이제 각 타깃은 해당 기능 그래프를 이미 빌드하는 잡에서 실행되며, routing은 control-plane으로 한 번, Redis KV 인덱스 케이스로 필터링해 라이브 redis-shared-state 잡에서 한 번, 두 번 실행됩니다. tests/cargo_test_discovery_audit_test.rs의 두 번째 감사는 게이트된 타깃의 단계가 두 파일 중 하나라도 빠지면 기본 스위트를 실패시키며, 두 파일이 같은 형태를 갖도록 local-ci.sh는 control-plane 단계를 루프 대신 한 줄씩 나열합니다. CI 그래프 게이트는 이제 다른 타깃이 #[path]로 불러오는 테스트 파일의 로더를 스케줄하고, tests/<class>/나 tests/<class>_*/ 그룹 디렉터리의 클래스를 규칙으로 도출합니다. 빠진 단계 때문에 가려져 있던 bedrock_converse_integration_test의 오래된 단언, unhealthy_backend_is_never_selected를 불안정하게 만든 죽은 포트 경합(이제 바인드만 하고 리슨하지 않는 소켓이 포트를 점유합니다), 아무것도 단언하지 않던 rate-limit 메트릭 테스트, #1620이 남긴 오래된 테스트 경로 참조 28개도 함께 고쳤습니다.

  • 대부분의 통합 테스트를 도메인 테스트 타깃으로 묶어 cargo test --tests가 링크하는 바이너리 수를 크게 줄였습니다 (#1620). 최상위 tests/*.rs 파일은 각각 라이브러리 전체를 링크하므로 링크 단계가 테스트 시간의 대부분을 차지했습니다. 최상위 파일 285개 중 194개가 이제 18개 그룹 타깃(tests/<group>/main.rs: server, auth, admin, models, anthropic, appproxy, routing, providers, tracing_capture, cache, responses, control_plane, fallback, systemone, files_media, guardrails, streaming, web_search) 아래에 내용 변경 없이 모듈로 들어갔고, tests/tool_calling/은 독자적인 타깃이 되어, 통합 테스트 바이너리가 285개에서 109개로 줄었습니다. CI나 스크립트가 이름으로 선택하는 파일, 실행 가능한 --test 선택자를 담은 파일, 프로세스 전역 상태(메트릭 레지스트리, control-plane 트래커, 설정 이력 저장소, 프롬프트 파일 캐시, 후보 프로브 제한, 타임아웃 부트 핀)를 읽는 파일은 최상위에 남습니다. 그룹에 속한 테스트는 cargo test --test <group> <old_stem>::로 선택합니다. 이 필터는 부분 문자열 일치이므로 같은 stem으로 끝나는 형제 모듈도 함께 선택되며, 테스트 하나만 고르려면 cargo test --test <group> -- --exact <old_stem>::<test>를 씁니다. 그룹 목록과 선택 규칙은 CLAUDE.md에 있습니다. CI 기능 그래프 게이트는 이동한 파일을 여전히 이름으로 해당 그래프에 연결하고, 그룹의 main.rs 변경을 그 멤버 전체의 변경으로 취급합니다.

  • 호스팅 CI의 기능 그래프 작업을 변경된 파일에 따라 실행합니다 (#1621). Detect Changes에 새로 추가된 단계 scripts/ci_graph_gates.py가 경로 분류, 변경된 각 파일의 기준 버전과 변경 후 버전에 있는 모든 feature = "..." 문자열, 그 파일을 포함하는 기능 게이트 모듈의 게이트, 변경된 데이터 파일을 포함(include)하거나 기능 게이트가 있는 테스트에서 실행 중에 읽는 파일, 각 작업이 이름으로 지정한 --test 대상을 바탕으로 ci-isolated-graphs, ci-release-graph, redis-shared-state의 실행 여부를 각각 결정하므로, src/services/smart_routing/처럼 게이트가 없는 코드만 바꾼 풀 리퀘스트는 ci-default만 실행합니다. 결정할 수 없는 경우(삭제되거나 이름이 바뀐 파일, 공유 테스트 헬퍼, 분류되지 않은 기능, 코드 루트 아래의 추적되는 심볼릭 링크, 계산할 수 없는 diff, 경로 필터는 보고했지만 git에는 없는 파일)에는 모든 작업을 실행하고, 게이트 단계가 실패하거나 건너뛰어지거나 시간 제한(5분)을 넘기면 "실행"으로 간주하며, workflow_dispatch는 여전히 전체 매트릭스를 실행합니다. 이제 ci-default는 게이트 테스트(scripts/test_ci_graph_gates.py), cargo check --features "control-plane appproxy-router hot-reload redis-cache otel dynamo" --lib, 그리고 ci-isolated-graphs에서 옮겨 온 bare-graph clippy 두 단계를 항상 실행하고, 풀 리퀘스트에서 게이트가 릴리스 그래프를 건너뛰면 그 작업의 all-features clippy를 변경되지 않은 통합 테스트 대상만 빼고 실행하므로, 핵심 코드 변경이 bedrock-sigv4, s3-cache, appproxy-legacy 코드, 기능 게이트가 있는 단위 테스트, 예제, 벤치마크의 컴파일을 깨뜨리면 여전히 드러납니다. 게이트는 풀 리퀘스트에서만 실행되며, push에서는 redis-shared-state에 게이트를 적용하지 않습니다. rust 필터는 이제 Rust가 아닌 컴파일 입력(src/**, tests/**, examples/**, benches/**, docs/shared/llm-skills/**, config.yaml.example)과 CI 스크립트도 포함합니다. make ci-local은 게이트를 적용하지 않고 여전히 모든 단계를 실행하며, 게이트 테스트, union 검사, bare-graph 두 단계가 이제 기본 섹션에 있습니다.

  • 호스팅 CI의 기능 그래프 작업을 풀 리퀘스트와 수동 실행에서만 돌리고, 대체된 풀 리퀘스트 실행을 취소합니다 (#1619). .github/workflows/ci.yml에 이벤트와 풀 리퀘스트 번호(또는 ref)를 키로 하는 워크플로 수준 concurrency 그룹을 추가했습니다. 풀 리퀘스트에 새 푸시가 오면 그 풀 리퀘스트의 진행 중인 실행이 취소되고, push와 workflow_dispatch 실행은 진행 중인 실행을 취소하지 않으며 새로 대기열에 들어온 실행이 더 오래된 대기 실행을 대체합니다. 이제 main이나 develop으로의 push는 ci-default와 ubuntu-latest 작업만 실행하고 ci-isolated-graphs와 ci-release-graph는 건너뜁니다. 풀 리퀘스트 실행이 그 PR의 병합 ref에 대해 기능 그래프를 빌드하고 병합 후 푸시는 더 이상 이를 다시 빌드하지 않기 때문이며, 병합 후에만 나타나는 문제(실행이 끝나기 전에 병합된 풀 리퀘스트, 또는 각각은 통과한 두 풀 리퀘스트가 이동한 main에 스쿼시된 뒤 서로 충돌하는 경우)는 그래프를 빌드하는 다음 풀 리퀘스트 실행, workflow_dispatch, 또는 make ci-local에서 드러납니다. 새 workflow_dispatch 트리거는 필요할 때 전체 매트릭스를 실행하며, 이때 Detect Changes는 모든 경로 그룹을 변경된 것으로 보고합니다. make ci-local은 여전히 전체 풀 리퀘스트 매트릭스를 그대로 따르고, dependency-float.yml은 바뀌지 않습니다.

  • 합성한 kv_metrics 픽스처를 실제 Dynamo 1.4.2 dynamo.vllm 워커에서 캡처한 메시지 두 개로 교체하고, 실제 플릿 호환성 표를 기록합니다 (#1749, 에픽 #1703). src/dynamo/load/fixtures/kv_metrics_frame_captured.bin(시퀀스 5, kv_used_blocks 4)과 kv_metrics_frame_captured_idle.bin(시퀀스 6, kv_used_blocks 0)은 한 워커의 부하 중 메시지와 유휴 메시지로, 워커 자신의 ZMQ PUB 소켓에서 읽기 전용으로 받았으며, 출처 노트에 플릿, 버전, 트리거, 디코딩한 값을 기록했습니다. 디코더는 워커가 보낸 값을 정확히 읽고, 테스트 인코더는 두 메시지를 바이트 단위로 똑같이 재현하므로 실제 envelope의 키 순서와 정수 폭이 인코더와 일치합니다. 캡처는 합성 픽스처가 가리고 있던 차이를 보여 줍니다. 실제 1.4.2 워커는 active_decode_blocks와 active_prefill_tokens를 모두 nil로 보내고 부하를 kv_used_blocks로만 알립니다. decode 비용에 이미 있던 kv_used_blocks 대체값 덕분에 그런 플릿에서도 decode 선택은 부하를 반영하며(이제 캡처를 넣는 picker 테스트로 고정), load-only picker에서는 prefill 풀이 순환합니다(KV 인식 picker는 대신 overlap으로 순서를 정합니다). docs/en/architecture/dynamo-integration.md의 새 "실제 플릿 검증" 절은 시험한 대상(Dynamo 1.4.2, kubernetes pod 모드 탐색, vLLM 0.25.0 aggregated 워커, --router-mode direct --dyn-chat-processor vllm 프론트엔드, kubernetes_pods 소스), 차이점(패치하지 않은 1.4.2에서 vLLM chat processor가 워커 고정을 버리는 문제, #1888의 다중 풀 네임스페이스, nil인 ActiveLoad 필드, OS가 할당하는 발행자 포트), 그리고 NIXL을 쓰는 dynamo.vllm prefill/decode 쌍과 SGLang의 동시 bootstrap 경로는 여전히 시험하지 않았다는 사실을 기록합니다.

  • dynamo 섹션의 필드 규칙을 로드 및 핫 리로드 경로에서도 적용합니다 (#1869). 지금까지 validate_config는 이 섹션의 Validate를 실행하지 않아, 문서화된 규칙은 config validate만 검사했고 규칙을 어긴 파일로도 라우터가 시작되었습니다. 이제 dynamo.enabled 여부와 관계없이 로드 시, 모든 핫 리로드 시, Admin API 가져오기 시에 적용됩니다: 각 discovery.etcd.endpoints 항목의 형식(자격 증명·쿼리·프래그먼트·경로 없음, 접속 가능한 스킴 또는 host:port), 최대 16개이며 http:///https:// 혼용 금지, http:// 항목 옆의 tls 금지, /·공백·제어 문자 없는 비어 있지 않은 discovery.etcd.namespace, 비어 있지 않은 auth.username, 파싱 가능한 watch_reconnect_backoff 기간과 max >= initial, load·shim 규칙, etcd 소스가 켜졌을 때의 endpoints, 그리고 존재할 때의 새 discovery.kubernetes 블록. 업그레이드 주의: 업그레이드 전에 파일에 continuum-router config validate를 실행하세요. 이를 통과하지 못하는 dynamo 스텁은 켜져 있든 아니든 이제 시작 시 필드를 명시한 오류로 라우터를 멈춥니다. 같은 변경에서 continuum_dynamo_discovery_connected에 source 라벨(etcd 또는 kubernetes_pods)이 추가되며, 설정된 소스만 내보내집니다(시작 시 0, watcher가 연결되면 1; 핫 소스 전환은 시리즈를 교체). 따라서 continuum_dynamo_discovery_connected == 0 알림은 그대로 동작하고, 라벨 없는 시리즈를 고르던 대시보드는 max by ()나 source 매처를 추가해야 합니다. Pod 소스는 서버의 일상적인 290초 watch 종료와 그에 이은 재 list 동안 게이지를 1로 유지하며, 패스 실패, 오류로 끝난 watch, 중지 시에만 0으로 내립니다. 320초 동안 조용한 watch(half-open 연결)나 290초 서버 타임아웃보다 한참 일찍 닫힌 watch(삼켜진 전송 EOF)는 오류로 끝나므로 게이지가 내려가고 재 list가 이어지며, list는 500개씩 페이지로 읽다가 보유 상한에서 멈춥니다.

  • 거부된 입력 마스크를 클라이언트가 받은 차단으로, 새 카테고리 guardrail_mask_unappliable로 기록합니다 (#1837, epic #1796). 클라이언트에 보이는 변경: 라우터가 적용할 수 없는 마스킹 판정(두 문자열에 걸친 마스킹, 재검사 예산을 넘는 바뀐 문자열, 반영되지 않은 다시 쓰기, 프로바이더가 여전히 바꾸려는 마스킹된 요청, 마스킹 단계의 프로바이더 실패, 인그레스가 타입이 있는 형태로 되돌릴 수 없는 마스킹된 요청)은 이제 일반 카테고리 guardrail 대신 x-guardrail-category: guardrail_mask_unappliable과 차단 본문의 같은 카테고리로 응답합니다. guardrail은 출력 게이트가 끊은 스트림의 마지막 청크에만 남습니다. 마스킹 거부를 guardrail로 알림하던 경우 새 값을 추가해야 합니다. 요청 단위 판정은 이제 결과가 확정된 시점에 기록되므로, guardrail_verdicts_total과 감사 결정 기록은 이런 요청을 첫 단계가 기록하던 transform이 아니라 block 하나로 집계합니다(기한을 넘긴 마스킹 단계는 guardrail_timeout의 block 하나). /anthropic/v1/messages(thinking 게이트 포함)와 /v1/responses의 타입 왕복 검사는 새 진입점 gate_input_document_accepting과 gate_input_document_deferred_accepting을 통해 마스킹 코어 안에서 실행되므로, 거부된 왕복은 guardrail_mask_unappliable로 기록되거나 요청의 보류 판정(#1836)에 합쳐지고, /v1/completions는 되쓰기 거부를 요청의 집계에 합치므로, 이 경로들은 각각 정확히 하나의 판정을 기록합니다. monitor 모드는 마스킹하지 않으므로, enforce 모드가 guardrail_mask_unappliable로 거부할 트래픽을 transform으로 기록합니다. 마스킹 코어는 첫 단계 전에 읽은 정책으로 판정을 기록하므로, 마스킹 단계 도중에 설정이 다시 로드되어도 그 판정이 사라지거나 모드 라벨이 바뀌지 않습니다. 보류 판정(#1836)도 남은 패스가 첫 프로바이더 호출 전에 읽은 정책으로 기록합니다(#1847). 도구 정의 게이트는 검사할 수 없는 거부에 그때까지 모은 판정을 합치므로, monitor 모드의 deny 뒤에 리프 상한을 넘는 도구가 오면 deny로 보고됩니다. Continuum Hub에 연결된 라우터에서는 이런 요청의 하트비트 판정 카운터가 transform에서 block으로 옮겨 갑니다. 카테고리 자체는 허브로 가지 않으며, 허브는 프로바이더 차단만 카테고리별로 집계합니다(guardrail과 새 값 모두 other로 합쳐짐).

  • 내장 pii 전화번호 후보를 주변 텍스트와 함께 읽습니다 (#1837, epic #1796). 전화번호 패턴에는 단어 경계가 없고 7~15자리 숫자 개수만 검사했으므로 IPv4 주소, epoch 타임스탬프, 압축된 날짜, 프로세스 ID, 파일 크기, 16진 해시와 UUID 안의 숫자열을 마스킹했습니다. 에이전트 트래픽은 이런 값을 매 턴 다시 보내며, #1827 이후 도구 계약 필드에 있으면 요청 전체를 거부했습니다. 이제 후보는 문자, 숫자, _, /, 또는 숫자 뒤의 .에 붙어 있으면 안 되고, 뒤에 .이나 -와 숫자가 이어지면 안 되며, IPv4 주소 형태이면 안 되고, 점은 전화번호 형태(점 두 개 이상, 4자리 이하 그룹)로만 써야 하므로 소수와 좌표는 일치하지 않으며, 마지막 그룹 앞의 숫자 그룹이 5자리를 넘으면 안 되고, 문자가 16진 숫자뿐인 토큰(숫자로만 된 UUID나 해시 그룹)에 하이픈으로 이어져 있으면 안 되며(phone-나 -ext처럼 일반 단어에 이어진 경우는 막지 않음), 서식 없는 숫자열은 국내 접두 번호 형태(앞자리 0에 9~11자리, 예: 01012345678)일 때만 인정합니다. 뒤따르는 공백, 점, 하이픈은 더 이상 마스킹 범위에 포함하지 않으며, 거부된 일치가 그 안에서 시작하는 번호를 가리지 않습니다. 대가: 4155550123처럼 접두 번호 없이 서식 없는 번호는 더 이상 탐지하지 않으며, 서식 있는 형태(415-555-0123, 415.555.0123, (415) 555-0123, +1 415-555-0123)는 phone-415-555-0123, Tel-010-1234-5678처럼 단어에 하이픈으로 이어진 경우를 포함해 탐지합니다. 서식 없는 번호가 중요하면 외부 인식기를 설정하십시오.

  • 프로토콜 크레이트를 Continuum Hub의 단일 리비전에서 다시 벤더링했습니다(#1794, 에픽 #1796의 2단계). crates/continuum-protocol/src와 tests는 이제 허브 리비전 92907b47403b325b2a7038ad1f9ecf4ba4d07fbf를 바이트 단위로 그대로 복사한 것이며, 예전의 로컬 선행 기록 대신 Cargo.toml의 출처 줄 하나가 이 리비전을 가리킵니다. 라우터는 모든 곳에서 허브의 정의를 씁니다. GuardrailPolicyMode와 GuardrailBlockBehavior가 라우터의 선언 열거형을 대체하고, 스마트 라우팅, 가드레일 프로바이더 콘텐츠, 프로바이더 인벤토리, ttft_absent_reason, routing_enabled, invalid_model_budget_usage_watermark 표면은 허브의 것을 그대로 쓰며, 와이어 픽스처도 허브의 것입니다. 라우터 전용 헬퍼(가드레일 요약 방출 한도와 카테고리 접기, 임계값 변환)는 src/control_plane/ 아래로 옮겼습니다. 가드레일 적용 경로는 이제 허브의 수용 검증 진입점으로 검증하므로 수신 한도는 그대로입니다(카테고리 항목 64개, 비어 있는지와 길이만 검사하는 256바이트 카테고리 id, 경로 항목 64개, 256바이트 경로 키, 알 수 없는 차단 동작 허용). 다만 이 멤버를 보내는 모든 허브가 이미 지키는 네 가지를 이제 요구합니다. digest가 없는 가드레일 선언은 라우터의 재계산으로 적용하지 않고 digest_mismatch로 거부하며, mode가 없거나, 단계 선택이 비어 있거나, 경로 키에 제어 문자가 든 정책은 invalid_guardrail_policy로 거부합니다. 각 거부는 가드레일 트랙에만 적용됩니다. 봉투의 나머지는 적용되고, 보유 중인 가드레일 정책은 계속 유효합니다. 같은 수신 측에서 EquivalenceClass.members는 이제 허브의 선언대로 와이어에서 필수입니다. v0.5.0 이후의 모든 허브가 항상 이 필드를 보냅니다. 라우터가 기록하지 않는 허브 선행 사용량 필드(UsageRecord.project_id와 웹 검색 계량 필드)는 비워 두며, 이 라우터는 search_credentials_v1을 알리지 않으므로 허브가 PolicyEnvelope.search_credentials와 search_credentials_digest를 보내면 적용 시 지워 평문 사용자별 검색 자격 증명이 보유 스냅숏에 남지 않게 합니다. 사용량 레코드는 이제 항상 compressed를 실으며, PROTOCOL_VERSION은 0으로 유지됩니다.

  • 벤더링한 프로토콜 크레이트의 배치 라이프사이클, TTFT 측정 소스, 모델 예산, 최적화 정책 와이어 타입을 Continuum Hub의 정의로 교체했습니다 (#1793, 에픽 #1796의 1단계). 허브 rev e6aae11e의 코드를 그대로 복사했습니다. 라우터는 전이별 배치 보고를 BatchStatusTransition(상태 열거형 BatchJobStatus)으로 부르며, 여전히 그 평탄한 본문을 /api/agent/v1/batches로 보냅니다. 사용량 레코드는 ttft_kind 대신 허브의 정식 키 ttft_source를 씁니다. 허브는 두 키를 모두 읽으므로 이전과 이후 허브 모두 TTFT를 계속 수집하며, ttft_absent_reason은 바뀌지 않습니다. 모델 예산 타입은 허브의 이름(ModelBudgetTierPolicy, 허브의 sell_spend_microusd를 담는 ModelBudgetEntry, ModelBudgetDimensionStatusWire)으로 policy.rs에 들어갔고, 다이제스트는 그대로이며, 집행은 여전히 허브의 판매 지출 판정만 읽습니다. 모든 티어에 풀이 없는 선언은 이제 허브와 같이 권위 있는 해제(clear)로 처리됩니다. 최적화 블록, semantic_similarity_bps, cache_ttl_secs는 허브와 같이 선택 항목입니다. 블록이나 값이 없으면 라우터 기본값을 유지하고, u32 초를 넘는 TTL은 포화되며, 허브가 블록을 보내지 않았을 때 Admin 정책 뷰는 optimization: null을 보고하고, 허브가 명시하지 않은 블록 안의 semantic_similarity_bps와 cache_ttl_secs는 생략합니다(이전에는 0). 이때 라우터는 기본값을 씁니다. 헤더 상수는 값은 그대로 두고 허브의 *_CONTROL_HEADER 이름을 씁니다. PROTOCOL_VERSION은 0으로 유지되고 와이어는 바뀌지 않습니다.

  • fallback.quality_check와 usage.quality_judgment가 타깃을 공유하면 비스트리밍 채팅 응답을 한 번만 판정합니다(#1802). 같은 target 블록과 같은 max_request_bytes / max_reply_bytes를 쓰면 사용량 판정이 도구 결과를 보내지 않으면, 품질 검사가 원래 응답을 판정하는 호출에서 품질 검사를 실패시키거나 판정을 바꿀 수 없는 최선 노력 질문으로 language_match도 함께 묻고, 사용량 레코드는 두 번째 판정 호출이나 max_in_flight 슬롯 없이 그 답을 재사용합니다. 공유 호출이 답하지 않은 질문은 따로 물어 합치며, 서로 다른 두 판정 모델이 해석되면 합치지 않고 invalid로 버립니다. UsageQualityPlan::finish_text_with가 finish와 finish_with 뒤의 새 와이어 중립 핵심입니다. 공유 호출은 systemone_requests_total{consumer="fallback_quality"}로 집계됩니다. 품질 검사가 판정하지 않으면 사용량 판정은 이전처럼 따로 호출하며, 타깃이나 상한이 다르면 두 호출을 유지합니다. /v1/decisions 엔드포인트의 타깃별 클라이언트 캐시는 공유 ClientCache(용량 256, 타깃 이름을 키로 사용)를 쓰며, 타깃 클라이언트 생성 실패 경고는 여전히 한 번만 남깁니다.

  • 분류기가 라우터 밖으로 나가는 스마트 라우팅 /v1/chat/completions 요청에서는 입력 가드레일 차단이 503 smart_routing_rejected 부하 거부와 라우팅된 모델에 대한 키별 403보다 먼저 응답됩니다 (#1816). 이 경우 입력 단계가 분류보다 먼저 실행되므로, 부하 관리나 키의 모델 허용 목록이 거부할 상황에서도 차단된 프롬프트는 차단 응답을 받습니다.

  • 라이브 스위치가 꺼져 있으면 스마트 라우팅 관리 엔드포인트가 비활성 상태로 응답합니다 (#1711). 스마트 라우팅을 켠 채 시작한 뒤 런타임에 끈 라우터에서 GET 및 PUT /admin/smart-routing/policies, POST /admin/smart-routing/classify, POST /admin/smart-routing/simulate, /admin/smart-routing/cache/*는 이제 400으로 응답하며(이전에는 꺼진 라우터에서 200으로 응답했습니다), GET /admin/smart-routing/stats는 부하 관리가 꺼져 있는 동안 load_snapshot: null을 보고합니다.

  • 스마트 라우팅이 비활성화되어 있어도 시작 시 스마트 라우터와 부하 모니터를 생성합니다 (#1711). 둘 다 유휴 상태로 시작하므로, 리로드나 허브가 합성한 설정에서 스마트 라우팅 또는 load_management를 켜면 재시작 없이 적용됩니다. 이전에는 스마트 라우팅을 끈 채 시작한 라우터가 이후의 활성화를 무시했습니다. 비활성 라우터는 요청당 원자적 로드 한 번의 비용만 들며(부하 모니터의 enabled 스위치가 AtomicBool로 바뀌었습니다), 부하 평가 태스크는 부하 관리가 처음 켜질 때 시작됩니다.

  • 설정 채널이 없는 허브 관리 대상에 대한 관리 API 쓰기에 타입이 있는 503으로 응답합니다 (#1711). 가드레일 쓰기 엔드포인트와 PUT /admin/smart-routing/policies / model-profiles는 {"error": {"code": "hub_governed_no_config_channel", "section": ...}}를 반환합니다(가드레일 응답 본문은 기존 status와 message 필드를 유지합니다). 스마트 라우팅 엔드포인트는 이전에 편집이 버려지는데도 200 updated로 응답했습니다.

  • UpdatableHandles의 구성 요소별 스마트 라우팅 필드(tier_registry, smart_router, load_monitor, extensions_source)를 smart_routing: Option<SmartRoutingRuntime>로 대체하고 사용 중단(deprecated)으로 표시했습니다 (#1708). 이 필드들은 계속 동작합니다. smart_routing이 설정되지 않으면 HotReloadService::new가 이 필드들로 런타임을 만듭니다.

  • 액션 세이프티 어댑터의 safeguard_results를 실제 Claude Code 세션에서 캡처한 와이어 형태로 방출합니다 (#1694). 변환 백엔드 응답과 스트리밍 safeguard_results 이벤트는 이제 최상위 배열에 dangerous_tool_use 검사 하나를 담고, 그 status.tool_uses 맵은 도구 사용 ID를 키로 합니다. allow는 네이티브 백엔드가 반환하는 것과 동일한 {"type": "evaluated", "outcome": "not_flagged"}로, deny는 outcome: "flagged"로 읽힙니다. 제안된 액션 중 하나라도 indeterminate(타임아웃, 도달 불가 평가기, 예산 소진 등 fail-closed 경우)로 판정되거나 ID로 상관시킬 수 없으면, 검사 전체가 맵 없이 status: {"type": "unavailable"}로 읽힙니다. 기존 {"results": [{"verdict", "reason"}]} 객체는 제거되었고, 평가기의 reason은 더 이상 클라이언트에 전달되지 않고 debug 수준 로그에 기록됩니다. deny, unavailable, 스트리밍 매핑은 실제 클라이언트에서 관찰되지 않았으며, 세이프가드 설계 노트에 결정으로 문서화되어 있습니다.

  • Dynamo etcd 연결 신원(dynamo.discovery.etcd.endpoints, tls, auth)을 바꿔도 재시작 필요 오류로 거부하지 않고 즉시 반영합니다 (#1756). 탐색 watcher는 이미 패스마다 etcd 클라이언트를 새로 만들고 있었으므로, 부팅 시 고정은 정상 동작할 변경을 오류로 만들 뿐이었습니다. 이제 탐색 수퍼바이저가 watcher를 교체하고 레지스트리를 비우며, 교체된 watcher가 새 클러스터로 접속해 전체 재동기화합니다. dynamo 섹션에는 더 이상 재시작이 필요한 필드가 없습니다. pin_boot_dynamo_connection과 assert_dynamo_connection_unchanged는 폐기 예정인 no-op으로 남습니다.

  • continuum_router::services::disaggregated::BackendInfo에 #[non_exhaustive]를 붙였습니다 (#1759). 이 구조체는 #1698에서 필드가 늘어나(in_flight가 u64로 바뀌고 waiting_requests와 kv_cache_usage가 추가됨) 이미 크레이트 밖의 구조체 리터럴 생성이 깨졌습니다. 이제 크레이트 밖의 코드는 BackendInfo::new로 만든 뒤 공개 필드를 설정하므로, 이후 필드가 추가되어도 다시 깨지지 않습니다.

  • 버퍼링(비스트리밍) 분리형 경로에서도 클라이언트 오류를 폴백하지 않고 그대로 전달합니다 (#1759). 라우팅된 백엔드(또는 그 앞의 게이트)가 408과 429를 제외한 4xx로 응답하면, #1752 이후 스트리밍 경로와 똑같이 비스트리밍 경로에서도 그 응답이 요청의 답이 되며 x-continuum-routing-path가 붙고 라우팅된 경로로 한 번 집계됩니다. 더 이상 decode 오류가 아니며 fallback_to_unified에서도 통합 서빙으로 다시 보내지 않습니다.

  • Dynamo 집계형 폴백을 continuum_dynamo_selection_total{role="prefill", reason="no_prefill_pool"}로 집계하고 decode 워커는 자신의 실제 사유를 유지합니다 (#1759). picker가 decode 사유를 no_prefill_pool로 덮어써 어떤 비교가 워커를 골랐는지 잃어버리던 문제입니다. {role="decode", reason="no_prefill_pool"} 시리즈는 더 이상 증가하지 않습니다.

  • Dynamo 탐색의 식별자 규칙을 강화했습니다 (#1759). 이 빌드가 인식하지 못하는 카드 worker_type은 prefill·decode 컴포넌트와 함께 오면 아무 역할도 주장하지 않은 것으로 보아 컴포넌트가 역할을 정합니다. 이전에는 불일치로 워커가 제외되어, 업스트림이 철자를 바꾸면 플릿 전체가 제외될 수 있었습니다. 같은 인스턴스의 레거시 카드(component와 endpoint가 비어 있음)와 범위가 지정된 기본 카드는 범위 지정 카드가 조용히 레거시 카드를 밀어내는 대신 함께 거부되고 kind="duplicate"로 보고됩니다. dynamo.discovery.etcd.namespace는 이제 제어 문자도 거부합니다.

  • 모델별 메트릭 레이블에 서빙 모델 ID를 그대로 표기합니다 (#1879). model_usage_total, model_tokens_processed, llm_tokens_total과 스마트 라우팅 메트릭(smart_routing_decisions_total·smart_routing_requests_total의 source_model·target_model, smart_routing_model_availability의 model)은 이제 폴백 메트릭이 이미 쓰는 규칙으로 모델 ID를 표기합니다. 대소문자를 유지하고, 유니코드 문자·숫자, -, _, .와 함께 /와 :도 남기며, 그 밖의 문자는 _로 바꾸고, 값은 문자 경계에서 128바이트로 자르며, 빈 ID는 unknown이 됩니다. 이전에는 meta-llama/Llama-3.1-8B-Instruct가 meta-llamallama-3.1-8b-instruct로 내보내졌고 Qwen/Qwen3-8B와 qwen/qwen3-8b가 한 시계열을 공유했으며, 스마트 라우팅은 길이만 잘라 공백과 제어 문자를 그대로 통과시켰습니다. 시계열 식별자가 바뀝니다: ID에 대문자, /, :가 들어간 모델(스마트 라우팅 메트릭에서는 공백이나 그 밖의 문장 부호도)은 예전 시계열이 멈추고 새 시계열이 시작되므로, 소문자로 바뀌거나 슬래시가 빠진 표기에 맞춘 대시보드와 알림은 수정해야 하며, /admin/metrics/history는 업그레이드 전에 기록된 샘플을 예전 표기로 유지합니다. 모델 카디널리티 가드도 이제 내보내는 표기를 키로 쓰므로, 예전에는 하나의 추적 값으로 합쳐지던 서로 다른 모델이 고정 한도인 1000개 모델에 따로 계산됩니다. 모델이 아닌 레이블(api_key_id, backend, 가드레일 provider와 category, type, 스마트 라우팅 policy_name)의 표기는 바뀌지 않습니다. 또한 GET /admin/config/schema는 fallback.fallback_chains의 키와 항목 노드에 더 이상 pattern을 내보내지 않습니다. 이 패턴의 \p{...} 이스케이프는 ECMA-262에서 u 플래그가 있을 때만 유효하므로, Python jsonschema와 여러 Go·Java 검증기가 스키마를 거부하거나 잘못 해석했습니다. 두 노드는 minLength: 1과 maxLength: 128을 유지하고, 설명은 허용 문자를 계속 안내하며, 서버 측 규칙은 바뀌지 않습니다.

  • metrics.max_model_labels를 실제로 적용하고, 읽히지 않던 메트릭 한도를 사용 중단(deprecated)으로 표시합니다 (#1897). 이제 max_model_labels가 model_usage_total, model_tokens_processed, llm_tokens_total의 모델 상한을 정합니다. 이전에는 파싱만 되고 읽히지 않았으며 상한은 컴파일된 1000이었습니다. 기본값이 100에서 1000으로 바뀌므로 이 값을 설정하지 않은 배포는 실효 상한이 그대로이며, 설정한 값은 시작 시 적용됩니다(변경하려면 재시작). 값을 낮춰도 아무것도 제거되지 않습니다. 이미 추적 중인 모델은 계속 내보내고 새 모델은 거부됩니다. metrics.max_backend_labels와 metrics.cardinality_limits 블록은 계속 무시되지만 기존 파일이 로드되도록 파싱되며, 기본값과 다른 값이 설정되면 config validate와 로드 시 경고를 냅니다. 카디널리티 가드는 이미 추적 중인 레이블 값을 요청마다 쓰기 잠금을 잡는 대신 읽기 잠금만으로 받아들입니다.

  • 소스 체크아웃에서 기본으로 이식 가능한 바이너리를 빌드합니다 (#1799, PR #1810). .cargo/config.toml은 더 이상 x86_64 Linux와 macOS에 -C target-cpu=native를 설정하지 않습니다. 이 플래그는 호스트 proc-macro와 빌드 스크립트에도 적용되어, 산출물이 CPU 세대 사이를 오가면 빌드나 캐시된 테스트 바이너리가 SIGILL로 중단될 수 있었습니다. 로컬에서는 RUSTFLAGS나 사용자 수준 ~/.cargo/config.toml로 다시 켤 수 있습니다.
  • Dynamo 부하 구독자의 상주 버퍼를 유휴 구독당 약 48 KiB에서 2,624바이트로 줄였습니다 (#1755, PR #1764). ZMTP 프레임 버퍼는 512바이트에서 시작해, 선언된 길이가 MAX_FRAME_BYTES 검사를 통과한 뒤에만 2의 거듭제곱 단위로 커지며, 소켓 버퍼는 tokio 기본값 8 KiB 대신 읽기 2 KiB, 쓰기 64바이트입니다.
  • cargo deny check가 모든 선택적 기능 그래프를 다루게 하고 CI가 설치하는 protobuf 컴파일러 버전을 고정했습니다 (#1757, PR #1760). deny.toml이 all-features = true를 설정하므로 dynamo, control-plane, appproxy-router, otel 의존성의 라이선스나 보안 권고 문제도 검사에 실패하며, 모든 arduino/setup-protoc 단계(CI, 릴리스, 이제 protoc를 설치하는 주간 의존성 플로트)는 version: "36.2"로 고정됩니다.

Fixed

  • Smart Routing 페이지에서 저장할 때 WebUI 정책 편집기가 편집하지 못하는 라우팅 정책 조건을 보존합니다 (#1988, PR #1990). 편집기는 각 when을 complexity, domain, requires만으로 다시 만들었기 때문에, key_tier, org, language, header, input_tokens로 범위를 좁힌 정책이 저장 시 when: {}로 전송되어 모든 요청에 매칭되었습니다. 이제 이 조건들은 그대로 유지되고 정책 행에 표시됩니다.
  • 예약된 모델 실험 변형 모델을 스마트 라우팅 status와 simulate에서 제외하고, smart_routing_model_availability가 후보 풀을 그대로 따르도록 했습니다(#1989). hide_variant_models: true 실험의 변형 모델은 요청 경로에서만 걸러졌기 때문에, GET /admin/smart-routing/status가 이를 candidate_models(또는 excluded_unprofiled_models)에 나열하고 POST /admin/smart-routing/simulate가 실제 요청은 절대 가지 않는 이 모델을 대상으로 반환할 수 있었습니다. 이제 후보 풀이 예약된 변형 모델을 직접 제외하며, 요청 경로와 status, simulate가 같은 풀을 읽습니다. 가용성 게이지는 모델마다 따로 쓰지 않고 수집할 때마다 다시 만듭니다. 라우팅 가능한 후보는 1, 비정상이거나 unprofiled_models: exclude에서 프로필이 없어 제외된 모델은 0(제외된 모델이 예전에는 1로 보였습니다)이고, 풀을 떠난 모델(백엔드가 internal: true나 비활성으로 바뀐 경우, 새로 예약된 변형 모델, 집계에서 사라진 모델)이나 다른 티어로 옮긴 모델의 시계열은 재시작할 때까지 마지막 값을 유지하지 않고 제거됩니다. 모델 집계가 실패하면 게이지는 그대로 둡니다. SmartRoutingMetrics::set_model_availability는 수집 결과 전체를 받는 SmartRoutingMetrics::refresh_model_availability로 바뀌었으며, 수집 순번도 함께 받아 더 새로운 수집보다 늦게 끝난 수집은 반영하지 않습니다.
  • hybrid가 실제로 에스컬레이션하는 요청 비율을 명시하고 관측할 수 있게 했습니다 (#1973). 문서는 하이브리드 모드가 정말 모호한 요청("보통 전체의 10~20%")만 에스컬레이션한다고 약속했지만, 규칙 신뢰도는 라벨이 맞을 가능성을 추정하는 대신 신호 개수를 셉니다. 라벨이 붙은 정확도 데이터셋에서 hybrid는 기본 rule.confidence_threshold 0.7에서 요청의 53.7%를 에스컬레이션하고, 공개 벤치마크 프롬프트에서는 91%~99%가 임계값에 못 미쳤으며, 이 신뢰도가 맞은 복잡도 라벨을 틀린 라벨보다 높게 매기는 능력은 우연 수준을 넘지 못하므로(AUROC 0.46) 어떤 임계값으로도 모호한 소수만 골라낼 수 없습니다. 구성 문서는 이제 측정된 비율과 이 점수의 정체를 밝히고, 비율이 높을 때의 대안으로 rule과 shadow: true 또는 hybrid_system_one을 제시합니다. 새 카운터 smart_routing_hybrid_gate_total{method, outcome}(method는 hybrid 또는 hybrid_system_one, outcome은 routed 또는 escalated)로 /metrics에서 이 비율을 읽을 수 있고, 정확도 하네스는 방식별로 맞은 라벨과 틀린 라벨의 신뢰도와 에스컬레이션 비율을 보고합니다. 규칙 신뢰도 자체와 모든 기본값은 그대로입니다.
  • continuum-event-broker Helm 차트가 레지스트리 pull Secret을 두 가지 목록 형태로 받습니다(#1984). imagePullSecrets 항목은 - name: <secret> 또는 이름만 쓴 - <secret>일 수 있으며, 둘 다 모든 브로커 Pod에 {name: ...}로 렌더링됩니다. 다른 형태의 항목, 유효하지 않은 Secret 이름, 두 번 나열된 Secret은 API 서버가 거부할 Pod spec을 만드는 대신 렌더링 단계에서 실패합니다. 차트 README, 예제 values, Kustomize 번들(패치), 배포 가이드에 비공개 레지스트리나 미러에서 브로커 이미지를 받는 방법을 추가했습니다. 차트는 여전히 자격 증명을 렌더링하지 않습니다.
  • 규칙 분류기가 번역 요청을 multilingual로 분류하도록 했습니다 (#1976). DomainTag::Multilingual은 번역으로 문서화되어 있고 LLM 분류기와 System One 분류기도 번역 요청에 이 태그로 답하지만, 규칙 분류기는 비ASCII 문자 비중만 보고 이 태그를 붙였고 primary_language에서는 억제했습니다. 그래서 영어 번역 요청은 general로 읽혔고 한국어 배포판의 한국어 번역 요청도 마찬가지였으며, when.domain: [multilingual]은 둘 다 매칭하지 못했습니다. 새 translation_intent 도메인 신호는 단어로 쓰인 translate("translation lookaside buffer", CSS translateX, "untranslated"는 제외), 번역(번역가, 번역자, 번역기, 번역본, 번역서 제외), 翻訳, 翻译, 翻譯에서 발화하고 multilingual로 이어지며 primary_language가 억제하지 않습니다. 강도 0.72는 창작 마커 하나나 분석 마커 두 개보다 높고 펜스 코드, code_intent, 수식 신호보다 낮습니다. 다른 의도 신호처럼 규칙 신뢰도에 반영되므로, 짧은 번역 요청은 이제 상위 분류기로 넘어가지 않고 기본 하이브리드 임계값 0.7을 넘습니다. 의도 신호가 없을 때는 여전히 문자 체계 검사가 대체합니다. 타입 문서와 구성 문서의 도메인 표와 신호 표가 이 의미를 명시하고, 정확도 데이터셋의 번역 케이스 세 개(그리고 System One 라우팅 코퍼스의 라우터 출처 행 두 개)를 multilingual로 다시 라벨링했습니다. 도메인 정확도는 100%를 유지합니다. 보정 derived.json 파일은 커밋된 원시 실행에서 오프라인으로 다시 도출했으며(커밋된 실행이 라우터 출처 번역 행 두 개를 general로 기록했기 때문에 라우팅 코퍼스에서 규칙 도메인 정확도는 0.445에서 0.440으로, 규칙 단독은 0.477에서 0.475로 바뀝니다. 수정된 코퍼스 레이블로 채점하면 두 분류기 모두 이 행을 맞힙니다), System One의 도출 기본값은 어느 쪽이든 모두 그대로입니다.
  • 한국어 요청을 영어 번역과 같은 복잡도로 읽도록 했습니다 (#1972). 규칙 분류기의 한국어 단순 키워드는 영어 키워드가 요청 표현인 데 반해 맨 의문사였습니다. 무엇은 모든 "what"과 "which" 질문에, 어떻게는 모든 "how"에, 설명은 명사와 서술 동사에, 정의는 정으로 끝나는 명사의 속격(과정의)에 걸렸습니다. 그래서 한국어 요청이 영어 짝보다 한 구간 낮게 읽히는 경우가 그 반대보다 훨씬 많았습니다(벤치마크 809쌍의 불일치 251건 중 212건). 이제 설명은 요청 형태(설명해, 설명하세요, 설명 부탁)에서만, 무엇은 "what is"일 때만(…것은 무엇입니까, 무엇이었는가, 무엇을, 대명사 무엇인가를은 제외), 어떻게는 "how do I"일 때만(어떻게 바꾸었는가, 어떻게 되었나요, 어떻게 지내세요는 제외) 셉니다. 정의는 동사 어미가 뒤따르지 않으면 속격 경계를 넘어 세지 않습니다. 명사 증명은 점수 경로에서 더 이상 복잡 키워드로 세지 않고 동사 형태만 셉니다(prove를 포함한 영어 항목은 그대로입니다). 길이 추정에서 한글 한 음절과 한자 한 글자는 라틴 문자 두 개가 아니라 두 개 반으로 셉니다. 저장소의 영어·한국어 짝 픽스처 36쌍에서 한국어 쪽은 이전에 12.9% 짧게, 이후에 1.6% 길게 추정됩니다. 정확도 하네스에서 기존 케이스의 한국어 복잡도는 24/34에서 25/34로 오르고 영어는 27/34 그대로이며, 32쌍 중 30쌍이 같은 구간으로 읽히고(이전 27쌍), 복잡도 게이트는 72%에서 73%로 오릅니다. 한국어 인사말 안녕하세요! 오늘 하루 어떻게 지내세요?는 이제 11토큰으로 추정되어 simple로 읽힙니다. System One 보정 derived.json 파일은 규칙 분류기를 System One과 나란히 채점하므로, 커밋된 원시 실행에서 오프라인으로 다시 도출했습니다. 라우팅 shadow 표의 규칙 수치가 움직였고(all에서 규칙 단독 0.472에서 0.477), 도출된 기본값은 모두 그대로이며, non_latin 슬라이스에는 더 이상 규칙 분류기를 이기는 하한이 없습니다.
  • 내부 백엔드만 제공하는 모델을 스마트 라우팅 후보 풀에서 제외합니다 (#1975). 스마트 라우팅은 집계된 모든 정상 모델 가운데서 골랐기 때문에, 유일한 백엔드가 internal: true(또는 enabled: false)인 모델이 사용자 요청의 대상으로 선택될 수 있었습니다. 이제 후보 풀은 모델 목록과 같은 가시성 규칙을 적용하므로 그런 모델은 대상이 되지 않으며, 공개 백엔드도 있는 모델은 공개 백엔드만으로 상태를 확인합니다. 내부 백엔드는 여전히 LLM 분류기용으로 쓸 수 있습니다.
  • 리로드로 web_search가 사라지면 사용자별 웹 검색 자격 증명 바인딩을 해제하고 모든 검색 경로를 계측합니다 (#1970). search_credentials_v1을 알리고 스토어가 Hub가 전달한 검색 비밀값을 보유하게 하는 바인딩은 한 번 래치되면 해제되지 않아, 리로드로 web_search 섹션이 사라진 뒤에도 라우터가 capability를 계속 알리고 search_credentials_digest를 확인하며 어떤 요청도 쓸 수 없는 개인 비밀값을 보유했습니다. 이제 바인딩은 설정 리비전마다(시작, 파일 리로드, Admin 적용) 섹션을 따르며, 해제하면 capability를 거두고 보유한 비밀값과 확인 다이제스트를 즉시 버립니다. Anthropic web_search_20250305 에뮬레이션(자체 호스팅 백엔드에 대한 Claude Code의 WebSearch)은 이제 쿼리 추출 턴을 검색 계측과 함께 통계와 control-plane 사용량 레코드에 기록하고 조기 실패도 기록합니다. 각 검색은 폴백 체인의 여러 시도에 걸쳐서도 정확히 한 레코드에만 담깁니다. 검색한 뒤 이후 턴이 실패한 채팅 완성 또는 Anthropic 도구 루프는 멤버의 키로 비용을 낸 검색을 버리지 않고 토큰 0과 계측된 검색을 담은 사용량 레코드 하나를 내보냅니다. 검색하지 않은 실패 요청은 여전히 아무것도 내보내지 않습니다. 실패한 채팅 완성의 로컬 통계 행은 이제 unknown 대신 실패를 반환한 백엔드를 표시합니다.
  • 규칙 분류기의 신뢰도 계산에서 multilingual 문자 체계 신호를 제외했습니다 (#1971). classifier.rule.primary_language를 설정하지 않으면 대한민국의 수도는 어디야? 같은 짧고 단순한 한국어 질문이 기본 rule.confidence_threshold인 0.7에 정확히 도달했습니다. 강도 0.7의 이 신호가 신호 하나로 한 번, 강한 신호로 또 한 번 계산되었기 때문이며, 그 결과 hybrid와 hybrid_system_one이 LLM이나 System One 분류기에 묻지 않고 규칙 결과로 라우팅했습니다. 같은 질문이 primary_language: ko에서는 0.5로 계산되어 상위 분류기로 넘어갔습니다. 이 신호는 의도 신호가 없을 때 여전히 도메인을 결정하지만 신뢰도를 더 이상 움직이지 않으므로, 두 설정이 한국어 텍스트를 같은 신뢰도로 계산합니다. 또한 신뢰도 합계를 범위 제한 전에 1e-9 격자로 반올림하여, 임계값에 정확히 걸리는 조합(신호 4개 중 강한 신호 1개는 0.7999999999999999가 아닌 0.8)이 기본값이 아닌 임계값을 통과합니다. 모든 조합이 이제 십진수 값에 가장 가까운 f64가 되기 때문입니다. method: rule의 라벨은 바뀌지 않습니다.
  • GET /admin/smart-routing/status가 실효 스마트 라우팅 상태를 보고합니다 (#1974). 상태 응답은 classifier_method, 정책 수, 가상 모델을 로컬 smart_routing: 섹션에서 읽었기 때문에 Hub 정책 아래에서는 라우터가 실행하지 않는 분류 방식을 보고할 수 있었습니다. 이제 최상위 필드는 실행 중인 스마트 라우터와 티어 레지스트리에서 오며, 이를 로컬 값으로 읽던 소비자에게는 동작 변경입니다. 로컬 값은 새 configured 블록 아래로 옮겨졌습니다. 새 필드 source(hub 또는 local), hub_governed, smart_routing_policy_digest(실행 중인 Hub 다이제스트로 Hub에 확인 응답한 값과 같음), declined_hub_policy가 어떤 정책이 적용 중인지 보여 주며, WebUI 상태 탭도 출처를 표시합니다. POST /admin/smart-routing/simulate는 이제 하나의 직렬화기로 만든 POST /admin/smart-routing/classify와 같은 classification 객체(language, required_capabilities, reasoning, signals 추가)를 반환하며, 두 응답은 Admin API 가이드에 문서화되었습니다.
  • GET /v1/models/extended에서 모델마다 컨텍스트 윈도를 하나만 표시합니다 (#1479). 평탄화된 업스트림 필드는 한 백엔드의 max_model_len(또는 애그리게이터의 context_length)을 그대로 유지한 채 라우터의 metadata.limits.context_window 옆에 두었기 때문에, 두 값이 다를 때 객체가 서로 다른 윈도 두 개를 표시했습니다. 하나의 ID를 제공하는 백엔드들의 값이 달라 라우터가 최솟값을 게시하는 경우(#1456)와 model-metadata.yaml 또는 model-metadata.d/ 드롭인이 limits를 제공하는 경우입니다. 이제 두 키는 존재할 때 라우터가 실제로 지키는 윈도인 metadata.limits.context_window 값을 담습니다. 백엔드가 보내지 않은 키는 추가하지 않고, 게시된 윈도가 0(해당 없음)이면 다시 쓰지 않으며, 그 키에서 윈도를 얻은 단일 백엔드 모델은 이전과 똑같이 직렬화됩니다. pricing과 top_provider를 포함한 그 밖의 업스트림 키는 여전히 그대로 전달되며, 둘 다 표시하는 값은 metadata가 기준입니다. GET /v1/models, GET /v1/models/{model}, GET /admin/models는 바뀌지 않습니다. API 레퍼런스에 GET /v1/models/extended 절을 추가했습니다.
  • 스트리밍 Gemini 경로에서도 선언된 reasoning_efforts 어휘를 따릅니다 (#1668). gemini 타입 백엔드로 가는 스트리밍 /v1/chat/completions 요청은 타입 지정 GeminiBackend가 처리하는데, 이 백엔드의 Anthropic 고유 필드 게이트는 백엔드 타입만 고정하고 선언을 읽지 않았습니다. 그래서 thinking이나 output_config를 담은 요청이 스트리밍이면 Gemini 기본 어휘에, 비스트리밍이면 선언된 어휘에 맞춰졌습니다. 이제 스트리밍 디스패치가 타입 지정 변환 전에 요청이 라우팅된 설정 스냅샷으로 그 게이트를 실행하므로, 백엔드의 model_configs와 공유 메타데이터 계층(model-metadata.d/ 드롭인)이 두 경로에서 같은 방식으로 해석됩니다. 각 홉의 선언을 이미 라우팅된 스냅샷에서 해석하는 중간 스트림 폴백 릴레이에도 회귀 테스트를 추가했습니다.
  • smart_routing 섹션의 런타임 수정을 실행 중인 스마트 라우터에 적용합니다 (#1715). PUT /admin/config/smart_routing으로 classifier.method를 hybrid에서 llm으로 바꾸는 것처럼 smart_routing만 바뀐 설정 게시는 설정 API에는 반영되었지만 실행 중인 분류기에는 도달하지 않았습니다. 핫 리로드 비교가 이 섹션을 비교하지 않아 아무것도 적용하지 않았기 때문입니다. 이제 이 섹션을 비교하므로 스마트 라우팅만 바뀐 수정도 분류기를 다시 만듭니다. 이미 요청마다 읽히던 model_experiments만 바뀐 수정도 이제 변경으로 보고됩니다.
  • 규칙 분류기가 현재 턴만 채점하도록 바꿔, 대화 이력이 더 이상 스마트 라우팅 결정을 바꾸지 않습니다(#1714). 분류기는 대화 전체를 읽었습니다. 모든 메시지가 길이 신호에 합산되고, conversation_depth 신호가 메시지 수를 세고, 이전 턴의 키워드와 코드 마크업이 점수에 들어갔기 때문에, 짧은 대화 두 번 뒤의 사소한 질문은 simple, 복잡한 대화 한 번 뒤에는 moderate로 읽혔고, 늘어난 신호가 규칙 신뢰도를 기본 rule.confidence_threshold 0.7까지 올렸습니다. 그 결과 hybrid가 LLM 분류기로 에스컬레이션하지 않고, 이력 없이 같은 질문을 했을 때보다 큰 티어로 후속 질문을 보냈습니다. 이제 복잡도, 도메인, 신뢰도는 LLM 분류기가 판단하는 것과 같은 텍스트인 system, developer 메시지와 마지막 사용자 메시지에서 계산하며, 모든 method(rule, hybrid, 그리고 llm, system_one, hybrid_system_one의 규칙 대체 경로)에 똑같이 적용됩니다. 공개되는 language도 이제 마지막 사용자 메시지의 언어이므로, 영어 대화 뒤의 한국어 질문은 when.language: [ko] 정책에 매칭됩니다. 그 메시지에 글자가 없을 때만 모든 사용자 턴의 언어를 씁니다. required_capabilities는 여전히 대화 전체를 읽으므로, 이전 턴의 이미지에는 계속 비전 백엔드가 필요합니다. conversation_depth 신호는 제거되었습니다. 단일 턴 요청은 이전과 똑같이 분류되고, 라벨링된 데이터셋의 복잡도 정확도도 변하지 않습니다.
  • Dynamo shim을 통해 처리된 비스트리밍 사용량과 선택된 백엔드가 처리한 그 밖의 모든 버퍼링 disaggregated 응답을 제시된 Hub 키에 귀속합니다 (#1953, epic #1703). 버퍼링 disaggregated 경로는 요청 헤더 없이 사용량을 기록했기 때문에 control-plane 사용량 탭이 API 키를 보지 못해 레코드에 key_id="unattributed"를 찍고 fail-open 경고를 남겼으며 어떤 키에도 과금하지 않았습니다. 그 결과 이러한 요청은 키의 TPM 윈도와 월간 예산 델타에 반영되지 않았습니다. 이제 이 경로는 클라이언트 헤더와 함께, unified 경로가 기록하는 것과 같은 Hub 라우팅 귀속 정보 및 컨텍스트 압축 메타데이터로 기록하므로, 레코드에 일치한 키의 key_id, 사용자, 팀이 담기고 사용량이 키의 한도에 정확히 한 번 반영됩니다. 스트리밍 귀속과 로컬 캐시 재생 계량은 바뀌지 않았습니다. fail-open 경고 control-plane: usage recorded without hub key attribution에는 이제 cause 필드(no_policy_store, policy_not_synced, no_presented_token, unmatched_token, no_stream_grant)가 붙고, 토큰을 기록하지 않은 채 메시지에서 원인을 밝힙니다. control_plane.policy.enabled: true를 설정하라는 안내는 no_policy_store에만 남습니다. 각 원인은 각자의 1분 윈도로 빈도가 제한됩니다.
  • 폴백 체인의 사용량을 실제로 응답한 모델 이름으로 기록합니다 (#1934). fallback.fallback_chains가 실패한 주 모델에서 요청을 옮기면 체인 대상의 토큰이 실제 응답 backend 및 backend_id와 함께 클라이언트가 요청한 모델 이름으로 기록되었습니다. 그 결과 /admin/stats는 주 모델이 생성하지 않은 토큰을 주 모델에 집계했고, control-plane 사용량 레코드는 실제 공급자와 잘못된 모델을 짝지어 실제로 소비된 Hub 모델 예산 풀과 비용 센터 풀을 놓쳤습니다. 이제 /admin/stats의 모델 행, 모델별 및 키별 토큰 카운터, 모델 실험 토큰 귀속, 사용량 레코드의 model은 notify_on_fallback 설정과 무관하게 버퍼링되는 /v1/chat/completions, /v1/completions, /v1/embeddings, rerank, sparse embeddings에서, 그리고 백엔드 선택 시점, 첫 바이트 이전, 스트림 도중에 일어난 스트리밍 채팅 홉에서 체인 대상의 설정된 ID를 기록합니다. 일반 체인 홉은 requested_model을 기록하지 않으며, Hub 대체(substitution), 차익 거래(arbitrage), 예산 폴백이 적용된 경우 requested_model은 정책의 값을 유지합니다. 모델 예산 승인은 여전히 요청된 오퍼링의 풀을 검사하고 정산은 실제로 응답한 풀에 부과됩니다. 라이브러리 표면: 공개 메서드 MidStreamFallbackContext::begin_provider_attempt는 시도 대상 모델을 두 번째 인자로 받습니다. /anthropic/v1/messages와 /v1/responses 체인은 변경되지 않습니다.

  • Responses 세션 저장소의 범위를 프로세스가 아니라 라우터 인스턴스로 좁혔습니다 (#1941). 저장된 응답(store: true)이 프로세스 전체에서 하나의 저장소에 있었기 때문에, 한 프로세스 안의 두 라우터(serve_embedded, 라이브러리 빌더, 또는 하나로 묶인 테스트 바이너리의 멤버들)는 업스트림이 같은 ID로 응답할 때마다 서로의 세션을 읽고 덮어썼습니다. 패스스루 응답은 pt_<업스트림 ID>로 저장되므로 두 번째 쓰기가 첫 번째를 대체했습니다. 이제 각 AppState는 첫 Responses 요청 때 자신만의 저장소를 받으며, 백그라운드 정리 작업과 10,000개 세션 상한도 따로 가집니다. 해제된 상태의 저장소와 정리 작업은 다른 상태가 저장소를 등록할 때 함께 해제됩니다. 라우터가 하나인 프로세스, 즉 모든 프로덕션 배포는 이전과 동일하게 동작하며, 핫 리로드 후에도 같은 저장소를 유지합니다.

  • 모델별로 반복되는 백엔드 거부 로그의 빈도를 제한합니다 (#1926, epic #1703). 모델의 유일한 백엔드가 비정상일 때 거부된 요청마다 ERROR 또는 WARN 로그가 약 네 줄씩 기록되었기 때문에(No healthy backend found for streaming model ..., Failed to select backend for model ..., Router error: All backends are unhealthy, Dynamo shim 경로의 Disaggregated fast decode streaming dispatch failed), 분당 21,600회 재시도하는 클라이언트가 한 시간에 약 500만 줄의 로그를 만들었고 장애 원인을 설명하는 헬스 체크 전환 로그가 묻혔습니다. 이 지점들과 비스트리밍 경로의 No healthy backends available from candidates, 이 거부로 끝나는 재시도 루프의 Exhausted all retry attempts ...와 Non-retryable error ... 로그, 백엔드별 속도 제한 거부는 이제 로그 지점과 모델마다 첫 거부를 WARN으로 기록하고, 60초 구간 안의 반복은 DEBUG로만 남기며, 구간이 지난 뒤 다음 거부가 도착하면 횟수, 모델, 사유를 담은 WARN 요약 한 줄(N requests refused for model 'X' in the last 60s: all backends unhealthy)을 기록합니다. 추적하는 모델 테이블의 크기는 제한됩니다. 거부 메트릭은 변경되지 않습니다. 이 거부 로그는 ERROR가 아니라 WARN 수준으로 기록됩니다. ERROR 수준의 Router error: All backends are unhealthy, Exhausted all retry attempts, No healthy backend found 로그에 걸려 있던 알림은 WARN 수준을 함께 보거나 거부 메트릭을 기준으로 바꾸어야 합니다.

  • CI 테스트 단계가 테스트를 조용히 건너뛰던 문제를 수정했습니다 (#1485). cargo test --tests -- --skip integration_test는 tests/integration_test.rs를 제외한 적이 없습니다. libtest의 --skip은 테스트 경로에 대한 부분 문자열 일치이고 타깃 이름은 그 경로에 포함되지 않기 때문입니다. 대신 config_integration_tests(tests/mid_stream_fallback_test.rs)와 metrics_integration_tests(tests/rate_limiting_test.rs)의 테스트 4개가 제외되고 있었습니다. .github/workflows/ci.yml과 scripts/local-ci.sh에서 필터를 제거해 해당 테스트가 CI에서 실행되며, 주석과 단계 이름도 더 이상 제외를 주장하지 않습니다.

  • fallback.fallback_chains에서 HuggingFace와 Ollama 모델 ID를 받아들입니다 (#1585). 체인의 모델 이름은 기본 모델이든 대체 모델이든 기존의 유니코드 문자와 숫자, -, _, .에 더해 /와 :를 포함할 수 있으므로, ggml-org/gpt-oss-20b-MXFP4: [meta-llama/Llama-3.1-8B-Instruct, llama3:8b]도 검증을 통과하고 다른 체인과 똑같이 폴백합니다. 128바이트 상한은 그대로이고 공백, 제어 문자, 그 밖의 문장 부호는 여전히 거부되며, 이름은 바이트 그대로 사용됩니다. 거부 메시지는 이제 해당 문자와 바이트 오프셋을 알려 주고, 거부된 값은 이스케이프한 뒤 128자로 잘라 다시 보여 주므로 제어 문자, ANSI 시퀀스, 양방향 재정렬 문자가 로그나 검증 및 Admin 응답에 그대로 나타나지 않습니다. fallback.quality_check.models도 같은 규칙을 따릅니다. decisions.targets[].name과 모델 실험 이름 및 변형 ID는 운영자가 정하는 값이므로 [A-Za-z0-9._-]를 유지하며, 거부된 값이 org/model 형태이면 그렇다고 알려 줍니다. 폴백 메트릭(fallback_attempts_total, fallback_success_total 등)은 이제 original_model과 fallback_model의 /와 :를 _로 바꾸지 않고 그대로 내보내며, 64바이트에서 문자 중간을 자르는 대신 문자 경계에서 128바이트로 자릅니다(기존 방식은 긴 유니코드 모델 이름에서 panic이 날 수 있었습니다). 슬래시가 든 ID의 밑줄 표기로 매칭하던 대시보드에는 새 시계열이 보입니다. WebUI 체인 편집기와 Admin 설정 스키마도 새 규칙을 따릅니다.

  • 규칙 분류기가 짧은 형식적 증명 요청을 expert로 분류하고, 규칙 분류기가 expert를 결정하는 설정에서 이 수준에 의존하는 라우팅 정책을 config validate가 경고합니다 (#1617). 규칙 복잡도 점수는 0.75를 경계로 나뉘는 가중 평균이어서, 수식 표기가 없는 추정 200토큰 미만 요청은 0.683을 넘지 못했고, 실제로 expert는 어려운 요청이 아니라 긴 요청을 뜻했습니다. 이제 마지막 사용자 메시지에 수식 표기와 온전한 단어로서의 prove/proof(또는 증명)가 함께 있는 요청은 길이와 관계없이 expert로 읽힙니다. 단, simple 키워드가 complex 키워드보다 많으면 그렇지 않으며, 시스템 프롬프트나 이전 턴의 수식은 세지 않습니다. 새로운 아키텍처나 연구 수준의 짧은 요청은 신호가 일반적인 설계 요청과 똑같아서 여전히 complex 이하로 읽히며, 그 계산은 고급 설정 가이드의 복잡도 수준 절에 있으며, 짧은 경로가 하나 더 있다는 점(추정 51~200토큰, 수식 표기, 서로 다른 complex 키워드 네 개 이상)도 거기에 적혀 있습니다. 운영자가 reject_expert 부하 상태를 켜 두면, 이전에 complex로 읽히던 짧은 증명 요청은 이제 거부됩니다. config validate는 method가 rule, hybrid, hybrid_system_one일 때(하이브리드는 rule.confidence_threshold가 0.95 이하일 때만) complex 없이 expert만 매칭하는 정책을 smart_routing.routing_policies[N].when.complexity 위치에서 경고합니다. 라벨링 데이터셋에서 규칙 모드의 복잡도 정확도는 69.5%에서 76.3%로, 정확도 게이트는 65%에서 72%로 올라갔습니다.

  • 규칙 분류기의 형식적 증명 하한을 좁히고, LLM 분류기에 백엔드가 없으면 config validate가 경고합니다 (#1892, #1617 후속). 증명에 대해 묻는 요청을 가려내는 조건이 이제 simple 키워드와 complex 키워드를 마지막 사용자 메시지에서 비교합니다. 수식 표기와 증명 표현을 읽는 턴과 같은 턴이므로, 이전 턴의 "what is"가 증명 요청을 막지 않고, 이전 턴의 "implement"가 증명에 대한 질문을 expert로 바꾸지 않습니다. 한국어 어간 증명은 앞 음절이 증으로 끝나는 다른 낱말을 닫을 때(검증명령, 인증명세, 보증명세서) 더 이상 일치하지 않고, 존재증명처럼 붙여 쓴 증명 합성어는 그대로 일치합니다. method: llm 또는 hybrid에서 smart_routing.classifier.llm.backend가 지정되지 않았고 모든 백엔드가 internal: true 또는 enabled: false이면 이제 그 경로에 경고가 나오며, expert 한계 경고도 같은 경우를 다룹니다. 빈 값으로 지정한 백엔드는 로드 검사가 이미 그랬듯 런타임에서도 지정하지 않은 것으로 읽으므로, 분류기를 조용히 버리지 않고 사용자 라우팅이 가능한 첫 백엔드를 씁니다. 앞뒤에 공백이 붙은 이름은 그 이름의 백엔드로 해석됩니다. 메서드가 llm, hybrid, hybrid_system_one이면 빈 값은 보통 설정되지 않은 환경 변수를 뜻하므로 config validate가 경고합니다. 라벨링된 데이터셋의 복잡도 정확도(76.3%)와 커밋된 System One 보정 실행 결과는 바뀌지 않습니다.
  • 규칙 분류기의 형식적 증명 하한이 언어, 띄어쓰기, 대화 이력과 관계없이 같은 요청을 같게 다루도록 고쳤습니다 (#1899, #1892 후속). 증명에 대해 묻는 요청인지 가리는 검사가 이제 두 언어 모두에서 증명 용어를 한 번만, 그리고 요청 동사일 때만 셉니다. 그래서 "이 증명을 설명해줘 \(\sum ...\)"는 expert로 읽히지 않고 "Explain this proof of \(\sum ...\)"처럼 점수를 유지하며, "증명하고 각 단계를 설명해줘"는 여전히 하한이 적용됩니다. 이 검사 안에서 improve는 더 이상 complex 키워드로 세지 않습니다. 검사는 또한 마지막 사용자 메시지 자체의 언어에 맞는 키워드 표를 읽으므로, 긴 영어 턴 뒤에 오는 한국어 증명 질문에 더 이상 하한이 적용되지 않습니다. 증으로 끝나는 의학 용어 뒤의 증명(합병증명단, 후유증명칭, 통증명칭)은 더 이상 증명 표현이 아니며, 어간 뒤에 동사 어미가 오면 앞 음절 제외 규칙보다 우선합니다(보이고증명하시오는 이제 하한이 적용됩니다). disprove/disproof, 문장을 시작하는 "show that"("please"나 "now" 뒤 포함), 을 보이/을 보여 앞에 명사형으로 바꾼 주장이 오는 한국어 구문(무리수임을 보여라, 성립함을 보여라, 무리수라는 것을 보이시오)이 이제 증명 표현이며, "demonstrate"는 의도적으로 제외했습니다. 한국어 구문은 명사형 서술어 어미(임, 함, 됨, 짐, 음, 움, 큼)나 -는 것 절만 읽으므로, 받침이 ㅁ일 뿐인 명사(이 그림을 보여줘, 교점을 보여줘, 덧셈을 보여줘, 관심을 보이는)에는 하한이 적용되지 않습니다. 과잉 인식 하나는 감수하고 테스트로 고정했습니다. "Show that step again with \(\frac{a}{b}\)."에는 하한이 적용됩니다. 정확도 데이터셋에 증명 하한 사례 아홉 개를 추가했고, 그중 하나는 새 선택 필드 context를 쓰는 여러 턴 사례입니다. 68개 사례의 복잡도 정확도는 75.0%(51/68, 수정 전 67.6%)이며, 기존 59개 사례는 45/59로 변함없습니다.
  • 외부 Presidio 호환 인식기가 멀티바이트 문자 뒤의 pii 구간을 보고할 때 올바른 텍스트를 마스킹합니다 (#1862, #1857 후속). Presidio는 start/end를 유니코드 코드 포인트(Python str 인덱스)로 보고하지만 프로바이더는 이를 UTF-8 바이트 오프셋으로 읽었기 때문에, 엔티티 앞에 악센트 문자, 한글, 한자, 이모지가 있으면 마스킹 위치가 어긋났습니다. 서버 주소는 10.20.30.40 입니다는 서버 <REDACTED:IP_ADDRESS>0.20.30.40 입니다가 되었고, 문자 경계에 맞지 않게 된 구간은 버려져 담당자 홍길동 님, 연락은 John Smith에게의 John Smith가 그대로 나갔습니다. 이제 구간을 겹침 정리 전에 코드 포인트에서 바이트로 변환하므로 엔티티 전체를 마스킹하고 주변 텍스트는 보존합니다. ASCII만으로 된 텍스트의 동작은 바뀌지 않습니다. 오프셋은 코드 포인트여야 하므로 UTF-8 바이트나 UTF-16 오프셋을 보고하는 분석기는 호환되지 않습니다. 뒤집혔거나 텍스트 끝을 넘는 구간(그런 분석기가 만드는 구간)은 이제 잘못된 분석기 응답으로 보고 on_error를 따릅니다. fail_closed는 잘못된 응답 본문과 마찬가지로 검사를 실패로 처리하고, fail_open은 나머지 유효한 구간을 마스킹하고 개수를 경고 로그로 남기며 guardrail_degraded_total{kind="external_invalid_span"}를 증가시킵니다. 빈 구간은 이전처럼 무시합니다. 내장 스캐너는 IP 주소, 사람 이름, IBAN을 탐지하지 않으므로 이 인식기가 이들을 마스킹하는 유일한 경로입니다.
  • 다른 숫자 옆에 있는 전화번호를 마스킹합니다(#1863, #1860의 후속). 패턴의 마지막 그룹이 뒤따르는 5~12자리 숫자열의 앞자리를 가져가도 전화번호가 더 이상 드러나지 않습니다. 번호가 +, (, 0으로 시작하거나 그 안에서 시작하는 뒤 후보가 숫자열의 나머지를 가져가지 않으면 그 그룹을 뺀 번호를 마스킹하므로, 415-555-2770 337543, +82-10-1234-5678 482913, +81 3 8758 4450 082985는 전화번호를 마스킹하고 1588-8099 01099843848은 두 번호를 모두 마스킹하며, order 1234 030 12345678은 여전히 030 12345678을 마스킹합니다. 한 그룹 일찍 시작한 일치는 다음 그룹이 5자리 이상이어도 탐지기가 그 번호 전체를 인정하면 그 번호로 옮겨 가고(room 1419 07700 900159는 1419 07700이 아니라 영국 휴대전화 번호를 마스킹), 앞 그룹이 5자리여도 되며(ref 12345 010 1234 5678), 우편번호처럼 0으로 시작하는 4~5자리 토큰 뒤의 번호도 마지막 그룹이 더 이상 드러나지 않습니다(06236 010 1234 5678은 010 1234 5678을 마스킹하고 0163 020 7946 4552는 전체를 마스킹). 패턴이 다음 번호의 첫 그룹을 가져가 15자리 상한에 걸렸던 국가 번호는 그 그룹을 돌려주고 다시 마스킹합니다(자체 +로 시작하는 번호는 마지막 그룹을 유지하므로 room 0987 +86 138 0979 3831은 여전히 번호 전체를 마스킹). +44 20 7946 5121 0630은 +44 20 7946 5121을 마스킹하고 +44 20 7946 0958 4111 1111 1111 1111은 전화번호와 카드 번호를 마스킹하므로, #1857에서 알려진 빈틈으로 고정해 둔 경우가 해결됩니다. 240,000줄의 혼합 말뭉치에서 드러나는 전화번호 숫자는 28,878개에서 190개로, 마스킹되는 비개인정보 숫자는 10% 줄어들며, 이 말뭉치와 이슈의 형태, 200,000줄의 적대적 말뭉치 어디에서도 이전에 마스킹하던 전화번호나 카드 번호 숫자를 드러내는 경우는 없습니다.
  • 카드 번호 바로 뒤에 쓴 전화번호를 번호 전체로 마스킹합니다(#1868, #1863의 후속). 전화번호 패턴이 카드 번호의 마지막 그룹부터 일치할 수 있었고, 전화번호 검증기가 카드 번호의 숫자를 번호의 앞 그룹으로 읽었기 때문에 구간이 카드 번호 끝에서 잘려 번호의 마지막 그룹이 평문으로 남았습니다. 4681 3716 0697 9774 010 1841 1335는 <credit_card> <P> 1335가 되었습니다. 이제 전화번호 구간이 카드 번호 안에서 시작해 그 뒤에서 끝나면 카드 번호 바로 뒤에서 시작하는 후보로 전화번호 탐지를 한 번 더 수행하고 두 번의 탐지 결과를 모두 유지하므로, 번호 전체를 마스킹하면서 첫 번째 탐지가 마스킹한 숫자는 하나도 드러내지 않습니다. 그런 구간이 없는 텍스트는 한 번만 탐지합니다. 1~3자리 토큰과 3자리 토큰 뒤에 오는 번호도 두 토큰과 함께 마스킹하므로(2 118 07700 900907, 8 681 020 7946 0402) 마지막 그룹이 더 이상 평문으로 남지 않습니다. 카드 번호 탐지기가 전화번호의 첫 그룹을 카드 번호의 짧은 마지막 그룹이나 겹치는 카드 후보의 나머지로 읽은 경우에도, credit_card: allow가 카드 구간을 빼면 그 그룹은 마스킹된 채로 남습니다(4545 9033 0689 8841 415 555 1780은 415 555 1780을 마스킹). #1863의 240,000줄 혼합 말뭉치에서 40줄에 걸쳐 드러나던 전화번호 숫자 190개가 모두 마스킹되고 마스킹되는 비개인정보 숫자는 0.21% 늡니다. 이 말뭉치와 200,000줄의 적대적 말뭉치 두 개, 60,000줄의 혼합 말뭉치 두 개, 형태 말뭉치 세 개에서 기본 mask 정책, phone: block, phone: allow, credit_card: block 아래 이전에 마스킹하던 숫자를 드러내는 경우는 없으며, credit_card: allow 아래에서도 전화번호 숫자는 드러나지 않습니다.
  • 내장 pii 프로바이더에 남아 있던 전화번호 유출 형태를 막습니다(#1894, #1868의 후속). 한 그룹 일찍 시작한 일치를 왼쪽 확장이 앞 번호의 0으로 시작하는 마지막 그룹까지 넓히면, 번호는 그 이른 시작을 유지한 채 마지막 그룹을 잃었습니다. +44 20 7946 0527 3700 020 7946 7480은 <P> <P> 7480이 되었고, 020 7946 5629 4279 3413 0472 1495 020 7946 2290, page 0 09305 020 7946 1556, 020 7946 0757 21087 07700 900578, +82 10 1389 0551 5088 07700 900675도 같은 방식으로 새었습니다. 둘 중 하나가 0으로 시작하는 짧은 토큰 두 개 뒤의 번호도 마지막 그룹을 잃었습니다(177 021 07700 900011, 080 832 010 0689 4644, +44 20 7946 9625 071 962 020 7946 3640). 이제 첫 전화번호 탐지가 그런 구간을 보면, 패턴이 찾은 시작에서 앞 그룹을 다시 판정하고 0으로 시작하는 두 토큰 앞 그룹도 받아들이는 검증기로 두 번째 탐지를 수행하며, 그 구간은 첫 탐지의 구간을 그 숫자를 모두 덮을 때만 대체하므로 이전에 마스킹하던 숫자는 드러나지 않습니다. 0으로 시작하는 카드 구간은 카드 번호가 아니므로, 그 구간의 시작에서 시작해 구간 안에서 끝나는 전화번호는 구간의 뒤 그룹에서 실제 카드 번호가 시작하지 않는 한 더 이상 함께 버려지지 않습니다(0310 3782 822463 10005는 여전히 전체가 허용됨). credit_card: allow에서 07700 900700 79271 020 7946 7001은 두 전화번호를 모두 마스킹하고, phone: block은 이를 차단합니다. 0으로 시작하지 않는 카드 형태 오탐 안의 전화번호는 실제 카드 번호와 구별할 규칙이 없어 credit_card: allow에서 여전히 드러나며, 가드레일 안내서에 알려진 유출로 적었습니다. 300,000줄의 정답 표시 말뭉치(일곱 나라 형식의 전화번호, 허용되는 모든 묶음의 카드 번호, 날짜, 시각, 가격, ID, 버전, IP 주소, 적대적 인접 배치), 그 말뭉치의 다른 시드, 이슈 시제품의 분포를 따른 200,000줄 말뭉치에서 기본 mask 정책, credit_card: allow, phone: block, phone: allow, credit_card: block 아래 이전에 마스킹하던 숫자를 드러내는 경우는 없으며, 마스킹되는 비개인정보 숫자는 기본 정책에서 최대 0.08%, credit_card: allow에서 최대 0.84% 늡니다. 시제품 분포 말뭉치에서 평문으로 남는 전화번호 숫자는 기본 정책에서 28,450개에서 8개로, credit_card: allow에서 62,402개에서 21,499개로 줄어듭니다.
  • 내장 pii 프로바이더가 짧은 토큰과 실제 카드 번호의 앞 그룹을 하나의 전화번호로 읽지 않게 합니다(#1937, #1894의 후속). 공백으로 묶은 카드 번호 앞의 토큰에서 시작해 카드 번호 안에서 끝나는 전화번호 범위는 카드 번호 안에 완전히 들어가지 않아 유지되었습니다. credit_card: allow에서 0311 3782 822463 10005는 0311 3782를 전화번호로 마스킹했고, phone: block은 전화번호가 없는데도 이를 차단했습니다. 0003 4111 1111 1111 1111, 0311 3056 930902 5904, 03 3782 822463 10005도 같았고, 0으로 시작하는 범위가 Luhn 체크섬을 통과하는 0311 4111 1111 1111 1111은 카드 번호의 마지막 두 그룹을 마스킹했습니다. 이제 그런 범위는 카드 번호 바로 앞에서 잘리고 남은 숫자가 7자리보다 적으면 버려지며, 0으로 시작하는 떠도는 토큰 뒤의 실제 카드 번호 안에 완전히 들어가는 전화번호 범위도 버려집니다. 그래서 이 행들은 credit_card: allow에서 전체가 허용되고 phone: block에서 차단 대신 마스킹되며, 02 1234 5675 4111 1111 1111 1111은 02 1234 5675를 계속 마스킹하고 차단합니다. 두 규칙은 네트워크가 인쇄하는 방식으로 적은 카드 번호가 숫자 그룹 열의 끝에서 끝나고 열의 다른 그룹에서 카드 번호가 시작할 수 없을 때만 적용되며, 온전한 전화번호의 끝이 될 수 있는 숫자는 자르지 않습니다. 같은 텍스트가 전화번호 뒤에 짧은 숫자가 이어진 경우(02 1234 5678 9012 3452)만큼 흔하기 때문이며, 그렇게도 읽히는 5678 4111 1111 1111 1111과 (03) 4111 1111 1111 1111은 전화번호 범위를 유지합니다. 두 번째 규칙은 카드 구간이 숫자를 모두 덮는 범위만 버리며(0647 6515 4450 8558 3827 006ID 2076처럼 문자에 붙은 카드 번호는 카드 구간이 아님), 토큰에서 시작하는 온전한 전화번호가 끝난 뒤에서 시작하는 범위만 버립니다(016977 4050 6177 9526 5756 826은 4050을 계속 마스킹). 0이나 +로 시작하지 않는 번호는 15자리까지 될 수 있으므로, + 없이 적은 국가 코드(86 138 1234 4567 8901 2345 6783)의 숫자도 계속 마스킹됩니다. 150만 줄의 정답 표시 말뭉치(#1894의 말뭉치 세 개, 0으로 시작하는 토큰, 그 밖의 토큰, 괄호 토큰, 전화번호 뒤의 실제 카드 번호 400,000줄, 짧은 숫자가 뒤따르는 전화번호 300,000줄)와 독립적으로 만든 500,000줄 말뭉치에서 기본 mask 정책, credit_card: allow, phone: block, phone: allow, credit_card: block 아래 이전에 마스킹하던 숫자를 드러내는 경우는 없고, 새로 마스킹되는 비개인정보 숫자도 없습니다. credit_card: allow에서 전화번호로 마스킹되는 카드 번호 숫자는 2,512,026개에서 2,262,464개로, 1,018,884개에서 971,688개로 줄어듭니다. phone: block에서 탐지되는 전화번호가 있는 줄이 차단을 멈추는 경우는 없으며, 두 버전 모두 탐지하지 못하는 전화번호(예: +33 1 74 53 03 18)가 있는 줄 가운데 토큰과 카드 번호 앞부분에 걸친 범위만으로 차단되던 9줄과 161줄이 차단을 멈춥니다. 이 수는 탐지하지 못하는 전화번호 형식의 비율에 따라 늘어납니다.

  • 내장 pii 프로바이더가 유럽, 러시아, 불규칙한 국내 형식, 슬래시로 나눈 형식, 구분자 없는 형식으로 적은 전화번호를 탐지합니다(#1947, #1937의 후속). 전화번호 패턴은 3자리 그룹 뒤에 3자리나 4자리 그룹을 요구하므로, 1자리와 2자리 그룹으로 적은 번호는 기본 정책에서 마스킹되지 않고 phone: block에서 차단되지 않았으며(+33 1 74 53 03 18, +7 495 301-77-50, 8 (495) 301-77-50), 3-2-2 꼬리는 마지막 쌍을 평문으로 남겼고(+7 495 301 77 50, 044 668 18 00, 0212 123 45 67), 01267-1-5398, 1800-87-6141, 030/12345678, Mobile: 13800138000은 전혀 탐지되지 않았습니다. 이제 두 번째 전화번호 스캐너가 이 형식들을 읽습니다. 각 형식에는 강한 기준점이 필요합니다. 번호를 두 자리씩 적는 나라의 + 또는 00 국가 코드, 국내 접두 번호 0, 정확한 형태의 러시아 접두 번호 8, 수신자 부담 번호 1800이 기준점이 되고, 프랑스 국내 번호, 공백으로 나눈 영국 불규칙 번호, 구분자 없는 중국 휴대전화 번호는 앞의 전화 단어가 기준점이 됩니다(Tél. : 01 42 68 53 00, Tel: 01267 1 5398, 手机13800138000). 일반 전화 단어(home, work, office, ext, contact, call)는 번호 바로 앞이거나 call me at 형태일 때만 기준점이 되고, 강한 전화 단어는 사이에 ASCII가 아닌 문자를 하나까지만 허용합니다. 그래서 /home used 15393645540 bytes와 手机销量 16836428018 台은 평문으로 남습니다. 식별자 단어(ref, lot, order, Kundennummer, 주문, 订单 등) 뒤의 국내 형식과, 바로 앞에 전화 단어가 없을 때 공백으로 다른 숫자와 이어진 국내 형식은 마스킹하지 않습니다. 수열이나 복권 번호로 읽히는 프랑스 형식, 전화 단어가 없는 독일 형식의 0으로 채운 카운터나 파일 이름, 번호를 두 자리씩 적지 않는 나라 코드로 시작하는 두 자리 묶음 데이터 행(+10 20 30 40 50)도 마스킹하지 않습니다. 알려진 누출은 테스트에 고정하고 문서화했습니다. +나 00 없이 적은 국가 코드(33 9 61 83 82 88), 그리고 앞에 전화 단어가 없는 프랑스 국내 번호, 공백으로 나눈 영국 불규칙 번호, 중국 휴대전화 번호(01 42 68 53 00, 01267 1 5398, kim,13800138000,seoul)입니다. 0으로 채운 표의 행, 점으로 나눈 빌드 번호, 서로 무관한 인접 숫자, 주문 번호가 같은 형태를 갖기 때문입니다. 가드레일 문서에는 이 형식이 여전히 마스킹하는, 전화번호가 아닌 형태(Step 03630/100000, Δ +43 79 06 04 33, 1800 12 2026, KW 05 123 45 67, home: 16235080111)도 적었으며, phone: block은 이를 차단합니다. #1937 규칙에 쓰는 카드 네트워크 표에는 Diners Club 16자리와 3095, Mir, Maestro, RuPay, Elo, Hipercard, Verve, Troy, 13자리 Visa의 4-4-4-1 그룹 구성이 추가되어, credit_card: allow에서 0003 3056 9300 0902 0004와 0311 3095 123456 7897이 전체가 통과합니다. 4-4-4-4 카드 번호 앞의 접두 번호 토큰(0312 4111 1111 1111 1111)은 설계상 과하게 마스킹되며 이제 문서화했습니다. 새 형식과 오탐 위험을 담은 정답 표시 말뭉치 500,000줄, #1937 말뭉치 150만 줄, 독립 리뷰어의 830,000줄에서, 기본 mask 정책, credit_card: allow, phone: allow, credit_card: block 아래 이전에 마스킹하던 전화번호나 카드 번호 숫자를 드러내는 경우는 없고, phone: block에서 탐지되는 전화번호가 있는 줄이 차단을 멈추는 경우도 없습니다. 새 형식에서 새는 전화번호 숫자는(고정한 누출은 제외) 1,901,810개에서 83,135개로 줄고, 그 500,000줄에서 실제 전화번호가 없는 줄이 새로 차단되는 경우는 없습니다. 다만 넓어진 네트워크 표 때문에 phone: block에서 두 버전 모두 탐지하지 못하는 형식의 전화번호가 있는 줄 가운데 그 500,000줄의 69줄과 리뷰어 말뭉치의 21줄이 차단을 멈춥니다(0003 6362 3386 3784 5325 tel 33 2 67 00 22 73). 이전 코드는 떠도는 토큰과 카드 번호 앞부분에 걸친 범위로만 이 줄들을 차단했기 때문입니다. 새 형식은 이전 코드가 평문으로 두던 비개인정보 숫자 271개와 5개를 마스킹하며(이전에 마스킹하던 비개인정보 숫자의 0.045%와 0.0002%), 카드 네트워크 표는 토큰 숫자 10,142개와 553개를 풀어 줍니다. 탐지 시간은 숫자가 빽빽한 텍스트에서 최대 약 두 배, 회귀 말뭉치에서 약 20% 늘어납니다.

  • 분리형 서빙의 통합형 폴백에서 prefill 워커와 decode 워커를 제외합니다(#1864, #1855 후속). fallback_to_unified가 켜져 있으면 분리형 서빙이 되돌려 보낸 요청(prefill-then-decode 와이어 프로토콜을 쓸 수 없으므로 prefill 워커와 decode 워커가 모두 있는 배포의 모든 콜드 요청, 그리고 실패한 모든 fast-decode 디스패치)이 모델을 서빙하는 모든 백엔드로 다시 디스패치되었습니다. 그래서 기본 round-robin이 prefill 전용 워커로, 또 방금 실패한 decode 워커로 되돌아가 순환했고, 이는 unified 백엔드로 폴백한다는 문서 내용과 어긋났습니다. 이제 버퍼링과 스트리밍 두 경로 모두 호출자가 접근할 수 있는 사용자 라우팅 가능 비역할 백엔드에서 실패한 백엔드를 뺀 allow-list로 폴백을 실행하며, 이 목록은 그중 하나가 모델을 서빙하고 지금 선택될 수 있을 때(헬스, 서킷, 백엔드별 rate limit, 허브 자격 증명 바인딩)만 쓰입니다. 그렇지 않으면 실패한 백엔드를 뺀 도달 가능한 전체 집합을 쓰고, 실패한 백엔드가 유일하면 그대로 선택 대상으로 남습니다. 이 목록은 API 키의 allowed_backends와 허브 또는 모델 실험 고정을 좁히기만 할 뿐 넓히지 않으며 빈 목록이 되지 않습니다. unified 백엔드가 서빙하는 동안에는 역할 워커를 지정한 x-backend 선호 헤더가 폴백에서 무시됩니다. Dynamo shim 디스패치는 allow-list가 풀이므로 어떤 레플리카도 제외하지 않습니다. 좁혀진 목록은 라우팅된 모델 자체의 시도에만 적용됩니다. 체인 홉(스트림 전, 스트림 중, 품질 판정)은 호출자 자신의 목록으로 실행되므로, 역할 워커만 서빙하는 체인 대상 모델에도 여전히 도달할 수 있습니다. 요청 중복 제거 키는 이제 유효 백엔드 allow-list가 비어 있지 않을 때 그 목록을 포함하므로, 본문과 헤더는 같지만 유효 목록이 다른 두 요청(설정 게시 없이 반영된 허브 정책 변경, 서로 다른 백엔드에 고정된 실험 arm, 또는 이 좁히기)이 더 이상 결과나 캐시된 오류를 공유하지 않습니다. 제한이 없는 호출자는 기존 키를 그대로 유지합니다.

  • 버퍼링 chat 요청과 Anthropic 재시도마다 입력 결정을 하나만 기록합니다 (#1855, #1847 후속). 통합형이든 disaggregated_serving을 거치든 버퍼링 /v1/chat/completions 요청은 더 이상 백엔드 호출과 함께 두 번째 입력 검사를 실행하지 않습니다. pre-call 게이트가 이미 모든 입력 프로바이더를 실행하고 기다리므로, 그 검사는 숨길 지연이 없었고 각 프로바이더를 한 번 더 호출해 guardrail_verdicts_total과 감사 로그에 두 번째 결정을 기록할 뿐이었습니다(fallback_to_unified 재시도는 한 번 더 실행했습니다). 이제 버퍼링 chat 요청마다 입력 프로바이더를 한 번씩만 호출하며, 스트리밍 chat과 마찬가지로 입력 단계는 게이트 이후에 주입되는 운영자의 전역 프롬프트를 검사하지 않습니다. guardrail::run_input_during_call과 GuardrailService::run_input_during_call은 라이브러리 호출자를 위해 남아 있으며, 어떤 라우터 경로도 호출하지 않습니다. /anthropic/v1/messages에서는 모든 시도(첫 시도와 각 폴백 홉)가 thinking 검사를 요청 범위의 결정 하나에 합칩니다. 메인 검사 시점에 없던 가드레일 서비스가 나중에 생긴 경우도 마찬가지이므로, 여러 변환 시도에서 검사된 요청도 시도마다 하나가 아니라 가장 심각한 결정 하나만 기록합니다.
  • 모든 요청 수준 입력 결정을 요청을 판정한 정책으로 한 번만 기록합니다 (#1847, epic #1796). 보류 중인 결정에 합쳐지는 검사는 이제 합쳐질 때의 정책이 아니라 첫 프로바이더 호출 전에 읽은 정책으로 기록하며, /v1/completions와 도구·스키마 정의 게이트도 시작할 때 정책을 한 번 읽으므로, 긴 요청 도중의 리로드(라우트의 가드레일을 끄거나 enforce를 monitor로 바꿈)가 결정을 없애거나 enforce로 거부한 요청을 monitor로 표시하지 않습니다. 외부 스마트 라우팅 분류기 앞에서 검사한 responses_only chat 요청은 결정을 두 개가 아니라 하나 기록합니다. 브리지가 이제 chat 입력 게이트를 실행하여 분류기 패스를 재사용하거나 그 결정에 합칩니다. 승인 전에 검사한 /anthropic/v1/messages thinking 검사는 승인된 백엔드가 변환할 때만 합쳐지므로, 네이티브 백엔드로 옮겨간 시도가 마스킹 없이 보낸 텍스트에 대해 transform을 기록하지 않습니다. /anthropic/v1/messages/count_tokens는 도구 정의 거부를 더 이상 기록하지 않으므로(여전히 400으로 응답) 두 모드 모두 결정을 기록하지 않습니다. /v1/completions는 어떤 검사가 요청을 판정했을 때만 결정을 기록하므로, 맞지 않은 매치 리스트만 거친 요청은 chat과 마찬가지로 아무것도 기록하지 않습니다. compact 본문이 담을 수 없는 /v1/responses/compact 입력은 응답하는 거부를 기록합니다.
  • 아직 검사 없이 백엔드에 도달하던 입력 텍스트를 검사합니다 (#1836, #1827 후속). /anthropic/v1/messages/count_tokens는 텍스트를 업스트림에 보내는 경로(네이티브 Anthropic과 엔진 /tokenize)에서 /anthropic/v1/messages와 똑같이 게이트되며, 전달하기 전에 본문을 마스킹합니다. 거부는 400 Anthropic 오류이고 로컬 추정은 게이트하지 않습니다. count_tokens는 모델 호출에 응답하지 않으므로 요청 수준 결정을 기록하지 않습니다. /anthropic/v1/messages의 OpenAI 호환 변환에서는 reasoning_content로 전달되는 thinking 텍스트를 시도마다 실제로 승인된 백엔드 기준으로 검사하고 마스킹합니다(승인 경쟁에서 져 네이티브 경로에 도달하면 서명된 원래 텍스트를 보냄). chat에서는 vLLM documents의 각 항목을 읽고 제자리에서 마스킹하고, chat_template_kwargs의 문자열 값은 제자리에서 마스킹하되 인자 이름은 검사만 하고 다시 쓰지 않으며, llama.cpp의 response_format.schema를 response_format.json_schema처럼 게이트합니다. 도구 최상위의 식별자 모양 type과 cache_control 값도 끝에 붙은 _YYYYMMDD 버전 날짜를 뗀 뒤 계약 텍스트로 읽으므로, 거부 용어를 그 자리에 숨길 수 없습니다. /v1/responses에서 정책 범위가 다른 모델로 폴백하거나 정책이 리로드되면 모든 전략(passthrough 포함)에서 클라이언트의 도구와 입력을 그 모델로 다시 검사합니다. 여러 패스로 검사하는 요청(변환 시도의 thinking 블록, 다시 검사하는 /v1/responses 폴백이나 이어가기, 분류기 전과 라우팅 후에 검사하는 스마트 라우팅 chat 요청)은 이제 패스마다가 아니라 가장 심각한 요청 수준 결정 하나만, 요청이 끝날 때(비스트리밍은 업스트림 응답 뒤, 스트리밍은 핸들러 반환 시) 기록합니다.

  • Anthropic document와 search_result의 텍스트를 입력 단계에서 검사합니다 (#1848, #1836 후속). /anthropic/v1/messages와 그 count_tokens 라우트에서 search_result 블록의 text 블록과 title을 읽고 제자리에서 마스킹하며, source URL은 검사하되 다시 쓰지 않습니다. 이전에는 네이티브 Anthropic과 Bedrock 백엔드에 검사 없이 도달했습니다. tool_result 안의 search_result나 document는 원본 본문을 전달하는 count_tokens에서 읽으며, /anthropic/v1/messages는 여전히 그 위치를 파싱 단계에서 거부합니다. 미디어 타입이 text/*인 base64 document는 디코딩해 검사하지만 다시 쓰지 않습니다. 그 위의 마스크는 guardrail_mask_unappliable로 거부하고, 디코딩 결과가 1 MiB를 넘거나, 요청의 디코딩된 문서 합계를 4 MiB 넘게 만들거나, utf-8이나 us-ascii가 아닌 charset을 지정하거나, 올바른 base64가 아니거나, NUL 없는 UTF-8이 아닌 문서는 검사할 수 없는 입력입니다(enforce에서는 uninspectable_input, monitor에서는 통과). file_id 참조는 이제 입력 게이트 전에 한 번만 해석하므로 text/plain, text/csv, text/html 파일도 인라인 문서처럼 읽으며, 가드레일 거부는 파일을 불러온 뒤에 일어납니다. 클라이언트에 보이는 변경: 파싱 단계에서 거부되던 text 또는 content 소스의 document 블록을 받아들입니다. 그 텍스트, title, context는 제자리에서 마스킹하고, 네이티브 경로는 title, context, citations, cache_control을 그대로 전달하며(블록 수준 cache_control은 이전에는 버려졌고, 이제는 자동 cache-control 주입도 막음), OpenAI 호환, Gemini, Responses 변환은 텍스트를 텍스트 파트로 보냅니다. count_tokens는 이제 디스패치가 승인된 백엔드로 경로를 고르므로, 승인 과정에서 네이티브 셈이 추정 백엔드로 옮겨 가면 그 백엔드에 /v1/messages/count_tokens를 보내지 않고 로컬 추정으로 응답합니다. /v1/responses에서 thought: true인 Gemini 파트는 버퍼링과 스트리밍 모두 더 이상 output_text로 렌더링하지 않습니다.

  • 입력 단계에서 남아 있던 Anthropic 텍스트 블록과 문서 타입을 검사합니다(#1854, #1848의 후속). /anthropic/v1/messages와 그 count_tokens 라우트에서 미디어 타입이 text/*는 아니지만 텍스트 형식인 base64 document(application/json, application/xml, application/javascript, application/yaml, application/toml, application/sql, application/graphql, application/x-sh, 그리고 +json, +xml, +yaml 타입)는 text/*처럼 디코딩해 검사하고, application/pdf를 제외한 그 밖의 타입의 base64 문서는 검사할 수 없는 입력(enforce에서는 uninspectable_input, monitor에서는 통과)으로 처리하므로 알 수 없는 타입은 닫힌 쪽으로 실패합니다. 이전에는 둘 다 모든 백엔드 경로에 검사 없이 도달했습니다. 검사할 때 base64 문자 사이의 ASCII 공백을 건너뛰므로, 깨끗한 MIME 줄바꿈 텍스트 문서를 더 이상 검사 불가로 거부하지 않으며, 전달하는 data는 바뀌지 않습니다. 라우터가 네이티브 Anthropic과 Bedrock 백엔드에 그대로 전달하는 블록 타입(server_tool_use, mcp_tool_use, mcp_tool_result, web_search_tool_result, web_fetch_tool_result, 그리고 더 새로운 타입)은 이제 식별자, 불투명 토큰, cache_control, citations, media_type을 제외한 모든 문자열을 검사하되 다시 쓰지 않으며, 중첩된 document는 문서로 읽습니다. 클라이언트에 보이는 변경: 422로 응답하던 tool_result 안의 search_result와 document를 받아들여 입력 단계에서 읽고, file_id는 같은 참조 수와 바이트 예산 안에서 해석하며, OpenAI 호환, Responses, Gemini 변환의 도구 메시지에 텍스트로 넣습니다. base64, url, file_id 소스의 중첩 문서는 이 변환들에서 버리지 않고 400 invalid_request_error로 응답합니다. document 블록은 이제 source, title, context, citations, cache_control만 유지하며, #1848 이후 네이티브 백엔드에 전달되던 알 수 없는 필드는 다시 버립니다. AnthropicContentBlock::Document의 extra 맵은 타입이 있는 citations와 cache_control 필드로 바뀌고, AnthropicToolResultBlock에 SearchResult와 Document 변형이 추가됩니다. 원시 도구 사용 블록의 input은 키와 상관없이 통째로 읽습니다. AnthropicCacheControl에 공개 필드 ttl이 추가되어, 브레이크포인트의 ttl(예: 1h)을 버리지 않고 모든 블록 타입과 도구 정의에서 유지하며, tool_result 안에 중첩된 블록의 브레이크포인트도 자동 cache-control 주입을 막습니다. action-safety와 usage.quality_judgment 컨텍스트는 중첩된 search_result와 텍스트 document 블록을 도구 출력으로 읽습니다.

  • 캐시에 없는 큰 도구 목록이 수렴하게 하고, 전화번호의 일부만 마스킹하던 문제를 고칩니다 (#1850, #1835와 #1837 후속). 검사할 도구 정의 뷰가 뷰 상한(기본 묶음 크기 4 KiB에서 128개)을 넘으면, 이제 아무것도 검사하지 않고 거부하는 대신 상한까지의 첫 뷰를 검사해 깨끗한 도구를 기억한 뒤 요청을 검사할 수 없는 요청으로 취급합니다. enforce는 그 요청을 여전히 uninspectable_input으로 거부하고 monitor는 여전히 block 하나를 기록하지만, 같은 깨끗한 도구를 다시 보내는 클라이언트는 최대 한 번 거부된 뒤 처리되며, 요청 하나의 비용은 여전히 상한을 넘지 않습니다. 마스킹된 도구는 기억하지 않으므로 마스킹된 뷰가 상한보다 많은 목록은 계속 거부되며, 비결정적 프로바이더에서는 묶음 판정이 60초만 유지되므로 다시 보낸 요청이 그 안에 도착해야 합니다. 내장 pii 전화번호 탐지기는 이제 패턴이 잡지 못한 국가 번호, 지역 번호, 국내 접두 번호까지 마스킹하고(+44 20 7946 0958, +82 10-1234-5678, (02) 1234 5678, 10-1234-5678), 패턴이 접두 번호를 갈라 놓은 연속 번호도 각각 마스킹하며(02 1234 5678 02 9876 5432, 415-555-0123 212-555-0199. 두 번호가 함께 Luhn 검사를 통과하는 숫자열을 이루면 여전히 카드 번호 탐지기가 가져가 일부만 마스킹됨), 점이나 하이픈으로 이어진 더 긴 숫자의 끝부분(3.415-555-0123, 2024-01-1234-5678), 버전 뒤의 숫자열(1.0.0-rc.0123456789), 식별자에 하이픈으로 이어진 번호(id-1234-5678-9012, order-1234-5678), 화면 해상도(3840-2160), 연도 범위(2019-2024)는 더 이상 마스킹하지 않습니다. 1588-1234 같은 대표번호와 HP-010-1234-5678처럼 이름표가 붙은 번호는 여전히 마스킹합니다.

  • 겹치는 범위와 카드 번호 탐지의 빈틈으로 내장 pii 프로바이더가 카드 번호와 전화번호 숫자를 흘리던 문제를 고칩니다 (#1857, #1850 후속). 앞 범위 안에서 시작해 그 끝을 넘는 범위는 이제 버리지 않고 남은 부분을 자신의 유형으로 유지하므로 4111 1111 1111 1111-2222는 2222까지 마스킹하며, allow로 설정한 엔티티는 겹침을 정리하기 전에 제외하므로 허용한 이메일이 그 안의 카드 번호를 더 이상 가리지 않습니다(kim.4111111111111111@x.io). block으로 설정한 엔티티는 겹침을 정리하기 전에 판정하므로, 겹치는 마스킹 범위가 덮어도 이제 차단합니다. 카드 번호 탐지기는 이제 Luhn 검사를 통과하는 카드 형태의 온전한 숫자 그룹 열 가운데 가장 왼쪽, 그다음 가장 긴 것(13~19자리 숫자열 하나, 마지막 그룹만 1~4자리인 4자리 그룹들, 4-6-5, 4-6-4, 4111 111111111111처럼 4자리 이상인 그룹 두세 개 중 하나)을 모든 그룹 시작 위치에서 찾아 겹치는 열을 함께 마스킹합니다. 따라서 짧은 숫자 옆, 날짜 뒤, 전화번호 뒤의 카드 번호를 찾고(4111111111111111 010-1234-5678, 010 4111111111111111, 2026-09-29 4111 1111 1111 1111), 전화번호 두 개, 날짜와 전화번호, ISBN은 더 이상 카드 번호 하나가 되지 않습니다(415-555-0123 415-555-0199, 2026-10-05 415-555-0123, ISBN 978-89-01-12345-6). 짧은 그룹을 넣어 적은 카드 번호(2-4-4-4, 3-4-4-4, 1-4-4-4-3, 4-4-4-3-1, 3-3-3-3-4, 두 자리씩 여덟 묶음)는 탐지하지 않습니다. 모든 내장 패턴은 이제 ASCII 단어 경계를 쓰므로 한글이나 한자에 붙여 쓴 카드 번호, 이메일, SSN, AWS 키, API 키(4111-1111-1111-1111이고, 메일kim@x.io입니다)도 탐지합니다. 카드 번호 범위 안에 완전히 들어가는 내장 전화번호 범위는 버리므로 phone: block이 공백으로 묶은 카드 번호를 더 이상 차단하지 않고 credit_card: allow가 카드 번호 일부를 전화번호로 마스킹하지 않습니다. 다른 곳의 전화번호 형태 문자열은 여전히 적용되므로 phone: block에서 kim.01012345678@x.io는 예전처럼 이메일로 마스킹되지 않고 이제 차단됩니다. 앞 텍스트에서 한 그룹 일찍 시작한 전화번호 일치는 그 뒤의 번호를 대신 마스킹하되(order 1234 010 1234 5678은 order 1234를 남김), 옮기면 다음 번호가 달리 마스킹될 때는 옮기지 않고(번호에 이어지는 그룹이나 82 010 1234 5678 3처럼 + 없이 적은 국가 번호는 남긴 채 다음 그룹까지 가져감), 바로 뒤에 카드 번호가 적힌 전화번호도 더 이상 가려지지 않습니다(415-555-0123 378282246310005).

  • 강제(enforce) 출력 가드레일이 Chat Completions, Anthropic(Bedrock 포함), Gemini 전략에서 차단한 스트리밍 /v1/responses 요청의 토큰 사용량을 과금합니다 (#1833). 릴레이는 guardrail_blocked 오류 이벤트로 스트림을 끝내면서 사용량 탭을 실행하지 않고 버렸기 때문에, 공급자는 여전히 과금하는 토큰에 대해 라우터가 키별 TPM이나 월간 과금, llm_tokens_total, 허브 사용량 이벤트를 전혀 기록하지 않았습니다. 이제 차단 시 백엔드가 보고한 사용량을 정확히 한 번 과금하며, 이는 /v1/chat/completions와 패스스루 전략이 이미 따르는 규칙입니다. Gemini 전략에서는 finishReason 청크에 실린 것을 포함해 가장 최근의 usageMetadata를 사용합니다. Chat Completions 전략에서는 스트림 종료 검사가 종료(finish) 청크에서 실행되므로, OpenAI include_usage 규약을 따르는 백엔드가 뒤따르는 사용량 청크를 보내기 전입니다. 따라서 그 시점에 일어난 차단은 해당 청크, [DONE], 종료 후 유예 시간(timeouts.request.streaming.chunk_interval을 1초에서 5초로 제한한 값), 읽기 오류, 스트림 종료 중 먼저 오는 것까지 error 이벤트를 보류하고, 그때까지 도착한 사용량을 과금합니다. /v1/chat/completions는 검사가 [DONE]에서 실행되므로 같은 스트림을 과금합니다. 백엔드가 사용량을 보고하기 전에 일어난 차단(스트림 중간에 잘린 chunked 윈도우)은 아무것도 과금하지 않으며, 추정치는 만들지 않습니다. 종료 청크 자체의 텍스트로 일어난 chunked 차단은 종료 시점의 차단이므로 보류되어 뒤따르는 사용량으로 과금되며, /v1/chat/completions는 이 경우 읽기를 멈추고 아무것도 과금하지 않습니다. 클라이언트는 여전히 단일 error 이벤트를 받습니다.

  • 차단되었거나 완료된 스트리밍 /v1/responses 요청을, 이전의 부분적이거나 0으로 초기화된 보고 대신 최종 사용량으로 과금합니다 (#1849, #1833의 후속). Chat Completions 전략에서는 종료 청크에서 일어난 차단이 사용량을 전혀 보지 못했을 때만 뒤따르는 include_usage 청크를 기다렸기 때문에, 백엔드가 종료 청크 전에 부분 사용량 객체(예를 들어 프롬프트만 담은 보고)를 보고하고 그 뒤에 최종 보고를 보내는 경우 차단이 즉시 나가면서 뒤따르는 최종 값 대신 그 오래된 부분 값으로 과금되었습니다. 이제 이런 차단은 이전에 어떤 사용량이 도착했든 상관없이 항상 보류되며, 보류가 풀리는 시점까지 받은 가장 최근 보고로 과금되므로 뒤따르는 보고가 도착하면 앞서 받은 부분 보고를 대체하고, 실제로 보고가 있었다면 0으로 과금되는 일이 없습니다. Gemini 전략에서는 완료된(차단되지 않은) 스트림의 마지막 청크가 비어 있거나 전부 0인 usageMetadata를 담고 있으면 그 보고가 이전의 0이 아닌 보고를 통째로 덮어써 완료된 응답을 0 토큰으로 과금했습니다. 이제 0인 보고는 차단 경로와 완료 경로 모두에서 하나의 ResponseUsage::is_zero 검사를 공유하며 무시됩니다.

  • Chat Completions 또는 Gemini로 변환되는 스트리밍 /v1/responses 요청에서 출력 가드레일의 스트림 종료 검사를 종료 청크에서 실행합니다 (#1834). Gemini는 마지막 텍스트 조각을 finishReason 청크에 담는 경우가 많은데, 릴레이가 이 청크를 검사 후에 검사 없이 전달했기 때문에 마지막 조각에 든 거부 목록 용어나 PII 값이 마스킹을 포함한 모든 streaming_mode에서 클라이언트에 도달했습니다. 모든 델타에 "finishReason": null을 보내는 Gemini 호환 게이트웨이에서는 모든 델타가 그렇게 전달되었습니다. 이제 문자열 finishReason만 종료 청크로 취급합니다. Chat Completions 변환에서는 streaming_mode: chunked가 마지막 윈도를 보류한 채 응답을 완료해 응답 끝부분이 사라졌고, 모니터 모드는 스트림 종료 검사를 실행하지 않아 아무도 검사하지 않은 완료에 대해 판정을 기록하지 않았습니다. 이제 종료 청크는 나머지 텍스트와 함께 검사에 포함되고, 텍스트가 없는 종료 청크는 보류된 텍스트 뒤에 전달되며, 뒤따르는 사용량 청크 같은 이후 프레임도 클라이언트에 도달합니다. 업스트림이 [DONE] 뒤에 보낸 프레임은 더 이상 response.completed 이후에 변환되지 않습니다. 종료 청크를 보낸 뒤 사용량 청크도 [DONE]도 보내지 않는 Chat Completions 백엔드는 더 이상 스트리밍 전체 시간(기본 600초) 동안 response.completed를 붙잡지 않습니다. 라우터는 종료 청크 이후 timeouts.request.streaming.chunk_interval(1초에서 5초 사이로 제한)이 지나면 지금까지 보고된 사용량으로 응답을 완료하며, keepalive 주석은 이 대기 시간을 늘리지 않습니다.

  • 모든 인그레스에서 도구와 스키마 정의를 검사합니다 (#1827, #1805와 #1823 후속). 입력 단계가 메시지만 읽었기 때문에, 도구 설명, 매개변수 스키마, 레거시 functions 항목, 응답 스키마에 든 거부 목록 용어, 프롬프트 인젝션 페이로드, PII가 enforce 모드에서도 검사 없이 모델에 전달됐습니다. 이제 /v1/chat/completions(스트리밍과 버퍼링 모두), responses_only 브리지, /v1/responses, /anthropic/v1/messages에서 도구와 스키마 하나하나를 메시지보다 먼저 따로 검사합니다. description과 title 문자열, examples 안의 문자열은 제자리에서 마스킹하고, 이름, 속성 이름, required, enum 계열 값, 그 밖의 모든 문자열은 검사하되 마스크가 걸리면 요청을 거부합니다. 다시 쓰면 도구 계약이 바뀌기 때문입니다. 모든 검사가 허용한 도구는 현재 정책 세대 아래에서 기억하므로(라우터당 최대 65,536개), 매 턴 다시 보내는 도구 목록은 프로바이더 호출이 들지 않습니다. 가드레일 설정이 바뀌면 모두 잊으며(다른 섹션만 바꾼 핫 리로드는 더 이상 가드레일 정책을 다시 적용하지 않음), 차단, 마스크, monitor 모드에서 차단되었을 검사, fail-open으로 통과한 검사는 기억하지 않습니다. 도구가 256개를 넘거나 전체 문자열이 16,384개를 넘으면 검사할 수 없는 요청으로 취급합니다. 도구 검사는 여덟 개 한 묶음마다 프로바이더 타임아웃 하나에 셋을 더한 기한을 공유하며, 이 기한은 묶음을 보내기 전에만 확인합니다(이미 보낸 묶음은 끝까지 실행하므로 찾은 마스크를 버리지 않음). 한 묶음 전체에서 모든 프로바이더가 fail-open으로 실패하면 남은 도구는 프로바이더를 건너뛰고 매치 리스트로만 검사하며, 기한 초과는 fail-closed 프로바이더가 선택된 경우에만 요청을 거부하므로(guardrail_timeout), on_error: fail_open 뒤의 장애가 큰 도구 목록을 거부하지 않습니다. 도구 검사는 프로바이더별로 집계하고, 요청 단위 판정은 여전히 메시지 검사가 한 번 기록합니다.

  • 도구 정의 게이트가 요청 하나에 쓰는 프로바이더 호출 수에 상한을 둡니다 (#1835, #1827 후속). 판정 캐시에 없는 도구마다 입력 프로바이더당 호출이 하나씩 들었기 때문에, 요청마다 고유한 도구 256개를 보내는 키 보유 클라이언트는 매 요청 프로바이더당 유료 호출 256번을 일으킬 수 있었고, 마스킹된 도구마다 원격 재검사가 최대 64번 더 붙었습니다. 이제 텍스트 합이 4 KiB 이하인(JSON 이스케이프한 길이로 계산) 도구(선택된 프로바이더가 뷰를 자르면 더 작아짐: System One의 max_state_bytes에서 512바이트를 뺀 값)는 뷰 하나에 최대 32개씩 묶어 한 번에 검사하며, 모든 도구의 계약 필드를 뷰 앞부분에 둡니다. 이름만 있는 도구 256개로 된 에이전트 도구 목록은 256번이 아니라 8번의 호출로 끝나고, 깨끗한 묶음은 도구별로 기억하며, 마스크는 각 도구의 자기 필드에 다시 씁니다(계약 필드에 걸린 마스크는 여전히 요청을 거부). 매치 리스트는 묶기 전에 도구 하나하나에 따로 적용하므로, allow 규칙은 자신이 맞은 도구만 면제하고 고정된(anchored) 규칙은 각 도구를 혼자 보냈을 때처럼 읽습니다. 캐시, 매치 리스트, 묶기를 거친 뒤에도 뷰가 뷰 상한을 넘는 요청은 검사할 수 없는 요청으로 취급합니다(enforce는 거부, monitor는 기록). 뷰 상한은 기본 묶음 크기에서 128개이고 작은 프로바이더 뷰 때문에 묶음이 작아지면 커지므로(2 KiB 이하에서는 도구마다 뷰 하나인 256개까지), 작은 컨텍스트에 맞춘 System One 대상이 256개 도구 한도 안의 목록을 거부하지 않습니다. 한 요청의 모든 뷰는 재검사 문자열 64개라는 예산 하나를 공유하므로(문서 하나와 마찬가지로 마스킹 프로바이더가 모두 로컬에서 재검사하면 해제), 요청 하나의 비용은 기본 묶음 크기에서 프로바이더당 첫 검사 호출 128번(작은 뷰에서는 256번)이 최대이고, 마스킹 프로바이더가 T개이면 원격 재검사 64 x T x T번과 검증 64 x T번이 최대입니다. 비결정적 프로바이더(외부 인식기가 없는 내장 pii 스캐너와 매치 리스트를 제외한 모든 것)가 내린 깨끗한 도구 판정은 이제 다음 정책 변경까지 남지 않고 10분 뒤 만료되며, 여러 도구를 묶은 뷰에서 내린 판정은 점수 기반 분류기가 경계에 있는 도구를 묶음 전체의 판단으로 통과시킬 수 있으므로 60초 뒤 만료됩니다. 모든 인그레스에서 도구 판정을 요청 단위 결정 하나에 합치므로, monitor 모드는 차단되었을 도구나 검사할 수 없는 도구 목록에 block을 기록하고, enforce 모드는 도구만 마스킹된 요청에 transform을 기록합니다. 이전에는 guardrail_verdicts_total이 둘 다 세지 않았습니다. 사용자 정의 Guardrail 구현에는 기본값이 있는 힌트 두 개, max_view_bytes()와 deterministic()이 추가됩니다.

  • 스트리밍 /v1/chat/completions 요청에서도 입력 단계 가드레일을 실행합니다 (#1646). stream: true 요청은 입력 게이트보다 먼저 스트리밍 분기로 넘어가서 입력 프로바이더, deny/allow 리스트, 입력 PII 마스킹이 모두 건너뛰어졌고 프롬프트가 그대로 백엔드에 전달되었습니다. 같은 프롬프트를 stream 없이 보내면 차단되거나 마스킹되었습니다. 이제 pre-call 게이트는 스트리밍 분기, 글로벌 프롬프트, 모든 응답 캐시 조회, 분리형(disaggregated) 디스패치보다 앞에서 두 경로 모두에 대해 한 번 실행됩니다. 차단된 스트리밍 요청은 SSE가 시작되기 전에 스트리밍이 아닌 요청과 같은 차단 응답(chat.completion 차단 본문과 함께 200, block_behavior: error이면 content_filter 엔벨로프와 함께 400, 그리고 x-guardrail-* 헤더)을 받으며, transform은 백엔드와 캐시 키가 보는 프롬프트를 다시 씁니다. 스트리밍이 아닌 요청도 달라집니다. 정확 일치나 프롬프트 접두사 캐시 적중이 더 이상 입력 차단을 우회하지 않으며, /anthropic/v1/messages 경로처럼 입력 검사 비용을 치릅니다. 스트리밍 요청은 during-call 단계를 실행하지 않습니다. 늦게 도착한 판정이 폐기할 진행 중 응답이 남아 있지 않기 때문입니다.

  • 라우터가 업스트림 /v1/responses로 브리지하는 스트리밍 /v1/chat/completions 요청의 토큰 사용량을 과금합니다 (#1809). 이 브리지는 모든 responses_only 모델, 모든 Codex/ChatGPT 구독 백엔드, 그리고 chat_completions_tools_require_none_reasoning 플래그가 있는 모델에서 tools와 none이 아닌 reasoning_effort를 함께 보내는 요청을 처리하는데, 스트리밍 경로는 토큰이 0인 연결 시점 기록만 남겼습니다. 그 결과 허브, 비용 센터, 모델 예산 어디에도 컨트롤 플레인 사용량 이벤트가 전달되지 않았고 허브 키의 input_tpm, output_tpm, 월간 토큰 한도도 차감되지 않아, 클라이언트가 더미 도구와 stream: true를 추가하는 것만으로 한도를 넘어 사용할 수 있었습니다. 이제 브리지는 업스트림의 종료 이벤트 response.completed(또는 response.incomplete)가 보고한 사용량을 스트림 종료 시 한 번, 일반 채팅 스트림과 같은 스트림 종료 탭과 컨텍스트 빌더로 과금하며, 서비스한 백엔드의 backend_id, 매칭된 허브 key_id, 모델 실험 arm, TTFT를 함께 기록합니다. 키별 통계는 요청을 토큰과 함께 한 번만 집계하고, 종료 사용량이 오기 전에 끝난 스트림은 아무것도 기록하지 않습니다. 또한 두 브리지 경로 모두 업스트림의 input_tokens_details.cached_tokens를 비스트리밍 응답 본문과 스트리밍 include_usage 청크에 prompt_tokens_details.cached_tokens로 전달하므로, 캐시 읽기가 새 입력이 아닌 캐시 읽기로 계량되고 과금됩니다. 이 세부 객체는 업스트림이 input_tokens_details를 보낼 때마다 포함되며, OpenAI는 항상 이를 보내므로 캐시되지 않은 응답에도 네이티브 Chat Completions 형식과 같이 prompt_tokens_details: {"cached_tokens": 0}가 포함됩니다.

  • 모든 스트리밍 릴레이에서 느리게 읽는 클라이언트에게도 응답 전체의 사용량을 과금합니다 (#1815). 릴레이 10개가 클라이언트가 응답을 읽는 속도로만 업스트림을 읽었습니다. /v1/responses 패스스루와 그 Anthropic, Gemini, Chat Completions 변환 경로, /anthropic/v1/messages 네이티브 패스스루(HTTP, Unix 소켓, Bedrock mantle), Bedrock runtime, OpenAI 와이어, responses_only 경로, 그리고 네이티브 Gemini와 Bedrock runtime/Converse /v1/chat/completions 스트림입니다. 클라이언트가 천천히 읽다가 끝나기 직전에 연결을 끊으면 릴레이가 업스트림보다 뒤처져 있어서 사용량이 담긴 마지막 이벤트를 보지 못했고, 업스트림은 응답 전체를 생성하고 과금했는데도 아무것도 과금되지 않았습니다(허브 사용량 이벤트, 비용 센터나 모델 예산 차감, input_tpm/output_tpm/월간 한도 차감, 키별 통계 모두 없음). 이제 모든 릴레이가 일반 채팅 스트림처럼 하나의 공유 무제한 릴레이 채널로 전송하므로, 클라이언트가 읽는 속도와 상관없이 업스트림 속도로 업스트림을 읽고 마지막 사용량을 과금합니다. 네이티브 Gemini와 Bedrock 채팅 파이프라인은 탭 체인을 응답 본문 안이 아니라 별도 태스크에서 실행하며, 이 태스크는 업스트림을 기다리는 동안에도 연결 종료를 함께 감시하므로 긴 추론 공백 중에 클라이언트가 연결을 끊어도 다음 업스트림 이벤트를 기다리지 않고 그 즉시 생성을 취소합니다. 클라이언트가 연결을 끊으면 릴레이는 여전히 다음 전송에서 멈추고 업스트림을 끊으므로 취소 동작은 그대로입니다. 스트림별 버퍼는 이제 이벤트 256개가 아니라 업스트림 응답(출력 토큰 상한과 timeouts.streaming.total)으로 제한되며, 이는 일반 채팅 스트림과 같은 한도입니다. /v1/responses 변환 경로의 느린 클라이언트 백프레셔 경고는 제거되었습니다. 이제 소스 감사 테스트(tests/streaming_relay_pacing_audit_test.rs)가 SSE 이벤트를 담는 유한 채널이나 공유 릴레이 채널을 쓰지 않는 Sse 본문을 빌드 실패로 잡습니다.

  • 라우터가 Chat Completions로 변환하는 스트리밍 /v1/responses 요청의 토큰 사용량을 과금합니다 (#1825). 이 변환은 vLLM, SGLang, TensorRT-LLM, Ollama, llama.cpp, mlxcel, LM Studio, continuumrouter, generic 백엔드를 처리하며, 이런 요청은 모두 토큰 0개로 과금되었습니다. 라우터가 stream_options를 보내지 않아 OpenAI 규약을 따르는 서버는 사용량을 보고하지 않았고, 릴레이는 종료 청크의 usage를 읽기 전에 그 청크로 response.completed를 만들고 거기서 읽기를 멈췄기 때문에 뒤따르는 include_usage 청크는 도착하지 않았습니다. 그 결과 허브 사용량 이벤트 하나가 프롬프트와 완성 토큰 0개로 기록되었고, 키의 토큰 윈도, 월 예산, 비용 센터, 모델 예산에 모두 0이 과금되었으며, 클라이언트가 받는 response.completed의 사용량도 0이었습니다. 이제 릴레이는 stream_options.include_usage: true를 보내고(/v1/chat/completions가 모든 OpenAI 호환 스트림에 이미 하던 방식입니다), 종료 청크 또는 그 이후에 처음으로 사용량 객체를 담은 청크 또는 [DONE]에서 응답을 완료합니다. 따라서 뒤따르는 사용량 청크, 종료 청크의 사용량, 모든 청크에 실린 사용량이 각각 정확히 한 번 과금됩니다. "usage": null 델타와 값이 모두 0이거나 비어 있는 사용량 자리표시자는 더 이상 집계를 초기화하거나 스트림을 일찍 끝내지 않으며, 사용량이 없는 스트림은 이전처럼 [DONE] 또는 스트림 끝에서 사용량 0으로 완료됩니다. 이제 릴레이는 종료 청크 이후에도 계속 읽으며, 기다리는 동안 업스트림 읽기 오류가 발생해도 그때까지 모은 사용량으로 응답을 완료합니다.

  • /v1/responses와 /v1/completions에서 가드레일 단계를 실행합니다 (#1805). 두 라우트 모두 입력 단계를 실행하지 않았고, /v1/completions는 출력 단계도 실행하지 않았으며, 비스트리밍 /v1/responses 응답은 게이트되지 않았습니다. 그래서 클라이언트(Codex를 비롯한 모든 Responses API 클라이언트 포함)는 엔드포인트만 골라서 로컬 정책과 enforce 모드의 허브 guardrail_policy_v1 모두에서 입력과 출력의 차단과 마스킹을 건너뛸 수 있었고, 이 우회를 기록하는 메트릭, 감사 행, 허브 카운터도 없었습니다. 이제 /v1/responses는 두 모드 모두에서 응답 캐시, 백엔드 선택, 모든 폴백 시도보다 먼저 입력 단계를 실행하고, 네 가지 비스트리밍 전략 모두에서 store: true 세션 저장 전에 출력 단계를 실행하므로 GET /v1/responses/{id}와 previous_response_id 이어가기가 게이트된 텍스트를 담습니다. /v1/completions는 디스패치 전에 프롬프트를, 이후에 응답을 게이트하며, 퍼널이 버퍼링하는 stream: true 응답도 포함됩니다. 각 라우트는 자신의 와이어 형식으로 차단을 렌더링합니다. status: incomplete, incomplete_details.reason: content_filter인 response, 또는 finish_reason: content_filter인 text_completion이며, block_behavior: error에서는 OpenAI content_filter 오류 봉투(HTTP 400)입니다. 차단 응답은 캐시되지 않으며, trigger_conditions.error_codes에 400이 있어도 fallback_chains 홉을 일으키지 않습니다. 모든 Responses 입력 아이템을 와이어 형태로 검사하므로, 라우터가 그대로 전달하는 원시 아이템(status: incomplete인 function_call_output, 또는 phase, agent, caller를 담은 아이템)이 더 이상 검사 없이 통과하지 않으며, 도구 호출 arguments(Responses 아이템과 chat tool_calls)는 원본 문자열에서 키, 숫자, 중복 키를 포함해 토큰 단위로 읽고 다시 인코딩하지 않고 제자리에서 마스킹하며, JSON 파서가 거부하는 arguments 문자열은 enforce 모드에서 거부합니다. 알 수 없는 아이템 타입은 파서가 422로 거부합니다. 변환 전략에서는 previous_response_id 이어가기를 재구성된 히스토리에 대해 디스패치된 모델의 정책으로 다시 검사합니다. 입력 마스크는 /v1/chat/completions, responses_only 브리지, /v1/decisions, /v1/responses(저장된 히스토리 포함), compact, /v1/completions에서 각 문자열이 있는 자리(instructions, 모든 턴, 도구 출력, 도구 호출 arguments의 각 토큰)에 적용되며, 그렇게 적용할 수 없는 마스크는 마스킹되지 않은 채 디스패치하지 않고 거부합니다. 마스크를 반환한 프로바이더만 프로바이더 타임아웃 세 배의 기한 안에서 다시 실행하고, 마스크 이후의 프로바이더 실패는 fail-open에서도 거부하며, 프로바이더는 클라이언트 메시지마다 메시지 하나를 봅니다. chat은 reasoning_content, refusal, 커스텀 도구 입력, 도구와 참여자 이름도 읽고, /v1/completions는 각 프롬프트를 따로, suffix를 (프롬프트가 없어도) 한 번 판정하고 중첩된 프롬프트 배열을 읽으며, 요청당 최대 128개의 프롬프트를 판정하고 요청마다 판정 하나를 기록하며, 16,384개를 넘는 문자열로 읽히는 요청은 검사할 수 없는 요청으로 취급합니다. 입력 단계 검사가 적용되면 enforce 모드에서 /v1/completions 토큰 ID와 prompt_embeds 프롬프트를 거부하며, /v1/responses/compact 입력을 게이트합니다. 마스킹은 이제 chat, Completions, Responses 와이어(스트리밍 포함)에서 logprobs와 top_logprobs도 지웁니다. /anthropic/v1/messages의 출력 차단과 /v1/decisions의 입력 차단은 더 이상 폴백 모델로 넘어가지 않으며, 200 차단 응답에는 더 이상 x-served-backend가 붙지 않습니다. monitor 모드는 바뀌지 않습니다. 가드레일, 아키텍처, API 문서가 게이트되지 않는 라우트(/v1/batches)를 포함해 실제 라우트별, 단계별 적용 범위를 표로 정리합니다.

  • 컨텍스트 압축의 히스토리 요약을 선두 system(또는 developer) 메시지 뒤에 두 번째 system 항목으로 넣지 않고 그 메시지에 합칩니다 (#1704). summarization을 켠 허브 압축 정책은 여러 턴의 [system, user, assistant, user] 요청을 [system, system, ...]으로 바꿨고, Qwen3.8처럼 엄격한 채팅 템플릿은 이를 System message must be at the beginning.으로 거부했습니다. 병합은 모델 메타데이터와 무관하게 항상 이루어지고, requires_single_leading_system 병합(빈 줄 경계, parts 배열 유지)을 재사용하며, 선두 system 메시지가 없는 요청은 그대로입니다. 이 경우 요약은 여전히 0번 위치의 합성 system 항목입니다. 백엔드별 requires_single_leading_system 병합은 이미 모든 전송 지점에서 압축된 페이로드에 적용되므로, 압축 후 남은 대화 중간의 system 항목은 이전처럼 그곳에서 병합됩니다.

  • Gemini 사고(thinking) 모델의 thoughtsTokenCount를 chat-completions 사용량에 포함합니다 (#1677). /v1/chat/completions(버퍼링과 스트리밍)와 /anthropic/v1/messages 브리지의 네이티브 Gemini 사용량은 이제 completion_tokens(브리지는 output_tokens)를 candidatesTokenCount + thoughtsTokenCount로 보고하고, Gemini의 totalTokenCount가 0보다 크면 이를 합계로 사용하며, completion_tokens_details.reasoning_tokens를 추가해 /v1/responses가 이미 보고하던 값과 일치시킵니다. 과금에 영향이 있습니다. chat 경로와 브리지에서 사고 모델의 출력 계량값이 로컬과 Hub 사용 레코드 모두에서 사고 토큰 수만큼 늘어납니다.

  • 안전 정책으로 차단된 Gemini 프롬프트를 원본 Gemini 객체 대신 content_filter 종료로 응답합니다 (#1677). 후보 없이 promptFeedback.blockReason을 담은 네이티브 응답이나 스트림 이벤트는 이제 모든 차단 사유에 대해 finish_reason: "content_filter"인 일반 chat.completion(스트리밍에서는 사용량 청크 앞의 종료 청크)이 되며, 사용량은 그대로 계량됩니다. 이전에는 비스트리밍 본문과 usageMetadata가 없는 스트림 이벤트가 그대로 전달되었고, 사용량이 있는 스트림 이벤트에는 종료 사유가 없었습니다.

  • requires_single_leading_system을 openai뿐 아니라 모든 OpenAI 와이어 백엔드 타입에서 내장 카탈로그로부터 해석합니다 (#1705). 이 키는 이제 세 소스(백엔드의 model_configs, model-metadata.yaml 캐시, 내장 카탈로그)를 필드 단위로 거쳐 해석되며, 마지막 단계는 vllm, sglang, generic 등 다른 OpenAI 와이어 타입에서도 조회됩니다. 통째로 가져오는 내장 조회는 여전히 openai 전용이므로, OpenAI 모델 이름을 쓰는 자체 호스팅 서버가 그 항목의 responses_only 디스패치를 물려받지 않습니다. Qwen3.8 기본값 자체는 함께 배포되는 model-metadata.yaml에 있으며 이미 모든 백엔드 타입에서 해석되었지만, model_metadata_file이 가리킬 때만 읽힙니다. 파일 없이 시작한 라우터는 엄격한 템플릿 모델의 system 항목을 그대로 전달하므로, 백엔드 문서와 config.yaml.example에 이를 명시했습니다.

  • 강제(enforce) 출력 가드레일이 차단한 스트리밍 /v1/chat/completions 응답에서도 사용량을 보고합니다(#1680). 차단은 게이트가 보류하던 모든 것을 content_filter 종단 청크로 대체하면서 사용량 보고까지 함께 버렸기 때문에, 클라이언트는 백엔드가 생성하고 과금한 토큰에 대해 usage를 받지 못했습니다. 이제 게이트는 소스가 보고한 최신 사용량을 기억하고, 차단이 그 사용량을 담은 페이로드를 버린 경우 모든 block_behavior에서 종단 청크 뒤, [DONE] 앞에 OpenAI 사용량 전용 청크(choices: [])를 덧붙입니다. 이미 클라이언트에 도달한 사용량 보고는 반복하지 않습니다. 네이티브 Anthropic 소스 경로에서는 라우터도 이 값으로 차단된 완성을 계량합니다. 이전에는 사용량을 만드는 message_stop이 차단으로 버려져 아무것도 기록하지 않았습니다. 스트림 도중에 발생한 chunked 차단은 백엔드가 사용량을 보고하기 전에 스트림을 끊으므로 여전히 사용량을 담지 않습니다.

  • unix:// 소켓의 타입 지정 네이티브 백엔드가 라우터의 스트리밍 타임아웃 아래에서 점진적으로 스트리밍하도록 했습니다 (#1679). 타입 지정 스트리밍 경로(Gemini, Bedrock mantle, Anthropic, OpenAI, vLLM, llama.cpp, Continuum Router 백엔드의 execute_streaming_request)는 실행기의 버퍼링 Unix 소켓 요청을 사용했고, 이 요청은 첫 바이트가 라우터를 떠나기 전에 하드코딩된 30초 타임아웃 아래에서 전체 응답을 읽었습니다. 그 결과 SSE는 마지막에 한 번에 도착했고, timeouts.streaming.first_byte는 아무것도 측정하지 못했으며, 30초보다 긴 응답은 실패했습니다. RequestExecutor에 응답 헤더가 도착하는 즉시 ExecutorStreamResponse를 반환하고 엔진이 쓰는 대로 본문을 내보내는 execute_streaming / execute_streaming_with_client와, 각 타입 지정 백엔드가 자신의 HTTP 스트리밍 클라이언트 창(Gemini 300초, Anthropic과 Bedrock mantle 600초)으로 설정하는 with_unix_socket_config가 추가되어 소켓이 HTTP보다 엄격해지지 않습니다. HTTP 경로와 버퍼링 비스트리밍 소켓 경로는 바뀌지 않았고, Anthropic과 Bedrock mantle 경로의 2xx가 아닌 스트리밍 응답은 이제 Gemini가 이미 그랬듯 64 KiB로 제한된 동일한 오류 본문 접두사를 읽습니다.

  • Dynamo direct 모드 인스턴스 id 헤더를 프론트엔드가 파싱하는 10진수 lease id로 보냅니다 (#1748). x-dynamo-prefill-instance-id와 x-dynamo-worker-instance-id가 레지스트리의 소문자 16진수 id를 담았는데 dynamo.frontend는 이를 헤더가 없는 것으로 읽으므로, 실제 Dynamo 1.5 fleet에서 모든 shim 요청이 direct 모드 거부로 끝났습니다. Admin 뷰와 x-continuum-* 응답 헤더는 16진수 표기를 유지합니다.

  • Dynamo 1.5 prefill 워커의 모델 카드에서 타입이 있는 필드를 버리지 않고 유지합니다 (#1748). Dynamo 1.5는 prefill 카드의 runtime_config.disaggregated_endpoint를 bootstrap 접속 주소를 담은 객체로 기록하는데, 엄격한 문자열 필드가 타입이 있는 카드 전체를 기본값으로 되돌려 prefill 워커가 모델 이름과 용량을 잃었고 shim picker가 그 워커를 고르지 못했습니다. 이제 문자열이 아닌 값은 모두 없는 것으로 읽으며, 주소는 Admin 뷰 쪽으로 전달하지 않습니다.

  • deploy/examples/dynamo-direct-shim/의 워커가 시작되도록 고쳤습니다 (#1748). 토크나이저 캐시 볼륨을 이미지에 없는 경로에 마운트해 Docker가 root 소유로 만들었고, root가 아닌 mocker가 Permission denied로 종료했습니다. 이제 이미지가 소유한 /home/dynamo/.cache 위에 마운트합니다. 고정된 dynamo-planner:1.5.0 태그는 익명으로 pull되므로 예제, 하네스, dynamo-e2e.yml은 더 이상 NGC 로그인을 요구하지 않습니다.

  • OpenAI 호환 백엔드나 OpenAI Responses 백엔드로 브리지되는 스트리밍 /anthropic/v1/messages에서 업스트림이 프롬프트보다 큰 캐시 읽기를 보고해도 비스트리밍 요청과 같은 사용량을 계측합니다 (#1762). 스트림 변환기는 Anthropic 방식의 배타적 분할만 보관하고 이를 다시 합산했는데, 이런 보고에서는 분할이 0으로 포화되므로 업스트림의 prompt_tokens: 100과 cached_tokens: 800이 프롬프트 800, 캐시 읽기 800, 합계 810, Hub 캐시 입력 800으로 계측되었습니다. 비스트리밍 경로는 100, 100, 110, 100을 기록합니다. 이제 변환기가 업스트림의 포괄적 프롬프트 수를 분할과 함께 보관하고, 두 스트림 종료 스냅샷을 하나의 공용 헬퍼가 만들어 그 값을 TokenUsage::from_json_value의 기존 캐시 부분집합 clamp로 넘깁니다. 클라이언트가 Anthropic 응답에서 보는 사용량은 바뀌지 않습니다.

  • 분리형 RoleFilterScorer를 phase 전용으로 등록합니다 (#1759). 분리형 서빙과 prefix_routing은 켜져 있고 KV 인덱스는 없는 배포에서는 이 스코어러가 유일하게 등록된 스코어러여서, 키가 있는 모든 요청이 스코어러 파이프라인을 돌며 모든 후보에 중립 점수 0을 매기고 scorer_fell_back이 설정된 fallback 라우팅 결정으로 기록되었습니다. 이제 추론 phase가 없는 패스는 phase 전용 스코어러를 제외하고 파이프라인을 시도하지 않은 것으로 보고합니다. 라우팅 자체는 바뀌지 않습니다.

  • 스트리밍 분리형 경로에서도 Dynamo direct 모드 거부를 분류합니다 (#1759). shim의 거부는 업스트림 오류를 담은 200 SSE 스트림으로 도착해 스트리밍 경로가 정상 스트림으로 취급했습니다. 이제 스트리밍 파이프라인이 전달된 업스트림 실패를 표시하고, 이 경로는 실패했거나 표시된 shim 응답의 앞부분을 읽어 버퍼링 경로와 똑같이 503 disaggregated_prefill_decode_unavailable과 disaggregated_errors_total{phase="orchestration"}로 응답합니다. 그 밖의 응답은 클라이언트에 그대로 재생됩니다.

  • 버퍼링 분리형 경로의 백엔드 불일치에서 업스트림 비용을 두 번 청구하지 않습니다 (#1759). fallback_to_unified가 켜져 있으면 선택이 허용하지 않은 백엔드가 만든 응답을 버리고 다시 보내는 대신 fallback 응답으로 돌려줍니다. 꺼져 있으면 여전히 disaggregated_backend_mismatch로 거부합니다.

  • Dynamo 부하 구독자에서 핸드셰이크 이후의 커맨드 폭주를 거부합니다 (#1759). ZMTP 핸드셰이크 이후 1초 안에 256개를 넘는 커맨드 프레임은 이제 transport_refused 거부가 되므로, 피어가 게이지를 connected로 둔 채 구독을 건너뛰기 루프에 영원히 묶어 둘 수 없습니다. libzmq 하트비트는 이 상한보다 한참 낮습니다. MAX_COMMAND_BYTES, GREETING_BYTES, ZMTP_VERSION과 새 MAX_COMMANDS_PER_WINDOW, COMMAND_WINDOW를 dynamo::load에서 다시 내보냅니다.

  • 라우터가 시작하는 도중, 부팅 설정을 읽은 뒤 Dynamo 탐색 수퍼바이저가 구독하기 전에 게시된 설정 리로드를 적용합니다 (#1759). 이전에는 다음 변경까지 기다렸습니다. 이제 수퍼바이저가 시작할 때 채널의 현재 값을 부팅 섹션과 비교합니다.

  • 분리형 버퍼링 경로와 응답 캐시에서 가드레일을 우회한 응답이 제공되지 않도록 수정했습니다 (#1817). disaggregated_serving을 통해 제공되는 스트리밍이 아닌 /v1/chat/completions 응답(fast decode, prefill-then-decode, Dynamo shim 디스패치, 또는 fallback_to_unified에서 제공되는 백엔드 불일치 응답)은 during-call 단계와 출력 단계를 건너뛰었으나, 이제 통합(unified) 응답과 똑같이 두 단계를 모두 실행합니다. 응답 캐시 적중(버퍼링 및 스트리밍 chat, /anthropic/v1/messages, /v1/responses의 정확 일치, 프롬프트 접두사, 시맨틱)은 출력 단계를 건너뛰는데 캐시 키에 가드레일 정책이 반영되지 않아, mode: monitor에서 저장된 항목, 이후 강화된 정책에서 저장된 항목, 공유 네임스페이스에서 bypass_api_keys 호출자가 저장한 항목이 TTL이 끝날 때까지 계속 제공되었습니다. 이제 모든 캐시 네임스페이스에 가드레일 정책 범위가 포함됩니다: 현재 합성된 정책의 다이제스트(허브 guardrail_policy_v1 선언과 거버넌스 패턴 포함, 우회 키 목록 제외), 일치하는 routes 항목, 호출자가 우회 키를 가졌는지 여부입니다. 다이제스트에는 라우터 버전도 포함되므로, 내장 탐지기를 바꾼 릴리스는 혼합 버전 플릿이 공유하는 Redis 캐시를 포함해 이전 바이너리가 저장한 항목을 제공하지 않습니다. 요청 처리 중에 가드레일 정책이 바뀌었거나(요청 도중 bypass_api_keys에 키가 추가되거나 monitor로 전환된 경우), 프로바이더가 on_error: fail_open에서 실패해서만 강제(enforce) 출력 검사를 통과한 응답은 저장하지 않습니다. 프로바이더가 실패하는 동안에는 라우터 전체에서 캐시가 채워지지 않습니다. 같은 엔드포인트 뒤의 새 분류기 모델이나 프로바이더가 가리키는 backends[] 항목 수정처럼 guardrails 바깥의 변경은 범위를 바꾸지 않습니다. 가드레일을 쓰는 배포는 업그레이드할 때마다, 그리고 정책이 바뀔 때마다 기존 캐시 항목을 잃습니다(서로 다른 프롬프트당 한 번의 미스). 가드레일을 쓰지 않는 배포의 캐시 키는 바이트 단위로 그대로 유지됩니다. 버퍼링 분리형 디스패치가 실패해 통합형 서빙으로 폴백하면 통합형 시도에서 during-call 단계가 한 번 더 실행됩니다(평가 한 번 추가, 감사 기록 중복). 공개 ResponseCacheKey 구조체에 새 store_permit 필드가 추가되었으므로(무조건 저장은 StorePermit::default()), 구조체 리터럴로 이 값을 만드는 코드는 이 필드를 설정해야 합니다. 가드레일 가이드는 더 이상 캐시본이 반환되기 전에 출력 단계가 실행된다고 설명하지 않습니다.

  • 프롬프트를 라우터 밖으로 보내는 스마트 라우팅 분류기보다 먼저 가드레일 입력 단계를 실행합니다 (#1816). /v1/chat/completions에서 LLM 분류기, System One 분류기, shadow: true System One 분류기는 deny 규칙, 입력 프로바이더, PII 마스킹이 적용되기 전의 원본 프롬프트를 받았고, 이후 게이트가 차단한 프롬프트도 마찬가지였습니다. 이제 분류기가 프로세스 밖으로 나갈 수 있으면 클라이언트가 보낸 이름이 아니라 smart_routing.virtual_model을 키로 입력 단계를 먼저 실행하므로, intercept_all에서도 클라이언트가 더 느슨한 guardrails.routes 항목을 고를 수 없습니다. 차단되면 분류기를 호출하기 전에 요청을 멈추고, 마스크는 메시지별 마스킹 코어(#1805)를 거쳐 분류기가 읽기 전에 system을 포함한 모든 메시지에 제자리에서 적용되며, 그렇게 적용할 수 없으면 요청을 거부합니다. 라우팅 후에는 라우팅된 모델이 다른 guardrails.routes 항목으로 해석될 때만 입력 단계를 다시 실행하므로, 경로별 재정의가 없으면 요청은 한 번만 평가되고, 재정의가 있으면 가드레일 메트릭에 두 번 집계됩니다. 섀도가 없는 method: rule 라우터는 이전처럼 라우팅 후에 한 번 검사합니다.

  • /anthropic/v1/messages 요청의 모든 세그먼트를 제자리에서 마스킹합니다 (#1823, #1805 후속). 마스킹 판정(내장 pii 프로바이더, AWS Bedrock Guardrails)이 마지막 사용자 메시지를 대화 전체의 마스킹된 형태로 바꿨기 때문에, 판정과 메트릭은 마스크를 기록했지만 system, 이전 턴, tool_result의 PII는 마스킹되지 않은 채 백엔드에 도달했고, 마지막 사용자 턴은 이전 턴 전체의 사본을 받았으며, 그 턴의 tool_result 블록은 문자열 하나로 합쳐졌습니다. 이제 이 라우트는 리프 단위 마스킹 코어를 사용합니다. system, 모든 턴의 텍스트(대화 중간의 system 항목과 content 배열 안의 문자열 포함), tool_result 콘텐츠, tool_use.input의 문자열 값을 읽고, 스트리밍 여부와 관계없이 모든 백엔드 경로에서 각 문자열이 있는 자리에서 마스킹합니다. tool_use.input의 키, 숫자, 불리언은 읽지 않습니다. thinking 텍스트는 검사하지 않으며(네이티브 백엔드가 signature를 검증함), OpenAI 호환 변환이 이를 reasoning_content로 전달하는 경우도 마찬가지입니다. redacted_thinking, 이미지, 문서, 그대로 전달되는 블록 타입은 읽지도 다시 쓰지도 않습니다. 적용할 수 없는 마스크(예: 두 문자열에 걸친 마스크)는 Anthropic 차단 응답으로 응답하고 아무것도 디스패치하지 않습니다. 차단 전용 프로바이더도 이제 이 라우트에서 system, 도구 결과, 도구 사용 입력을 검사합니다. /anthropic/v1/messages/count_tokens는 여전히 게이트되지 않습니다. 모든 마스킹 프로바이더가 프로세스 안에서 다시 검사하면(외부 인식기가 없는 내장 pii 프로바이더) 요청당 마스킹 문자열 64개 한도가 적용되지 않으므로, 다시 보내는 도구 결과에 전화번호 형태의 숫자가 많은 에이전트 세션도 매 턴 거부되지 않고 마스킹됩니다. 원격 호출을 할 수 있는 프로바이더는 한도를 유지합니다. 내장 전화번호 탐지기는 IPv4 주소와 타임스탬프에도 일치하므로, 가드레일 가이드는 이제 에이전트 트래픽에 options.actions: {phone: allow}를 권장합니다.

  • 헬스 체크와 모델 카탈로그 조회가 프록시 연결 풀을 쓰지 않도록 했습니다 (#1924, 에픽 #1703). 헬스 체커와 /v1/models 조회기는 프록시 reqwest 클라이언트의 복제본으로 프로브를 보냈고, 복제본은 같은 풀을 공유합니다. 그래서 프록시 요청이 백엔드와의 단일 HTTP/2 연결을 서버의 MAX_CONCURRENT_STREAMS(nginx 인그레스에서 128)까지 채우면 프로브가 스트림 슬롯을 기다리다 타임아웃되었고, 바쁘지만 살아 있는 백엔드가 비정상으로 표시되었습니다. 유일한 백엔드가 비정상이 되면 라우터는 긴 스트림이 끝날 때까지 모든 요청을 거부했습니다. 이제 프로브는 프록시 클라이언트와 같은 빌더로 만든(같은 TLS 루트, 타임아웃, server.http_client 튜닝) 별도 연결 풀의 전용 클라이언트를 사용하며, 백그라운드 헬스 체커, 모델 탐색, 관리자 백엔드 프로브와 컨트롤 플레인 활성화 사전 점검의 일회성 후보 헬스 프로브가 이 클라이언트를 씁니다. 자격 증명과 요청별 health_checks.timeout(#1065)은 이전과 같이 요청마다 붙고, 연결 프리워밍은 계속 프록시 풀을 데웁니다. with_http_client로 클라이언트를 주입한 임베더는 설정에서 그 TLS나 프록시 설정을 다시 만들 수 없으므로, 새 ContinuumRouterBuilder::with_probe_http_client도 호출하지 않는 한 프로브도 그 클라이언트를 계속 사용합니다.

  • Serving pool이 적용된 Hub backend revision과 일치하면 BackendSyncStore::status가 그 revision을 active로 보고합니다(#1936, #1808 후속). #1808 이후 공개 wrapper가 "pool 미수렴"을 고정값으로 넘겨서, pool이 일치하는 적용 revision도 active로 보고할 수 없었고 이를 통해 active.is_none()을 확인하던 테스트 네 개도 실패할 수 없었습니다. 이제 이 메서드가 live pool을 직접 평가하고 inventory heartbeat도 수렴 검사를 중복하지 않고 이 메서드를 호출하므로 두 경로가 한 구현을 공유합니다. Library surface: continuum_router::control_plane::BackendSyncStore를 쓰는 control-plane 사용자는 status가 async가 되고 두 번째 매개변수로 live &BackendPool을 받는다는 점에 유의하십시오.

  • continuum-kv-listener가 source마다 보관하는 token id에 상한을 둡니다(#1936, #1806 후속). Single-block BlockStored event는 길이와 무관하게 token_ids 전체를 자신의 segment로 보관했기 때문에 문서화된 최악 한도가 max_tracked_blocks x max_chain_tokens(기본값에서 source당 약 6.1 GiB)였습니다. 새 source 설정 max_retained_tokens(기본값 16777216, 4바이트 id 기준 64 MiB, 0은 load 오류)는 max_tracked_blocks와 같은 방식으로 가장 오래된 block부터 정리하고 마지막 reference가 정리된 prefix만 purge하므로, 상한은 이제 min(max_retained_tokens, max_tracked_blocks x max_chain_tokens)입니다. 비어 있지 않은 token span의 길이가 0이 아닌 block_size와 다른 single-block event(vLLM은 masked sliding-window block을 hash 목록에서 빼면서 span은 연속으로 유지합니다)는 이제 span 전체를 보관하지 않고 token 없이 추적하므로 그 하위 block은 prefix에 귀속하지 않으며, source당 한 번 경고를 기록합니다.

  • 강제 모드 chunked 출력 가드레일에서 텍스트가 없는 청크를 보류 중인 텍스트 뒤에 둡니다 (#1643, PR #1959). streaming_stream_first: false일 때 finish_reason과 사용량(또는 Anthropic content_block_stop과 message_delta)이 보류된 부분 창보다 먼저 나가서, 종료 청크에서 완료를 끝내는 클라이언트는 마지막 문자를 최대 streaming_chunk_size - 1개 잃었습니다. 이제 이런 청크는 보류된 텍스트 뒤에 줄을 서서 순서대로 함께 나가며, 보류된 것이 없으면 여전히 즉시 나갑니다. 보류된 텍스트 뒤에 오는 도구 호출 델타는 이제 다음 창이나 스트림 끝에 도착합니다.
  • 기본 모델의 예산을 그대로 보내는 대신 각 폴백 대상의 컨텍스트 창에 맞춰 max_tokens 예산을 조정합니다 (#1601, PR #1807, #1908). 호환 여부는 일반적인 프롬프트 추정치(ASCII는 세 글자당 한 토큰, 그 밖의 문자는 한 글자당 한 토큰)로 판단하고, 조정은 바이트 상한을 유지하며, 여유가 16토큰 미만인 대상은 호환되지 않는 것으로 보고 건너뛰고, 예산은 낮추기만 합니다. 한도는 요청이 승인될 때의 설정 스냅샷에서 가져오며, 백엔드 메타데이터가 전역 메타데이터보다 우선하고 설정 메타데이터에 한도가 없으면 엔진이 보고한 원래 한도를 유지합니다. 품질 검사 홉은 일반 시도 한도 안에서 호환되지 않는 대상을 건너뜁니다. 스트림 중간 릴레이는 필요해지기 전에 전체 모델 집계를 기다리지 않으며, 폴백 순회가 소진됐을 때의 503은 호출자의 키가 볼 수 없는 체인 모델 이름을 더 이상 드러내지 않습니다.
  • 라우터를 빌드할 때 모델 카탈로그를 미리 채워, 임베디드 into_router() 경로와 model_aggregation.background_refresh가 꺼진 경우에도 첫 폴백 홉이 엔진이 보고한 창에 맞춰지도록 했습니다 (#1935, PR #1938). ContinuumRouter::build()는 빌드를 막지 않는 백그라운드 재검증을 한 번 시작합니다.
  • 품질 폴백 생성의 토큰을 실제로 생성한 모델 아래에 기록하고, 사용할 수 없는 생성의 토큰도 유지합니다 (PR #1828, #1601 후속). fallback.quality_check 홉의 사용량은 원래 요청된 모델 아래에 기록되었고, 파싱이나 검증에 실패한 /v1/decisions 에뮬레이션 응답은 백엔드가 보고한 사용량을 버렸습니다. 이제 이런 생성은 토큰을 담은 실패 행 하나가 되며 토큰이 0인 두 번째 실패 행은 생기지 않고, 건너뛴 비호환 대상은 아무것도 기록하지 않습니다.
  • 재시작 후 허브가 할당한 백엔드 풀을 복원합니다 (#1558, PR #1808). 영속된 허브 백엔드 계층을 핫 리로드 루프가 시작되기 전에 라이브 풀과 조정하고, 풀이 수렴하지 않은 동안에는 같은 리비전을 다시 시도하며, 하트비트는 정확히 영속된 리비전과 유효 풀에 대해서만 리비전을 active로 보고합니다. 감지된 유형이 generic과 다른, 핫 리로드로 추가된 generic 백엔드는 더 이상 리로드마다 드레인되고 재구성되지 않습니다.
  • 긴 vLLM 블록 체인에서도 continuum-kv-listener의 프리픽스를 유지합니다 (#1627, PR #1806). 리스너는 추적하는 모든 블록에 누적 토큰을 복사하고 max_chain_tokens에서 유효한 배치를 거부했기 때문에, 다음 시퀀스가 공백처럼 보여 그 백엔드의 모든 프리픽스를 지웠습니다. 이제 블록은 세대에 묶인 조상 관계와 함께 자기 토큰 구간만 보관하고, 한도를 넘는 체인은 시퀀스 진행을 잃지 않고 귀속 불가가 되며, 용량 압박 시에는 관련 없는 상태를 지우는 대신 가장 오래된 프리픽스를 은퇴시키고, 누락되거나 교체된 조상의 자손은 더 이상 salt 귀속을 되찾지 못하며, 비어 있거나 짧은 토큰 구간은 거부합니다.
  • Dynamo 디스커버리 watch가 계속 끝날 때 백오프합니다 (#1754, PR #1770). watch_reconnect_backoff.max만큼 유지된 watch 세션만 지연을 하한으로 되돌리므로, range get 직후 모든 watch를 닫는 프록시를 더 이상 500ms마다 다시 연결하지 않습니다. 백오프만 바꾼 리로드는 레지스트리 스냅샷을 유지하고, 비활성화는 더 이상 kind="restart"로 세지 않으며, 취소 채널이 사라진 watcher는 계속 도는 대신 종료합니다.
  • 중복된 Dynamo 인스턴스 ID와 퍼블리셔 ID를 etcd 키 순서로 해소하지 않고 거부합니다 (#1753, PR #1766). 하나의 (component, endpoint, instance_id)에 대한 기본 모델 카드 두 개, 인스턴스 ID가 같은 generate 엔드포인트 두 개, 퍼블리셔 ID가 같은 kv_metrics 채널 두 개는 모두 거부되고 continuum_dynamo_discovery_events_total{kind="duplicate"}로 한 번 집계됩니다. LoRA 어댑터 카드는 더 이상 워커에 결합되지 않으므로, 어댑터 카드가 워커의 모델 이름과 용량을 덮어쓰던 문제도 고쳐졌습니다.
  • 클라이언트 연결이 끊기면 조용한 /v1/responses 패스스루 스트림을 취소합니다 (#2003, PR #2004). 릴레이는 다음 업스트림 이벤트만 기다렸기 때문에, 제공자가 조용한 동안 떠난 클라이언트의 업스트림 요청은 제공자가 다시 내보낼 때까지 살아 있었습니다. 이제 릴레이는 각 업스트림 읽기와 함께 다운스트림 종료를 감시하고 업스트림을 즉시 끊습니다. background: true 스트림은 디스패치 뒤에 제공자에게 속하고 나중에 조회되므로 취소하지 않습니다.

Security

  • 옵트인 키 테이블 사이드카가 Hub가 해지한 키를 남겨 둘 수 있던 크래시 구간을 막았습니다 (#1969). control_plane.policy.persist_key_table: true에서 보안 관련 키 테이블 변경(키 해지, 제거, 해시 변경, 티어 변경, 티어 제한 변경)은 아무도 기다리지 않는 blocking 풀 쓰기로만 사이드카에 반영되었고, 그 시점에는 변경이 이미 강제되고 커서가 Hub에 확인된 상태였습니다. 그래서 그 사이에 프로세스가 강제 종료되면(또는 SIGTERM이 대기 중인 쓰기를 취소하면) Hub 장애 중 재시작할 때 이전 테이블을 복원해 그 키를 다시 허용했습니다. 이제 이런 변경은 새 스냅샷으로 교체하고 확인을 게시하기 전에 적용 스레드에서 이전 파일을 삭제하고 디렉터리를 fsync하며, 아직 대기 중인 오래된 테이블이 파일을 다시 만들지 못하도록 기록기의 세대 워터마크를 앞당깁니다. 새 테이블은 이전처럼 blocking 풀에서 기록합니다. 이 구간에서 크래시가 나면 파일이 남지 않으므로 재시작은 콜드 스타트가 됩니다. 삭제 실패는 policy_sync.key_table_persistence.write_failures에 집계하고 연속 실패마다 한 번 경고하며, 강제 적용을 늦추지 않습니다. 일상적인 갱신은 여전히 파일을 삭제하지 않습니다. 복원된 테이블은 시계 변화에도 제한됩니다. 동기화 시각이 복원하는 시계보다 5초 넘게 앞선 사이드카는 보정하지 않고 사용할 수 없는 것으로 거부하며, 복원된 테이블은 복원 시점에 잡은 단조 시계 기한에도 만료되므로 벽시계가 뒤로 돌아가도 서빙 기간이 늘어나지 않습니다.

  • 모델 id로 만드는 모든 업스트림 URL에서 모델 id를 정확히 하나의 경로 세그먼트로 인코딩합니다 (#1878). Nano Banana 이미지 경로(/v1/images/generations, /v1/images/variations, /v1/images/edits)는 클라이언트의 model을 인코딩 없이 Google Gemini URL에 넣었기 때문에, gemini-x-image/../../cachedContents?a= 같은 id가 gemini-*image* 조건을 통과하면 라우터가 운영자의 x-goog-api-key를 붙인 요청을 generateContent가 아닌 /v1beta/cachedContents로 보냈습니다. 이제 이런 URL 빌더는 모두 하나의 헬퍼 encode_model_path_segment를 거칩니다(/는 %2F, ?는 %3F, #는 %23, %는 %25). 대상은 이미지 경로, 네이티브 Gemini Responses 및 멀티모달 임베딩 엔드포인트, 각 백엔드의 get_model_details이며, get_model_details는 빈 id와 ., ..를 요청을 보내기 전에 거부합니다. 일반적인 id는 이전과 바이트 단위로 같은 URL을 만듭니다.

  • Nano Banana 이미지 요청의 백엔드를 이름이나 URL이 아니라 구성된 타입으로 선택합니다 (#1896). Nano Banana 경로(/v1/images/generations, /v1/images/edits, Nano Banana 모델의 /v1/images/variations)는 선택된 백엔드의 키를 x-goog-api-key로 실어 모든 요청을 Google의 Gemini API로 보내는데, 이름에 gemini가 들어 있거나 URL 어딘가에 generativelanguage.googleapis.com이 들어 있으면 그 백엔드를 골랐습니다. 그래서 이름이 gemini-gateway인 openai, generic, vllm 백엔드나 URL에 그 문자열을 끼워 넣었을 뿐인 백엔드(https://gw.example.com/generativelanguage.googleapis.com/)의 키가 Google로 전송되었습니다. 이제 type: gemini 백엔드만 후보가 되며, 같은 규칙 때문에 이름에 gemini가 들어간 type: openai 백엔드가 dall-e-2 variations에서 빠지던 문제도 사라졌습니다. 마이그레이션: Google의 OpenAI 호환 엔드포인트(https://generativelanguage.googleapis.com/v1beta/openai)를 가리키는 type: openai 또는 generic 백엔드는 type: gemini로 바꾸기 전까지 Nano Banana를 제공하지 않습니다. 또한 이 엔드포인트의 type: openai 백엔드는 더 이상 OpenAI dall-e-2 variations에서 제외되지 않으므로, OpenAI 백엔드와 섞인 풀에서는 타입을 바꾸기 전까지 일부 variations 요청이 이 백엔드로 라우팅되어 실패할 수 있습니다.

  • Bedrock 모델 id로 .와 ..를 거부합니다 (#1896). Bedrock runtime 및 converse 경로는 빈 id만 거부했고 .와 ..는 퍼센트 인코딩해도 그대로 남기 때문에, 이런 id를 구성하면 /model/{id}/invoke나 /model/{id}/converse에서 점 세그먼트로 해석되어 다른 경로를 가리켰습니다. 이제 요청에 서명하거나 보내기 전에 model에 대한 검증 오류로 거부하며, 다른 id는 이전과 같이 인코딩됩니다.

  • 클라이언트가 임의의 모델 이름으로 smart_routing_requests_total 시계열을 만들 수 없도록 합니다 (#1897). smart_routing.intercept_all이 켜져 있으면 모든 요청이 스마트 라우팅되는데, source_model 레이블이 카디널리티 제한 없이 클라이언트의 model 값을 그대로 담았기 때문에 인증된 클라이언트가 서로 다른 이름마다 제거되지 않는 시계열을 하나씩 만들어 라우터 메모리와 모든 스크레이프를 키울 수 있었습니다. 이제 이 레이블은 요청된 모델이 smart_routing.virtual_model이거나 라우터가 현재 서빙하는 모델일 때만 그대로 유지하며, model이 없는 요청은 none, 모델 별칭을 포함한 그 밖의 이름은 other로 내보냅니다. 이름이 정확히 other나 none인 서빙 모델은 그 시계열을 함께 씁니다. x-smart-route-source 디버그 헤더와 라우팅 로그는 클라이언트가 보낸 이름을 그대로 유지합니다.

  • 모든 빌드에서 모든 요청의 클라이언트 x-dynamo-* 헤더를 제거합니다 (#1750, PR #1765). 이 헤더는 dynamo.shim.backends가 설정된 동안에만 제거되었기 때문에, Dynamo 직접 모드 프런트엔드를 가리키는 일반 백엔드는 클라이언트의 x-dynamo-prefill-instance-id와 x-dynamo-worker-instance-id를 받아 클라이언트가 워커 쌍을 고를 수 있었습니다.
  • 분리 서빙의 백엔드 이름 응답 헤더를 클라이언트가 x-backend 요청 헤더를 보냈을 때만 보냅니다 (#1751, PR #1767). 클라이언트에 보이는 변경: x-continuum-prefill-backend, x-continuum-decode-backend, x-served-backend(Dynamo 심 디스패치에서는 워커 인스턴스 ID를 담음)는 이제 x-served-backend가 다른 곳에서 이미 따르던 규칙을 따르며, 빈 x-backend로도 옵트인됩니다. x-continuum-routing-path는 계속 무조건 보냅니다.

Dependencies

  • 모든 의존성을 최신 릴리스로 갱신했습니다. dirs 7, opentelemetry 0.33, tracing-opentelemetry 0.34와 WebUI의 Alpine.js 3.17.4, uPlot 1.6.32, Tailwind CLI 4.3.3이 포함됩니다 (PR #1771). OTLP 0.33은 일시적인 수집기 실패를 내보내기 타임아웃 안에서 재시도하므로, tracing.otlp.timeout은 이제 재시도를 포함한 배치 내보내기 한 번을 제한합니다. 다시 생성한 스타일시트는 커밋된 tailwind.css에 빠져 있던 클래스 약 20개를 되살립니다.

v1.31.0 - 2026-09-25

Added

  • make calibrate-systemone과 세 System One 소비자의 보정 기록을 추가했습니다(#1725, 에픽 #1719). 레이블된 코퍼스를 각 소비자가 보내는 모양 그대로(자체 state 빌더와 질문 묶음) /v1/systemone 엔드포인트에 재생하고, 슬라이스(all, en, ko, non_latin)별 정확도, 상수 답변 기준선, Brier, ECE, AUROC, 혼동 행렬, p50/p95 지연, 호출당 토큰을 체크포인트 리비전과 함께 기록하며, 테스트된 규칙으로 임계값을 도출합니다. 기본 laya-serve(CPU), 고정한 각 Laya 체크포인트, Kev-0.8B에 대한 실행을 tests/fixtures/systemone/calibration/2026-09-24/에 커밋했고, 호스팅 Jev, Kev-4B, method: llm shadow 축은 측정하지 않았으며 그 이유를 기록했습니다. 새 운영 문서 docs/ko/decision-models.md는 모델, 와이어, 세 소비자, 외부 전송과 적대적 견고성 주의사항, 절차, 결과, 결정 모델을 쓸 때를 다룹니다.

  • systemone 가드레일 프로바이더를 추가했습니다 (별칭 jev, laya; #1722, 에픽 #1719). System One 결정 모델(호스팅 Jev, 또는 #1720의 공유 클라이언트를 통한 자체 호스팅 Laya / Kev / Nimble 서버)에 한 번의 호출로 라우터 카테고리별 noul 하나와 4단계 severity 점수를 묻고, 응답된 확률을 판정 score로 사용합니다. p >= category_thresholds[category](기본 0.70)이면 차단, p >= options.review_threshold(기본 0.35, 카테고리별 options.review_thresholds)이면 flag, flag이면서 severity >= options.severity_block_level(기본 2.0)이면 차단으로 승격되므로, 모델로부터 Flag 판정과 실제 검토 구간에 처음으로 도달할 수 있습니다(분류기 프로바이더는 상수 1.0을 내놓습니다). 대상은 프로바이더 행의 공유 systemone 블록입니다. 상태는 텍스트를 이어 붙인 문자열이 아닌 객체이며(입력은 {user_message, history}, 출력과 check_streaming_chunk를 통한 chunked 윈도우별로는 {ai_response, user_message}), 옵트인하지 않는 한 시스템 프롬프트와 도구 결과를 제외하고, 전송 전에 max_state_bytes 아래로 맞춰집니다. options.task: injection은 배터리를 jailbreak와 indirect_injection으로 좁히고, options.questions는 문구를 그대로 대체합니다. 모든 422 / 429 / 529 / 타임아웃 / 잘못된 응답은 on_error를 따르는 검사 오류이며, 529는 추가로 guardrail_degraded_total{kind="overloaded"}를 기록합니다. config validate는 대상 블록 누락, 행 수준 대상 필드, 같은 카테고리의 조치 임계값보다 높은 검토 임계값, 알 수 없는 질문 카테고리를 거부합니다. 모델이 적대적 입력에 견고하지 않은 1차 게이트이므로 새 guardrails-systemone 템플릿과 함께 mode: monitor로 제공됩니다.

  • 스마트 라우팅용 System One 결정 모델 분류기를 추가했습니다 (smart_routing.classifier.method: system_one 및 hybrid_system_one, #1723, 에픽 #1719). 캐시되지 않은 요청마다 결정 호출 한 번으로 정렬된 복잡도 수준(내장 다섯 개에 custom_complexity_levels를 rank 순서로 더해 최대 열 개)에 대한 score와 도메인(내장 여섯 개에 custom_domains를 더하며 열여섯 개를 넘으면 로드 시 경고)에 대한 choice를 묻고, 점수를 가장 가까운 수준으로 반올림하며, 다른 분류기와 같은 ClassificationResult를 실제 confidence(두 답의 신뢰도 중 최솟값)로 채우므로 라우팅 정책은 바꿀 필요가 없습니다. 두 개의 선택적 noul 질문(signals.needs_tools, signals.is_sensitive)은 분류 신호로만 기록됩니다. min_confidence(기본값 0.5)가 답을 게이트합니다. system_one은 그 미만이면 규칙 결과로 폴백하고, hybrid_system_one(규칙을 먼저, 규칙이 불확실하면 System One)은 LLM 분류기가 설정되어 있으면 그쪽으로 에스컬레이션하고 아니면 규칙 결과를 사용하며, 모든 오류는 LLM 분류 실패와 똑같이 규칙 결과를 돌려줍니다. 전송되는 state는 LLM 분류기의 송신 내용을 JSON 객체로 옮긴 것(마지막 사용자 요청을 max_input_tokens로 절단한 것, 200자 시스템 발췌, message_count, has_images, has_tools, 요청된 model)뿐이며, 캐시 키는 그 SHA-256입니다. rule, llm, hybrid 옆에 shadow: true를 두면 라우팅 결정을 바꾸지 않고 질문 묶음을 실행해 smart_routing_shadow_agreement_total{field, agree}와 X-Smart-Route-Shadow 디버그 헤더로 일치도를 보고하므로, 플릿은 결정 모델로 전환하기 전에 먼저 측정할 수 있습니다. 블록은 #1720의 공유 systemone 대상 계약을 평탄화해 내장하고(classifier.system_one.endpoint 또는 .backend), LLM 분류기의 캐시와 disable_under_load_state 필드를 그대로 따르며, smart_routing의 나머지와 함께 핫 리로드되고(method: llm에서 system_one으로, 다시 되돌리는 리로드는 새 방식에 필요한 분류기만 정확히 다시 만듭니다), 로드 시 검증됩니다. 이 검증은 LLM 분류기의 빈틈도 메웁니다. 알 수 없거나 enabled: false인 백엔드를 가리키는 classifier.llm.backend는 그 백엔드로 보내는 방식에서 이제 규칙 기반으로의 조용한 폴백이 아니라 로드 오류입니다. 새 메트릭: smart_routing_system_one_classifier_cache_hits_total, smart_routing_system_one_classifier_fallbacks_total{reason}, smart_routing_system_one_classifier_input_tokens_total{counted}(서버가 보고한 사용량을 라우터 내부 사용량으로 기록하며 counted는 count_toward_budgets를 따름), 그리고 기존 분류 시리즈의 classifier_type="system_one". 구성 어시스턴트에 smart-routing-systemone 설정 템플릿이 추가되었고, tests/smart_routing_accuracy_test.rs에는 같은 레이블된 사례들을 픽스처 서버로 재생하는 System One 모드가 추가되었습니다.

  • safeguards.action_safety에 System One 평가기를 추가했습니다(#1724, 에픽 #1719). evaluator: systemone과 systemone 블록(공유 System One 대상과 allow_threshold 0.85, deny_threshold 0.60, min_confidence 0.6, mode: advisory | enforce)을 설정하면 제안된 도구 액션마다 의사 결정 호출 한 번으로 approve / confirm / block 중 하나인 action과 irreversible, injected_instruction을 {tool, arguments, user_request, recent_tool_results}에 대해 묻습니다. 시스템 프롬프트는 보내지 않습니다. 매핑은 fail-closed입니다: allow는 모든 임계값을 넘고 주입된 지시가 없는 approve 선택에서만, deny는 deny_threshold에 이른 block 확률이나 주입된 지시에서, indeterminate는 confirm과 모든 전송·파싱 실패에서 나옵니다. user_request는 도구 왕복이 아닌 마지막 사용자 메시지(끝 4KiB 유지)이며, Claude Code의 <system-reminder> 블록은 건너뜁니다. 기본값인 advisory 모드는 채팅 모델 평가기의 판정(backend가 없으면 indeterminate)을 방출하고, 판정이 되었을 값을 진단과 새 safeguards_action_safety_advisory_total{would_verdict, actual_verdict} 메트릭에 기록합니다. enforce는 매핑된 판정을 방출합니다. 기본값은 여전히 evaluator: llm이며, systemone에서는 backend가 선택 사항이 되고 기존 한도는 모두 그대로 적용됩니다.

  • Dynamo direct 모드 프론트엔드 shim 풀과 인스턴스 id 헤더 picker를 추가합니다 (#1699, 에픽 #1703의 일부). disaggregated_serving.enabled: true와 함께 dynamo.frontend --router-mode direct로 실행되는 평범한 OpenAI 와이어 role: unified 백엔드들을 dynamo.shim.backends 풀로 지정하면, 라우터가 탐색 레지스트리에서 prefill 워커(active_prefill_tokens / max_num_batched_tokens)와 decode 워커((active_decode_blocks + ceil(estimated_prompt_tokens / block_size)) / total_kv_blocks, data-parallel 랭크마다 후보 하나)를 고르되 신선한 비용이 가장 낮은 후보를 우선하고 신선한 샘플이 없는 후보들 사이에서는 라운드 로빈으로 순환하며, 클라이언트가 보낸 x-dynamo-* 헤더를 모두 제거한 뒤 x-dynamo-prefill-instance-id, x-dynamo-prefill-dp-rank, x-dynamo-worker-instance-id, x-dynamo-dp-rank를 붙여 클라이언트 요청을 그대로 풀로 보냅니다. 이 제거는 풀이 설정되어 있는 한 /v1 표면 전체에 걸리므로, shim 복제본을 평범한 백엔드로 고르는 경로를 포함해 어떤 경로도 클라이언트의 고정 헤더를 shim으로 실어 나르지 않습니다. dynamo.shim.model_map은 후보 매칭에만 쓰이며, 요청 본문은 그대로 전달되므로 프론트엔드가 클라이언트의 모델 id를 해석할 수 있어야 합니다. 어떤 워커도 서비스하지 않는 모델은 일반 라우팅으로 넘어가고, prefill 워커가 없으면 x-dynamo-worker-instance-id만 보내며 경로는 unified로 보고합니다. 응답에는 x-continuum-routing-path, dynamo:<instance_id>[/dp<rank>] 형식의 x-continuum-prefill-backend / x-continuum-decode-backend, shim 이름을 담은 x-served-backend가 실립니다. 프론트엔드의 direct 모드 거부는 503 disaggregated_prefill_decode_unavailable로 응답하고 disaggregated_errors_total{phase="orchestration"}로 집계하며 워커 쌍을 다시 고르지 않습니다. 새 continuum_dynamo_selection_total{role, reason}이 추가되고, 선택 정책은 레지스트리의 DynamoWorkerPicker 트레이트로 교체할 수 있습니다. dynamo.shim은 즉시 반영되고, dynamo.shim.model_map은 클라이언트 모델 id를 Dynamo display name으로 변환하며, 새 dynamo-direct-shim.yaml 템플릿이 포함됩니다.

  • Dynamo prefill/decode 플릿을 direct 모드 프론트엔드 shim 뒤에 두는 구성을 위한 GPU 없는 종단 간 하네스, 배포 예제, 운영자 문서를 추가합니다 (#1700, 에픽 #1703의 일부). deploy/examples/dynamo-direct-shim/은 etcd, prefill·decode 분리 모드의 dynamo.mocker 워커 둘, dynamo.frontend --router-mode direct 하나, 라우터를 띄우며, 모든 이미지 태그를 하나의 .env에만 고정해 compose 스택과 새 호환성 표가 어긋날 수 없게 합니다. mocker는 탐색이 etcd일 때 기본값이 NATS이고 라우터의 부하 구독자는 ZMQ만 읽으므로 --event-plane zmq를 명시적으로 전달합니다. scripts/e2e-dynamo-shim.sh는 GET /admin/dynamo/workers가 두 역할을 보일 때까지 기다린 뒤 스트리밍·비스트리밍 completion을 보내고, x-continuum-prefill-backend와 x-continuum-decode-backend가 서로 다른 인스턴스를 지명하며 x-dynamo-* 헤더가 클라이언트로 되돌아가지 않음을 확인하고, prefill 워커를 멈춰 집계 선택으로의 전환을 확인하며, 실패를 포함해 종료 시 스택을 정리합니다. .github/workflows/dynamo-e2e.yml은 수동 디스패치로만 실행되고, 저장소의 self-hosted 풀이 macOS인데 Dynamo 이미지는 linux/amd64이므로 러너 라벨을 입력으로 받습니다. docs/ko/architecture/dynamo-integration.md에는 토폴로지 다이어그램, 호환성 표, Dynamo가 계속 소유하는 것(토크나이즈, prefill/decode 핸드오프, KV 전송, 워커 생존)의 목록이 추가되고, docs/ko/deployment.md에는 "Dynamo prefill/decode 플릿" 절이 추가됩니다. 쿠버네티스 대응물은 deploy/helm/continuum-router/values-dynamo-direct-shim.yaml(기본값이 꺼진 옵트인 dynamoShimService ExternalName 별칭 포함)과 deploy/kubernetes/overlays/dynamo-direct-shim/이며, 둘 다 운영자가 관리하는 etcd와 기존 shim Service를 가리키고 RBAC 변경은 없습니다.

  • 크기가 제한된 비동기 프로세스 로그 파이프라인과 선택적 순환 파일 싱크를 추가했습니다(#1676). 포맷된 로그 레코드는 이제 제한된 큐에 들어가고 전용 writer 스레드 하나가 큐를 비우므로, 느리거나 멈춘 stdout, stderr, 파일 출력이 요청을 처리하는 Tokio 워커를 더 이상 막지 않습니다. 큐는 레코드 수(logging.queue_capacity, 기본값 8192)와 보관 바이트(logging.queue_capacity_bytes, 기본값 16 MiB)로 제한되고, 레코드 하나는 64 KiB에서 UTF-8 경계에 맞춰 잘립니다. 큐가 가득 차거나 싱크가 실패하면 라우터를 막거나 멈추지 않고 레코드를 버립니다. logging.file은 평문 레코드를 디렉터리에 기록하며 크기 기반 순환, 보관 개수 제한, {date} / {time} / {pid} / {sequence} 파일 이름 자리표시자({sequence} 필수), 파일 모드 0600을 지원합니다. 두 큐 설정은 재시작해야 적용됩니다. 새 메트릭: continuum_process_log_queue_depth, continuum_process_log_queue_bytes, continuum_process_log_dropped_total, continuum_process_log_truncated_total, continuum_process_log_write_errors_total.

Changed

  • 측정된 System One 기본값을 제공합니다(#1725). 기준 실행(compose 파일의 laya-serve, CPU)에 따라 가드레일 review_threshold 기본값을 0.35에서 0.40으로, violence 조치 임계값을 0.70에서 0.75로(다른 카테고리는 block 정밀도 0.90에 도달하지 않아 벤더 값 0.70 유지), smart_routing.classifier.system_one.min_confidence를 0.5에서 0.15로 바꿨습니다. 0.15에서 System One 체인이 규칙 분류기를 이겼습니다(0.463 대비 0.510, 이전 기본값에서는 0.448). action 답이 상수 기준선보다 낮아 action safety 임계값은 벤더 값을 유지합니다. 그 엔드포인트에서 가드레일과 action safety 소비자는 모니터 전용으로, 라우팅은 한국어 트래픽에 대해 shadow 전용으로 문서화했습니다. tests/systemone_calibration_test.rs는 커밋된 실행에서 모든 기본값을 다시 도출해 라이브러리나 두 언어의 기본값 표가 어긋나면 실패합니다. mode: enforce에서는 기본값 기준 차단이 줄어듭니다. violence 확률 [0.70, 0.75)는 이제 block 대신 flag되고(severity >= 2이면 여전히 block), 카테고리 확률 [0.35, 0.40)는 더 이상 flag되지 않습니다. 이전 구간을 유지하려면 review_threshold와 category_thresholds를 명시하십시오.

  • GPT Image의 사용자 지정 크기와 요청당 최대 10장 생성을 받도록 했습니다(#1740). 이미지 생성은 n이 1보다 큰 GPT Image 요청과 1024x1024, 1536x1024, 1024x1536, auto 외의 크기를 모두 거부했고, 이미지 편집도 같은 크기를 거부했습니다. 그러나 OpenAI는 n을 1~10까지 받고, GPT Image 2와 GPT Image 2.5 모델에서는 각 변이 16의 배수, 긴 변이 3840 이하, 비율이 1:3~3:1, 픽셀 수가 655,360~8,294,400인 임의의 WIDTHxHEIGHT를 받습니다. 이제 validate_gpt_image_size 하나가 생성과 편집을 함께 처리하며, 거부한 크기가 어떤 규칙을 어겼는지 알려 주고, GPT Image 1 모델에는 프리셋 목록만 허용합니다. 생성에서도 GPT Image 모델의 n은 1~10이고(편집은 이미 허용), DALL-E 3는 그대로 1이며, 모든 GPT Image 모델의 카탈로그 max_n은 10이 됩니다.

Removed

  • OpenAI 백엔드가 알리는 모델 목록과 내장 OpenAI 카탈로그에서 gpt-4-32k, gpt-3.5-turbo-16k, o1-mini를 빼고, o1-mini 카탈로그 항목은 model-metadata-retired.yaml로 옮겼습니다(#1739). 세 모델 모두 OpenAI API와 Azure OpenAI 양쪽에서 종료되었습니다(gpt-4-32k 2025-06-06, gpt-3.5-turbo-16k 2024-09-13·2025-04-30, o1-mini 2025-10-27·2025-11). gpt-4, gpt-4-turbo, gpt-3.5-turbo, o1, o3-mini, gpt-image-1처럼 종료일이 정해졌지만 아직 오지 않은 모델은 그날까지 목록에 남깁니다. 이 id들에 대한 요청 처리는 바뀌지 않습니다.

Fixed

  • deploy/docker-compose.systemone.yml이 실제로 laya-serve를 실행하도록 고쳤습니다(#1725). inference extra가 설치하는 laya가 자체 laya-serve 콘솔 스크립트를 제공해 패키지의 스크립트를 덮어썼기 때문에, 컨테이너는 /healthz 대신 /health를 제공했고(헬스체크가 통과하지 못함) LAYA_SERVE_* 설정을 무시했습니다. 또한 compose 파일은 설정되지 않은 LAYA_SERVE_API_KEY를 빈 문자열로 바꿨는데, laya-serve는 이를 설정된 키로 취급해 모든 요청이 401을 받았습니다. 이제 서비스는 python -m laya_serve.cli로 시작하고 빈 키는 해제합니다.
  • Anthropic과 Bedrock 모델 탐색이 Claude의 실제 컨텍스트 창과 출력 상한을 보고하도록 고쳤습니다(#1741). 두 백엔드는 부분 문자열 비교로 ModelInfo 한도를 정해 모든 Opus를 200K / 32K, 모든 Sonnet을 200K / 64K로 두었습니다. 그래서 model-metadata.yaml을 읽지 않는 네이티브 type: anthropic·type: bedrock 백엔드가 1M을 받는 Opus 4.6~5.5와 Sonnet 4.6·5를 200K로 알리고 있었습니다. 새 공용 함수 claude_model_limits는 계열 버전을 파싱하고(별칭, 날짜 스냅샷, Bedrock inference profile id 포함), 모든 Claude 항목에서 카탈로그와 같은 값을 내는지 테스트로 확인하며, 이후 마이너 버전은 계열의 한도를 그대로 물려받습니다. 두 백엔드의 max_tokens_per_request 기능 값도 200K에서 1M으로 올렸습니다.
  • Dynamo kv_metrics 구독자에서 할당 전에 ZMQ 프레임 길이를 제한합니다 (#1733, 에픽 #1703의 일부). SUB 소켓은 이제 zeromq가 아니라 라우터 자신의 최소 ZMTP 3.x 클라이언트(src/dynamo/load/zmtp.rs)로 평범한 tokio::net::TcpStream 위에서 동작합니다. zeromq의 ZmqCodec::decode는 와이어에서 8바이트 프레임 길이를 읽고 payload 바이트가 하나도 도착하기 전에 그만큼의 용량을 확보하며, 최대 메시지 크기 옵션이 없고, 코덱 모듈이 비공개이며 전송 계층을 교체할 수도 없습니다. 라우터는 플릿이 작성한 etcd 레코드가 알리는 엔드포인트로 접속하고 [profile.release]는 panic = "abort"를 설정하므로, 적대적인 피어가 선언한 바이트를 실제로 보내지 않고 9바이트 헤더만으로 라우터 프로세스 전체를 끝낼 수 있었고 more 플래그 프레임을 이어 붙여 멀티파트 메시지 하나를 무한히 키울 수도 있었습니다. 라우터 자신의 16 KiB payload 검사는 이미 수신이 끝난 프레임에 대해 동작하므로 거기까지 닿지 않았습니다. 새 클라이언트는 32 KiB를 넘는 메시지 프레임 선언 길이(좁히기 전에 u64로 비교하므로 선언된 u64::MAX가 허용 가능한 값으로 절단될 수 없습니다), 4 KiB를 넘는 커맨드 프레임, 와이어 메시지가 정확히 네 프레임인데도 8프레임을 넘기는 more 연쇄, 합계 64 KiB를 넘는 연쇄를 모두 거부하며, 각 검사는 와이어 값으로 무언가의 크기를 정하기 전에 이뤄지고 읽기 버퍼는 컴파일 시 상수로 한 번만 할당됩니다. NULL 메커니즘의 ZMTP 3.x로 PUB 또는 XPUB 소켓과 대화하지 않는 피어도 거부합니다. 거부는 새 continuum_dynamo_load_events_total{result="transport_refused"} 라벨로 한 번 집계되고 연결은 기존 지터 백오프 재연결로 넘어가므로, 적대적이거나 고장난 발행자가 얻을 수 있는 최대치는 자기 구독을 잃는 것입니다. 프로세스 무결성을 위해 dynamo.discovery.etcd의 쓰기 주체를 라우터와 동등하게 신뢰해야 한다고 요구했던 신뢰 경계 단서는 모듈 문서와 두 아키텍처 문서에서 제거되었습니다. 구독 수명 주기, 접속 예산, 디코더, 스토어는 그대로입니다. 재연결 스케줄은 접속이 성공할 때마다가 아니라 세션이 최소 30초 유지된 다음에만 500ms 바닥에서 다시 시작합니다. 그렇지 않으면 핸드셰이크를 마치고 곧바로 실패하는 피어가 자기 etcd 레코드가 존재하는 동안 발행자마다 초당 몇 번씩 재접속·기록·집계를 일으키며 스케줄을 바닥에 붙잡아 둘 수 있었고, 잘 만들어진 샘플조차 그런 피어가 보낼 수 있는 것이므로 피어가 만들어내는 어떤 신호로도 이를 판단할 수 없습니다. zeromq는 [dev-dependencies]로 옮겨져 어떤 런타임 그래프도 이를 해석하지 않고 릴리스 바이너리에서도 빠지지만, tests/dynamo_load_zmq_test.rs는 여전히 실제 PubSocket을 발행자로 돌립니다. 유계 클라이언트의 그리팅, READY 교환, ZMTP 3.0 구독 형식이 Dynamo 이벤트 플레인이 쓰는 크레이트와 계속 일치하는지 검증하는 장치입니다.

Dependencies

  • 의존성 lock을 갱신하고(rmcp 3.3.0에서 3.4.1을 포함한 47개 패키지), rmcp 3.4가 deprecated로 지정한 ServerInfo 별칭 대신 rmcp의 InitializeResult를 직접 사용하도록 했습니다(PR #1686). dependabot 그룹 업데이트 PR #1683을 대체합니다.

v1.30.0 - 2026-09-24

Added

  • 과금 귀속을 바꾸지 않고 Hub 백엔드 인벤토리와 AppProxy ROUTER 복제본에 명시적 엔드포인트·배포 UUID를 보고하며, 백엔드 설정·Admin·후보 탐색·스키마에서 이를 지원합니다 (#1732).
  • 공유 System One 결정 모델 클라이언트를 추가했습니다 (src/services/systemone/, #1720, 에픽 #1719). state에 대한 타입이 지정된 noul / choice / score 질문을 보내고 응답을 질문에 맞춰 디코딩합니다. 호출당 POST /v1/systemone 한 번을 보내며, 429, 529, 연결 실패에 한해 호출 자체의 타임아웃 안에서 retry-after를 지키며 최대 한 번 재시도하고, 422나 파싱된 응답은 절대 재시도하지 않습니다. 각 소비자가 자체 실패 정책을 적용할 수 있도록 닫힌 오류 열거형을 제공하고, 오류 본문은 200바이트로 제한하며, 서버가 해석한 model과 usage를 감사 및 내부 사용량 집계용으로 노출합니다. systemone_requests_total{consumer, result}와 systemone_request_duration_seconds{consumer} 메트릭을 추가했으며, 어떤 로그나 메트릭에도 state나 질문 텍스트가 실리지 않습니다. 대상은 항상 컴파일되는 SystemOneTargetConfig 블록(endpoint + api_key_env, 또는 고정 백엔드 계약을 따르는 backend, 그리고 model, timeout_ms, max_state_bytes, count_toward_budgets, max_concurrent)으로 한 번만 기술되며, 로드 시 검증되고 구성 어시스턴트에 문서화됩니다. 소비자 하위 이슈들은 이 블록을 그대로 내장합니다.

  • 지난 카탈로그 갱신 이후 출시된 모델을 model-metadata.yaml에 추가했습니다. 공개된 컨텍스트 창, 출력 상한, 지식 기준 시점, MTok당 또는 이미지당 가격을 함께 기록합니다. OpenAI는 gpt-6-sol, gpt-6-luna(9월 22일)와 GPT Image 2.5 두 모델 gpt-image-2.5-sunburst, gpt-image-2.5-flare(9월 8일), Anthropic은 claude-opus-5-5(9월 22일)입니다. Meta는 muse-spark-1.1, muse-spark-1.2, muse-spark-1.3과 1.2, 1.3의 저가 -contributor id, muse-image-1.0, muse-voice-transcribe-1.0입니다. Google은 gemini-3.8-live, gemini-3.8-live-extended-thinking, gemini-3.5-transcribe, gemini-3.5-transcribe-live, gemini-robotics-er-2-preview, gemini-3.1-flash-lite-image입니다. 중국 연구소의 오픈 웨이트 및 API 모델로는 deepseek-v4.1-flash(API id deepseek-flash), glm-5.3, glm-5.3-flash, glm-5.3-flashx, mimo-v2.6-pro, mimo-v2.6-flash, Tencent hy4-preview, hy3, Meituan longcat-2.0, Ant Group ling-3.0-flash, Shanghai AI Laboratory intern-s2-397b, StepFun step-3.7-flash, qwen-image-2.1, qwen-image-3.0-pro, ByteDance dola-seedream-5-0-pro-260628, dola-seedream-5-0-flash-260915를 추가했습니다. 기존 항목에도 업스트림 변화를 반영했습니다. deepseek-v4-pro는 0813 GA 스냅샷과 2026-08-16부터 적용된 피크 요금을, deepseek-v4-flash는 API id가 이제 V4.1 Flash로 연결된다는 점을 기록합니다. gemini-3-pro-image-preview는 GA id인 gemini-3-pro-image로 바꾸고(프리뷰 id는 별칭으로 유지) 가격을 Google의 토큰 요금에 맞춰 고쳤으며, muse-glimmer-30b에서는 모델 카드와 Meta 문서 어디에도 없는 비디오 입력을 뺐습니다. text-embedding-004에는 Gemini API에서는 종료되었지만 Vertex AI가 2027-04-01까지 제공한다는 점을, gpt-image-1, gpt-image-1.5, gpt-image-1-mini에는 예정된 종료일을, Gemini 2.5 모델에는 2026-09-18부터 기존 사용 프로젝트에만 제공된다는 점을 적었습니다.

  • gpt-6-sol과 gpt-6-luna를 내장 OpenAI 카탈로그에도 등록했습니다. 네이티브 type: openai 백엔드의 /v1/models 탐색은 YAML을 읽지 않기 때문입니다. 두 모델은 gpt-6-astra와 달리 reasoning_effort: "none"을 받고(none부터 max까지, 기본값 medium), OpenAI 문서상 Chat Completions에서는 none일 때만 함수 도구를 받으므로 gpt-5.6 계열과 같은 도구+추론 우회 플래그를 가집니다. none인 도구 요청은 /v1/chat/completions에 남고, 그 밖의 effort이거나 effort를 생략한 도구 요청은 /v1/responses로 우회합니다. GPT Image 2.5 두 모델은 내장 이미지 카탈로그에 등록했고, 이미지 생성은 이 모델들의 새 품질 값 xhigh와 max를 받습니다. 이전 GPT Image 모델에는 여전히 라우터가 이 값을 거부합니다.

  • Claude Opus 5.5의 요청 형식을 지원합니다. Opus 5.5는 thinking이 항상 켜져 있고 명시적 비활성 thinking 설정을 모든 effort에서 HTTP 400으로 거부하므로, effort high까지 이 설정을 받는 Opus 5 규칙 대신 Mythos급 계열의 생략 경로(model_rejects_disabled_thinking)를 탑니다. 두 게이트가 한 id에 함께 걸리지 않는다는 성질은 그대로입니다. Opus 5.5는 강제 도구 호출도 없앴으므로 model_rejects_forced_tool_choice가 Mythos급 >= 5.1에 더해 Opus >= 5.5도 다루며, 세 진입점 모두에서 라우터가 공식 해결책을 안내하는 자체 400으로 강제 tool_choice에 응답합니다. 두 게이트 모두 계열 버전을 파싱해 판단하므로 Opus 5의 동작은 그대로이고 이후 Opus 마이너 버전은 새 규칙을 물려받습니다.

  • 각 사용 레코드의 ttft_ms가 측정한 것을 기록하도록 했습니다. 모든 컨트롤 플레인 사용 프로듀서가 이제 추가된 ttft_kind 프로토콜 필드(TtftKind: streamed_first_token, response_headers, cache_replay)로 스팬의 종류를 명명하며, UsageEvent와 into_usage_record를 통해 전달됩니다 (#1641). 이 필드는 ttft_ms가 설정된 경우에만 설정되고, ttft_ms가 없는 레코드는 절대 kind를 갖지 않습니다. 이는 업스트림 재동기화 전의 추가 로컬 프로토콜 변경이며, 허브 측 분석 수정은 lablup/continuum-hub#1164입니다.

Changed

  • 종료되었거나 사용이 제한된 모델을 가리키던 기본값을 모두 새 프로젝트가 호출할 수 있는 모델로 바꿨습니다. model 없는 이미지 생성 요청의 기본값은 dall-e-3에서 gpt-image-2로, 이미지 편집은 gpt-image-1(OpenAI가 2026-10-23에 종료)에서 gpt-image-2로, 이미지 변형은 OpenAI의 유일한 변형 모델이던 dall-e-2에서 nano-banana로 바뀝니다. dall-e-2와 dall-e-3는 2026-05-12에 종료되었으므로 기존 기본값은 이런 요청을 모두 종료된 모델로 보내고 있었습니다. GPT Image 모델은 response_format을 받지 않으므로, model을 생략하고 response_format을 보낸 요청은 이제 그 조합에 대한 라우터의 기존 400을 받습니다. nano-banana는 Google이 2026-09-18에 기존 사용 프로젝트로 제한한 gemini-2.5-flash-image 대신 gemini-3.1-flash-image로 매핑하며, 카탈로그 별칭도 함께 옮겼습니다. gemini-2.5-flash-image를 직접 지정하면 지금도 그대로 전달됩니다. 모델 탐색이 실패할 때 쓰는 Gemini 백엔드의 대체 모델 목록은 gemini-3.1-pro-preview, gemini-3.8-flash, gemini-3.5-flash, gemini-3.5-flash-lite로 바꿨고, 백엔드가 알리는 모델 목록에서는 gemini-2.0-flash, gemini-2.0-flash-exp, gemini-2.0-flash-lite, text-embedding-004, imagen-3.0-generate-002를 빼고 gemini-3.1-flash-image, gemini-3-pro-image를 넣었습니다. OpenAI 백엔드 목록에서는 dall-e-2, dall-e-3를 빼고 gpt-image-2와 GPT Image 2.5 두 모델을 넣었습니다. timeouts.limits.extended_models 기본 목록에는 gpt-6-astra, gpt-6-sol, gpt-6-luna, claude-fable-5-1, claude-opus-5-5를 추가하고 gemini-3-pro를 뺐으므로, max_streaming_timeout을 넘는 모델별 스트리밍 재정의가 새 프런티어 티어에서는 통과하고 종료된 모델에서는 거부됩니다. 운영자가 목록을 지정하면 여전히 기본값을 통째로 대체합니다.

Removed

  • 모든 제공 경로에서 종료된 모델의 메타데이터를 model-metadata.yaml에서 라우터가 읽지 않는 새 파일 model-metadata-retired.yaml로 옮겼습니다. 대상은 dall-e-2, dall-e-3, gemini-3-pro(별칭 gemini-3-pro-preview 포함), gemini-2.0-flash, gemini-2.0-flash-lite, imagen-4, imagen-3.0-generate-002입니다. 각 모델은 2026-09-23에 제공사 자체 API, Azure OpenAI, Vertex AI 기준으로 확인했고, 항목마다 어디서 언제 종료되었는지 기록했습니다. text-embedding-004는 Vertex AI가 아직 제공하므로 남겼습니다. 새 파일은 기본 카탈로그와 스키마가 같아서, 이 id를 아직 서비스하는 배포는 항목을 model-metadata.d/ 드롭인에 복사하면 메타데이터를 되살릴 수 있습니다. 종료된 모델은 두 카탈로그 모두에서 빠진다는 규칙에 따라 dall-e-2와 dall-e-3는 내장 OpenAI 카탈로그에서도 제거했으며, DALL-E id에 대한 요청 처리는 그대로입니다. 종료 모델 파일이 파싱이나 검증에 실패하거나 같은 id·별칭이 두 파일에 함께 있으면 빌드가 실패하는 테스트를 추가했고, 이미지 메타데이터 테스트는 두 파일을 모두 읽어 DALL-E에만 있는 per_image, limits 형식도 계속 검사합니다.

Fixed

  • /v1/images/edits가 gpt-image-2, gpt-image-2.5-sunburst, gpt-image-2.5-flare를 받도록 고쳤습니다. 편집 엔드포인트의 모델 허용 목록이 GPT Image 1 세대에서 멈춰 있어서, 현재 GPT Image 모델로 보낸 편집 요청은 모두 백엔드에 닿기 전에 라우터가 400으로 거부했습니다.

  • nano-banana-pro를 Google이 2026-06-25에 종료한 gemini-3-pro-image-preview 대신 GA id인 gemini-3-pro-image로 매핑하도록 고쳤습니다. 그동안 모든 nano-banana-pro 이미지 생성과 변형 요청이 종료된 모델로 전달되고 있었습니다.

  • 실제 워커가 올라탈 수 있도록 분리형 prefill/decode 오케스트레이터 스캐폴드를 수리했습니다 (#1698, 에픽 #1703의 일부). Fast-decode 조회는 이제 KV 리스너와 같은 방식으로, 공유 prefix_key_hex 인코더가 만든 64자 16진수 SHA256 prefix key로 KV 캐시 인덱스를 조회하며 KV overlap scorer처럼 prefix_routing.enabled에 게이트됩니다. 이전에는 요청을 DefaultHasher로 16자 16진수로 해시했는데, 이는 어떤 실제 이벤트도 채우지 않는 키 공간이었습니다. stream: true 요청도 이제 오케스트레이터를 거칩니다. 결정은 스트리밍 분기 전에 내려지고, 선택된 백엔드는 단일 원소 허용 목록으로 스트리밍 파이프라인에 전달되며, x-continuum-routing-path와 백엔드 헤더가 스트리밍 응답에 찍힙니다. fallback_to_unified는 실패한 라우팅 시도를 통합형 스트리밍으로 재디스패치하고, 꺼져 있으면 503 엔벨로프가 유지됩니다. BackendInfo.healthy는 하드코딩된 true 대신 상태 확인기의 판정이고 in_flight는 0 대신 풀의 라이브 게이지이며, 가장 신선한 엔진 통계의 waiting_requests와 kv_cache_usage가 피커를 위해 함께 실립니다. 역할 필터링은 풀에 등록된 RoleFilterScorer(BackendPool::phase_eligible_candidates)를 거치며, 이 scorer는 새로 추가된 풀별 설정 아이덴티티 맵에서 역할을 읽습니다. BackendPool::update_from_config는 (name, url)만이 아니라 이 아이덴티티를 비교하므로 role 뒤집기는 drain 없이 제자리에서 다시 읽히며(role이 핫 리로드 백엔드 변경 감지에도 포함되어, 역할만 바뀐 편집에서도 이 조정이 실행됩니다), models, weight, request_extensions 변경은 엔진 통계 스냅샷을 유지한 채 백엔드를 drain 후 재생성하며, URL 변경은 기존의 초기화를 그대로 수행합니다. disaggregated_* 메트릭 패밀리는 전역 레지스트리에 등록되어 오케스트레이션 경로가 기록합니다. disaggregated_requests_total{routing_path}, disaggregated_fallback_total, disaggregated_errors_total{phase}가 기록되고, 지속 시간 히스토그램은 등록되었지만 피커를 위해 예약되어 있습니다. 아키텍처 페이지는 이제 KV 전송을 엔진 소유(워커 간 NIXL)로 설명하고 라우터는 선택만 중개한다고 기술하며, external_storage를 레거시 필드로 문서화합니다. 공개 구조체 continuum_router::services::disaggregated::BackendInfo가 변경되었습니다. in_flight가 이제 u64이고, waiting_requests와 kv_cache_usage 필드가 새로 추가되었으며, BackendInfo::new 생성자가 추가되었습니다. BackendInfo 값을 직접 생성하는 다운스트림 호출자는 호출 지점을 검토해야 합니다.

  • TokenUsage::from_json_value가 캐시 버킷 필드를 프로바이더가 보낸 값 그대로 저장하지 않고 포함 방식 프롬프트 수로 제한하도록 고쳤습니다 (#1678). OpenAI prompt_tokens_details.cached_tokens, Responses input_tokens_details.cached_tokens, 최상위 cache_read_input_tokens, Gemini의 cachedContentTokenCount처럼 prompt_tokens보다 큰 캐시 수치가 오면 그 값을 그대로 저장했으므로 허브 레코드의 cached_input_tokens가 포함 방식 프롬프트보다 커질 수 있었습니다. 새 clamp_cache_subsets 단계는 cache_read_input_tokens를 먼저 prompt_tokens로 제한하고 cache_creation_input_tokens를 그 나머지로 제한하며 None은 그대로 두고 아무것도 로그에 남기지 않습니다. 모든 형식에서 라우터 로컬 과금 대상 입력과 허브 레코드의 input_tokens는 그대로이고 과도하게 보고된 캐시 버킷만 줄어듭니다. 스트리밍 /v1/responses 계측 지점도 이제 TokenUsage를 직접 만드는 대신 from_json_value로 파싱해 같은 제한을 거칩니다.

v1.29.0 - 2026-09-21

Added

  • web_search.provider에 searxng를 추가해, 라우터가 관리하는 web_search 도구를 호스팅형 검색 API 대신 자체 호스팅 SearXNG 인스턴스로 실행할 수 있게 했습니다 (#1664). 기존 세 프로바이더(Serper, Exa, Brave)는 각각 상용 API 키와 공개 인터넷 경로가 필요한데, 에어갭 환경이나 정책 제한 배포에는 둘 다 없을 수 있습니다. base_url(#803)로 미러를 가리킬 수는 있었지만 다른 엔진을 가리킬 수는 없었습니다. 새 SearxngProvider는 build_provider 뒤에서 같은 SearchProvider 트레이트를 구현하므로 주입 루프, Anthropic web_search_20250305 에뮬레이션, 메트릭(provider="searxng"), 결과 정제는 그대로입니다. 자격 증명 없이 GET <base_url>?q=<query>&format=json을 호출하고 JSON results 배열의 각 항목(url, title, content; SearXNG는 이 중 어느 것이든 null로 직렬화할 수 있습니다)을 결과로 매핑하며, SearXNG에는 요청당 결과 수 파라미터가 없으므로 클라이언트 쪽에서 max_results로 잘라냅니다. 호스팅형 프로바이더와 다른 두 가지는 로드, 핫 리로드, config validate에서 강제됩니다. SearXNG에는 공개 엔드포인트가 없으므로 base_url이 필수이며 쿼리 문자열이나 프래그먼트가 없는 인스턴스의 /search 엔드포인트를 가리켜야 하고(둘 다 자체 쿼리 문자열을 붙이므로 Brave에 이미 있던 규칙과 같습니다), API 키 개념이 없으므로 api_key는 무시되고 전달되지 않습니다. 인스턴스 쪽 설정 두 가지가 흔한 실패 원인이라 그렇게 문서화했습니다. settings.yml의 search.formats에 json이 없는 인스턴스는 모든 검색에 HTTP 403으로 답하고, server.limiter: true는 라우터의 비브라우저 요청을 봇 트래픽으로 분류해 라우터 주소가 botdetection.ip_lists.pass_ip에 없으면 429로 답합니다. 둘 다 업스트림 상태 코드 그대로 드러나며(429는 Anthropic too_many_requests 코드로 매핑), 결과 0건인 200의 본문이 unresponsive_engines까지 보고하면 엔진 이름을 warn으로 남기고(엔진이 인터넷에 닿지 못하는 인스턴스는 모든 질의에 그렇게 답하므로), 기존 프로바이더를 지정한 설정은 이전과 똑같이 로드·검증·디스패치됩니다. 구성 어시스턴트 스킬 문서는 스키마 1.56.0으로 올라갑니다.
  • 서빙되는 모델이 받아들이는 reasoning_effort 어휘를 운영자가 선언할 수 있게 했습니다 (#1545). OpenAI 호환 와이어 어디에도 자체 호스팅 엔진이 어떤 레벨을 받는지 적혀 있지 않아, 라우터는 도출한 레벨을 백엔드 타입별로 하드코딩된 하나의 표기에 맞춰 보낼 뿐이었습니다(low, medium, high 이고, 자체 호스팅 엔진 타입에서는 high 와 xhigh 가 모두 high 가 됩니다). 그리고 그대로 전달했습니다. 그래서 어휘에 high 가 없는 백엔드는 high 를 요청한 클라이언트에 400 을 돌려주었고, 그 엔진이 무엇을 받는지 라우터에 알릴 방법이 없었습니다. BackendConfig 에는 effort 필드가 없었고, request_params 는 타입이 정해진 숫자 샘플링 파라미터 7개만 받는 닫힌 집합이며, 어떤 서빙 엔진도 모델별 허용 effort 집합을 문서화하지 않습니다. 이제 모델 항목의 선택 필드인 metadata.reasoning_efforts 배열이 그 모델이 받는 표기를 나열하고(minimal, low, medium, high, xhigh, max 순서입니다), 내장 OpenAI 모델 테이블이 이미 쓰는 것과 같은 최근접 탐색 및 동점 규칙으로 선언된 가장 가까운 레벨을 대신 사용합니다. max 와 minimal 은 라우터가 요청하지 않는 표기이므로 선언으로만 도달할 수 있습니다. none 은 레벨이 아니며 로드 시점에 rejects_reasoning_effort_none 을 안내하는 메시지와 함께 거부되고, 요청된 레벨에 아무 답도 하지 않는 선언(비어 있거나 피팅이 쓸 수 없는 항목만 담은 경우)은 아무것도 받지 않는 어휘가 아니라 선언이 없는 것으로 취급되므로, 핫 리로드된 설정이 그런 선언을 들고 있어도 운영자가 좁히려던 것뿐인 홉에서 effort 가 조용히 사라지지 않습니다. 선언은 선택된 백엔드의 model_configs 를 먼저, 공유 메타데이터 캐시를 그다음으로 조회하므로, model-metadata.d/ 드롭인으로 한 모델의 어휘를 그 모델을 서빙하는 모든 백엔드에 대해 선언할 수 있고 릴리스 없이 내장 OpenAI 테이블 항목을 덮어쓸 수도 있습니다. 선언은 모든 설정 기반 디스패치 지점에서 요청이 라우팅된 스냅샷으로부터 읽으며, 각 mid-stream 폴백 홉도 이에 포함됩니다. 타입 Gemini 변환은 백엔드 타입을 고정하고 설정을 들고 있지 않아 선언을 전혀 읽지 않습니다. 피팅은 시도마다 선택된 백엔드에 대해 한 번씩 실행되며, Chat Completions 와이어, Responses 와이어, 그리고 Anthropic 네이티브 thinking 을 /v1/chat/completions 로 보내는 클라이언트를 처리하는 디스패치 측 변환에 적용됩니다. 시도 단위이므로 폴백 홉은 자기 대상에 맞춰 피팅되고 이전 대상의 선언을 따르지 않습니다. Anthropic Messages 인그레스에서 피팅되는 값은 라우터 자신이 만든 값입니다. AnthropicMessagesRequest 에는 #[serde(flatten)] 캐치올이 없고 CanonicalParam 에도 reasoning 변형이 없어 클라이언트가 그 필드를 실을 수 없으므로, 이 값을 다시 쓰더라도 "클라이언트가 준 effort 는 절대 덮어쓰지 않는다"는 문서화된 불변식은 그대로 유지됩니다. 따라서 reasoning_effort 를 /v1/chat/completions 로 직접 보낸 클라이언트는 보낸 값을 그대로 유지합니다. OpenAI 와이어 직접 경로를 피팅하는 것은 진짜 클라이언트 값를 덮어쓰는 일이며 이번 변경이 내리지 않는 별개의 결정입니다. 선언된 어휘는 Gemini 자체의 VALID_REASONING_EFFORTS 와 교집합됩니다. 해당 백엔드의 변환이 피팅 뒤에 실행되어 그렇지 않으면 필드를 조용히 제거하고 요청을 Gemini 기본 effort 로 남겨두기 때문입니다. backends[].model_configs[] 의 선언은 알 수 없거나 중복된 항목을 가리키면 로드 오류이며, 이는 운영자가 작성한 다른 모든 백엔드 필드와 같습니다. 반면 공유 메타데이터 레이어는 경고에 그치는데, 내려받은 카탈로그가 리로드를 망가뜨릴 수 있어서는 안 되기 때문입니다. 선언을 설정하지 않으면 아무것도 달라지지 않습니다. 모든 페이로드가 이전과 바이트 단위로 동일합니다.

  • 백엔드별 request_extensions를 추가해, 운영자가 OpenAI 호환 애그리게이터의 라우팅 선호도와 출처 표기를 모든 클라이언트 요청에 싣는 대신 설정에서 한 번만 지정할 수 있게 했습니다 (#1631). 클라이언트는 이미 OpenRouter의 provider, models, transforms 필드와 HTTP-Referer/X-Title 헤더를 보낼 수 있었지만, 백엔드별로 조직 정책을 지정할 곳이 없었고 수정할 수 없는 클라이언트에는 정책을 적용할 방법이 없었습니다. request_params는 설계상 타입이 정해진 샘플링 파라미터 7개만 받는 닫힌 집합이고, BackendConfig에는 추가 헤더 필드가 없었기 때문입니다. request_extensions.body.defaults는 클라이언트가 설정하지 않은 키만 채우고(클라이언트 우선) request_extensions.body.overrides는 클라이언트 값을 대체합니다(설정 우선). 두 모드 모두 JSON 객체를 깊게 병합하며 배열, 스칼라, 타입 충돌은 해당 키에서 통째로 결정하므로, overrides.provider.data_collection: deny는 클라이언트의 provider.sort를 유지한 채 리프 하나만 강제합니다. request_extensions.headers는 같은 이름으로 전달된 클라이언트 헤더를 대체하는 헤더를 추가합니다. 조각은 모든 전송 지점에서 선택된 백엔드의 완성된 와이어 페이로드에 마지막으로 병합되며, 모든 재시도와 폴백 홉에 적용됩니다(다른 백엔드로 넘어가는 홉은 그 백엔드의 확장만 싣고 이전 백엔드의 확장은 싣지 않습니다). 적용 대상은 chat completions, completions, responses, embeddings이며 OpenAI 와이어 백엔드로 변환되는 Anthropic Messages와 Responses 인그레스도 포함합니다. 헤더는 모델 검색, 헬스 프로브, 사전 준비(pre-warm), 컨트롤 플레인 프로브, realtime 핸드셰이크에도 실립니다. #1652에서 이를 하나의 규칙으로 넓혔습니다. 백엔드의 자격 증명을 싣는 모든 요청은 그 백엔드의 확장 헤더도 싣습니다. 타입 백엔드 실행기를 거치는 가드레일 backend: 호출과 스마트 라우팅 분류기 호출, 프로바이더 배치 수명주기 호출, 엔진 통계 수집, 백엔드 타입 감지, Gemini 컨텍스트 캐시 관리, 타입 백엔드가 생성 시점에 수행하는 모델 자동 검색, llama.cpp /props 기능 감지, 타입 Anthropic 및 Gemini 연결 사전 준비도 이제 확장 헤더를 싣습니다. 두 전송 방식을 모두 쓰는 지점에서는 HTTP와 Unix 소켓 양쪽 모두에 적용됩니다. 설정 세대를 넘어 살아남는 소비자는 전송 시점마다 블록을 읽으므로 Admin 백엔드 수정이나 설정 동기화 변경이 다음 호출에 반영되고, 헤더만 바꾼 수정도 엔진 통계 폴링 태스크를 재시작시켜 다음 수집부터 새 값을 싣습니다. Admin 백엔드 생성의 타입 프로브는 후보 검증 전에 실행되므로 후보의 헤더가 이미 헤더 검증을 통과할 때만 붙이고 그렇지 않으면 헤더 없이 프로브합니다. 검증되지 않은 Authorization 확장이 와이어에서 키를 대체하지 못하게 하기 위해서입니다. Unix 소켓 헬스 프로브는 여전히 자격 증명도 확장 헤더도 싣지 않습니다. 검증은 모든 설정 수집 경로에서 로드 시점에 실행되고 오류에 경로를 명시합니다. anthropic, gemini, bedrock에서는 body가, SigV4 서명 디스패치라 추가 헤더를 실을 수 없는 Bedrock runtime/converse 백엔드에서는 headers가 로드 오류이고, 요청 식별 필드, stream, request_params 키와 그 별칭인 max_completion_tokens/max_output_tokens, 도구 및 출력 형태 필드, 응답 형태 필드인 text, logprobs, top_logprobs, encoding_format, dimensions, 라우터 관리 필드, 해당 백엔드에서 디스패치가 제거하는 키(Codex OAuth 백엔드의 stop 포함)는 예약되어 있고, 그 밖의 경우 stop, seed, logit_bias는 설정할 수 있습니다. 인증 계층과 라우터가 소유한 헤더 이름은 대소문자와 무관하게, 또 _를 -로 읽어 거부되며, 조각(16 KiB, 깊이 8), 헤더 수(16), 헤더 이름과 값의 크기에 한도가 있고 제어 문자는 거부됩니다. #1650에서 강화되어, background, include, include_reasoning, prompt_cache_key, safety_identifier(Responses), modalities, audio, prediction(Chat Completions), 레거시 Completions의 echo, vLLM의 prompt_logprobs, vLLM의 구조화 출력 필드 guided_json, guided_regex, guided_choice, guided_grammar와 이들을 대체한 vLLM 0.12의 structured_outputs도 위 필드들과 같은 이유로 예약되었습니다. 요청 내용이나 클라이언트가 해석하는 응답 형태를 바꾸기 때문입니다. service_tier, prompt_cache_retention, prompt_cache_options는 stop, seed, logit_bias와 함께 계속 설정할 수 있습니다. 헤더 값도 가시 ASCII(SP부터 ~까지)여야 하며, 그 범위를 벗어난 바이트가 있는 값(café나 한글 등)은 이제 로드 시점에 거부됩니다. 그렇지 않으면 와이어 클라이언트가 이를 RFC 9110의 obs-text로 보내고 업스트림과 중개 서버마다 처리 방식이 달라지기 때문입니다. request_extensions는 아직 어떤 릴리스에도 포함되어 출시된 적이 없으므로, 이는 마이그레이션이 아니라 같은 개발 주기 안의 강화입니다. #1631이 이 블록을 처음 추가한 이후로 작성된 설정이 지금 새로 예약된 본문 키 중 하나를 쓰거나 가시 ASCII가 아닌 헤더 값을 쓰고 있었다면, 이 빌드와 이후 빌드에서 로드에 실패합니다. 키를 제거하거나 헤더 값을 퍼센트 인코딩하십시오. 헤더 값은 ${ENV_VAR}를 지원하고 헤더 이름과 무관하게 비밀 값으로 취급됩니다. Admin 설정 및 백엔드 조회, 내보내기, 이력, WebUI에서는 이름은 보이되 값은 마스킹되고, 마스킹된 GET 후 PUT 왕복에서도 저장된 값이 유지되며, config diff와 MCP 도구에서는 가려지고, 로그에 기록되지 않으며, Continuum Hub에는 로컬 백엔드 내보내기의 request_extensions 미지원 필드 코드로만 보고됩니다. 변경은 재시작 없이 다음 요청부터 적용되고, 본문 조각이 있는 백엔드는 조각의 지문으로 응답 캐시 항목의 키를 정하므로 조각을 고친 뒤에는 이전 조각으로 저장된 항목이 응답하지 않으며, 이 필드가 없는 설정은 이전과 똑같이 직렬화되고 디스패치됩니다.

  • model_experiments를 추가해, 클라이언트가 보내는 하나의 모델 이름이 요청마다 여러 가중치 변형 중 하나로 해석되도록 했습니다 (#1634). 각 변형은 업스트림 모델을 지정하고 선택적으로 백엔드를 고정합니다. 해석은 dispatch_model_for_request 안에서 이뤄지므로 별칭을 디스패치하는 모든 인그레스가 같은 순서를 따르고, 할당은 호출자별로 고정되며 리플리카 사이에서 결정적이고, 귀속은 변형별로 기록되며, 한 갈래가 다른 갈래의 캐시에서 응답하는 일은 없습니다. 실험은 최대 32개이고 각각 최대 16개의 변형을 가집니다. sticky_key가 할당 키를 고르고(기본값 api_key, 그 밖에 user, none, header:<name>), sticky_ttl이 할당 수명을 60초에서 365일 사이로 제한하며, hide_variant_models는 변형 모델을 목록에서 감추고 직접 요청에는 일반적인 404로 답해 실험 트래픽 전용으로 남겨 둡니다.

  • GET /admin/control-plane/policy를 추가해, 허브가 마지막으로 보낸 정책이 아니라 라우터가 실제로 집행 중인 Continuum Hub 정책을 읽기 전용으로 볼 수 있게 했습니다 (#1451). 티어별 키 개수를 포함한 티어 한도, 치환 규칙, 동등 클래스, 라우팅 윈도우, 최적화 정책, 모델 예산 풀 상태, 허브로 보고되는 상태를 담습니다. 하나의 정책 스냅샷에서 필드 단위로 구성하므로 키 해시와 공급자 자격증명은 응답에 닿지 않고 키는 개수로만 나타납니다. /admin/control-plane/status 옆에 같은 control-plane 게이트 아래로 마운트되며, Integrations 페이지의 허브 정책 카드가 미동기화, 허브 연결 불가, 리비전 거부 상태를 표시합니다.

Fixed

  • rate_limiting.limits.per_backend를 선택 시점의 후보 필터로 집행하고, 기본값으로 켜서 배포하던 것을 중단했습니다 (#1682). 이 차원은 식별 허용 장착 지점이 평가했는데, 그 지점은 아무도 삽입하지 않는 RateLimitBackendName 요청 확장을 읽었으므로 모든 요청이 "고정된 백엔드 없음" 분기를 타고 한도는 한 번도 차감되지 않았습니다. 그런데 rate_limiting.limits를 설정한 적 없는 모든 배포에 기본값이 per_backend: 100 req/s, burst 200을 실어 두었으므로, 그저 동작하게만 만들었다면 업그레이드만으로 트래픽을 거절하기 시작했을 것입니다. 백엔드별 한도는 그 백엔드의 용량 제약이며, 따라서 거절 사유가 아니라 라우팅 입력입니다. 이제 공유 선택 심(seam)이 헬스 필터, 서킷 브레이커 필터 옆에서 버킷이 빈 백엔드를 후보에서 제외하므로 요청은 같은 모델을 서빙하는 다른 백엔드로 가고, 버킷은 디스패치 경계에서 한 번만 차감되므로 필터가 제외한 백엔드, 전략이 고르지 않은 백엔드, 폴백 홉이 떠나온 백엔드, 캐시 히트, 진 half-open 허용 경쟁은 모두 차감되지 않습니다. 모든 후보가 소진되었을 때만 요청을 거절하며, 그 429는 error.details.limit_type: "backend"와 버킷이 지킬 수 있는 retry-after를 실어 장애 시의 503 service_unavailable과 구별됩니다. 폴백 체인이 있으면 이 거절은 429 트리거이므로 체인이 여유 있는 백엔드를 가진 모델로 넘어가고, 그것으로 소진된 체인은 일반적인 소진 500 대신 429로 답합니다. 모든 디스패치 경로(chat, 스트리밍과 mid-stream 릴레이, Responses, Anthropic Messages와 count_tokens, 이미지, realtime, ACP)에 적용되고, 소스 감사 테스트가 어떤 경로도 차감 없이 서킷 허용을 열지 못하도록 고정하며, 허용 시점 경로(RateLimitBackendName, 그 읽기, 장착 지점별 플래그, 건너뜀 집계)는 필터 옆에 남기지 않고 제거했습니다. 기본값은 per_api_key와 같은 per_backend: None이 되어 어떤 배포의 실제 집행도 달라지지 않습니다. 이 차원은 이전에 한 번도 차감된 적이 없고, 이제는 운영자가 설정한 곳에서만 집행됩니다. storage: redis에서는 버킷이 복제본 간에 공유되고 Redis에 닿을 수 없으면 다른 차원과 마찬가지로 복제본의 메모리 버킷으로 내려가며, whitelist와 bypass_keys는 호출자를 허용 장착 지점에서 면제할 뿐 백엔드를 자기 용량에서 면제하지는 않습니다.

  • 스트리밍 Anthropic 사용량을 /v1/chat/completions 클라이언트와 라우터 자체 계정 집계에 OpenAI 형식으로 보고합니다 (#1640). 스트림 변환기가 message_delta.usage를 종료 청크에 그대로 복사했기 때문에, 클라이언트는 prompt_tokens, completion_tokens, total_tokens 없이 {"output_tokens": N}만 받았고, Anthropic이 message_start에서만 보고하는 입력과 캐시 수치는 아예 버려졌습니다. 그 결과 사용량 파서는 프롬프트 수를 찾지 못해 Prometheus, 관리 통계, 허브 TPM, 컨트롤 플레인 사용량 탭에 prompt_tokens: 0을 기록했고, Anthropic 프로바이더를 쓰는 Backend.AI GO 에이전트 실행은 스트리밍 입력을 전혀 계측하지 못했습니다. 이제 변환기는 message_start와 모든 message_delta의 수치를 누적하고(Anthropic이 누적 값을 보고하므로 나중 이벤트에 있는 필드가 앞선 값을 대체하되, 알려진 네 개의 카운터만, 그리고 그 값이 부호 없는 정수일 때만 유지하므로 나중 이벤트의 손상된 값이 앞선 카운트를 지우지 못합니다), message_stop에서 choices: []와 비스트리밍 변환이 쓰는 것과 같은 헬퍼로 매핑한 usage를 담은 사용량 전용 청크를 한 개 내보냅니다. 이는 OpenAI가 stream_options.include_usage에서 보내는 형식이자 Bedrock Converse가 이미 내보내던 형식입니다. 라우터 자체 탭이 이 청크를 읽으므로, 클라이언트가 stream_options에 무엇을 요청했든 청크는 항상 나갑니다. 종료 청크는 더 이상 사용량을 싣지 않습니다. 이 변경은 공유 변환기를 쓰는 모든 호출 경로에 적용됩니다. HTTP와 Unix 소켓의 네이티브 Anthropic, type: anthropic 백엔드, Bedrock endpoint_type: runtime과 mantle입니다. 캐시 히트에서 스트리밍 prompt_tokens는 이제 포함 방식 수치(캐시되지 않은 입력 + 캐시 읽기 + 캐시 쓰기)이고 prompt_tokens_details.cached_tokens가 함께 실리며, 이는 #1642가 비스트리밍 경로에 적용한 보고 방식과 같습니다.

  • /v1/responses 사용량 분해를 OpenAI Responses API 필드명인 input_tokens_details와 output_tokens_details로 직렬화합니다 (#1660). 라우터는 단수형 input_token_details와 output_token_details를 내보냈는데, OpenAI 스키마를 따르는 클라이언트는 이 이름을 찾지 않으므로 캐시된 토큰 수가 그런 소비자에게는 전혀 보이지 않았고, 캐시 입력을 따로 과금하는 소비자는 프롬프트 전체를 입력 단가로 청구했습니다. 이는 /v1/responses의 클라이언트에 보이는 필드 이름 변경입니다. 비스트리밍 본문, 응답을 실어 나르는 모든 스트리밍 이벤트(response.created, response.completed 등), 저장된 세션 모두 이제 복수형 키를 담고 이 라우터가 직접 직렬화하는 모든 본문에서 단수형 키는 사라집니다. 직렬화를 거치지 않는 두 가지 본문만 예외입니다. /v1/responses 캐시 히트는 저장된 바이트를 그대로 재생하므로 업그레이드 전에 기록된 항목은 만료될 때까지 단수형 키를 유지하고, 패스스루 본문은 업스트림이 보낸 그대로 전달되므로 옛 빌드의 업스트림은 단수형 키를 그대로 내보냅니다. 두 경우 모두 파서가 두 철자를 다 받아들이므로 계량에는 영향이 없습니다. 두 객체 내부의 멤버 이름은 그대로입니다. cached_tokens와 reasoning_tokens는 이미 OpenAI 스키마와 같고, 나머지 멤버(cache_write_tokens, text_tokens, image_tokens, audio_tokens)는 그 스키마에 없는 라우터 확장이기 때문입니다. 입력에서는 두 철자를 모두 계속 디코딩하므로 버전이 섞인 라우터 쌍은 양방향 모두 정상적으로 계량합니다. 새 라우터가 옛 피어의 단수형 본문을 읽는 경우, 옛 라우터가 새 피어의 복수형 본문을 읽는 경우(#1645에서 추가한 읽기가 이미 복수형을 우선했습니다), 업그레이드 전에 기록된 캐시·세션의 Responses 본문 모두 캐시 읽기를 입력 단가로 청구하지 않고 허브 레코드의 cached_input_tokens로 유지합니다. 이 라우터의 /v1/responses 사용량을 파싱하면서 단수형 키를 찾는 다운스트림 소비자는 수정이 필요하고, OpenAI 스키마를 따르는 소비자는 수정 없이 처음으로 이 분해를 보게 됩니다.

  • Continuum Hub 사용량 레코드가 캐시된 프롬프트 토큰을 두 번 청구하지 않도록 고쳤습니다 (#1642). 허브는 input_tokens와 cached_input_tokens를 겹치지 않는 버킷으로 과금하는데, 레코드는 백엔드의 prompt_tokens를 그대로 input_tokens로 보냈고 OpenAI 형식 응답에서는 이 값에 이미 prompt_tokens_details.cached_tokens가 들어 있어, 캐시된 토큰마다 입력 단가로 한 번, 캐시 단가로 또 한 번 청구되었습니다. 로컬 캐시 재생도 같은 방식으로 청구되었습니다. 이제 모든 생산자에서 prompt_tokens는 포함 방식의 프롬프트 수(캐시되지 않은 입력 + 캐시 읽기 + 캐시 쓰기)이고, 레코드에는 라우터 로컬 input_tpm과 월간 강제 적용이 이미 청구하던 것과 같은 과금 대상 입력인 input_tokens = prompt_tokens - 캐시 읽기가 실립니다. 제외 방식으로 보고하는 형식은 들어오는 지점에서 변환합니다. 사용량 파서는 Anthropic 네이티브 input_tokens에 cache_read_input_tokens와 cache_creation_input_tokens를 다시 더하고, 네이티브 /v1/messages 스트림 추적기는 캐시 쓰기도 수집하며, Anthropic과 Bedrock Converse의 /v1/chat/completions 변환은 포함 방식 prompt_tokens와 prompt_tokens_details.cached_tokens를 내보내고 thinking 블록이 없어도 최상위 Anthropic 캐시 키를 유지합니다. 눈에 보이는 결과는 두 가지입니다. Anthropic이나 Bedrock 캐시 히트에서 /v1/chat/completions 클라이언트가 받는 prompt_tokens가 이전보다 커지고, 그런 캐시 히트에서 라우터 로컬 입력 TPM과 월간 토큰 카운터는 이제 캐시되지 않은 입력과 캐시 쓰기를 청구하며(이전에는 이미 제외 방식인 Anthropic 수에서 캐시 읽기를 다시 빼서 청구가 0이 될 수 있었습니다), Prometheus 입력 토큰 카운터, 관리 통계, 모델 실험 토큰 합계, 용량 관측은 Anthropic과 Bedrock의 캐시 읽기와 쓰기를 프롬프트 토큰으로 셉니다. /v1/chat/completions의 Anthropic 스트리밍은 같은 릴리스의 #1640부터 프롬프트 사용량을 보고합니다. 반대 방향 브리지는 Anthropic 규칙을 따릅니다. /anthropic/v1/messages를 OpenAI 호환 또는 Responses API 백엔드가 서빙할 때, 비스트리밍 본문과 스트리밍 message_start/message_delta 사용량은 이제 input_tokens를 업스트림 프롬프트에서 캐시 읽기와 쓰기를 뺀 값으로 보고하므로(최상위 키뿐 아니라 prompt_tokens_details.cached_tokens와 input_tokens_details.cached_tokens도 읽음), 클라이언트나, 브리지된 본문을 직접 다시 파싱하는 이 라우터 자신(비스트리밍 응답, 자체 로컬 캐시 재생, 또는 스스로 재구성한 브리지 스트림)은 캐시된 프롬프트를 두 번 세지 않습니다. 캐시 히트 시 이런 대상은 더 작은 input_tokens를 보게 됩니다. 네이티브 /v1/messages 스트리밍 추적기는 이제 message_delta에서도 input_tokens, cache_read_input_tokens, cache_creation_input_tokens를 읽습니다(Anthropic Messages API가 누적 값을 거기에 반복하고, 브리지된 스트림은 message_start가 input_tokens: 0을 담기 때문에 그 값들을 거기로 미룹니다). 그래서 다른 라우터의 브리지된 스트림을 계측하는 라우터가 0 대신 실제 프롬프트를 기록합니다. Anthropic 백엔드가 서빙하는 /v1/responses는 비스트리밍과 스트리밍 경로 모두에서 Responses 규칙의 포함 방식 input_tokens와 input_tokens_details.cached_tokens/cache_write_tokens를 보고합니다(스트리밍은 이전에 Anthropic의 제외 방식 수 또는 0을 기록하고 두 캐시 버킷을 버렸습니다). OpenAI 호환 chat 백엔드가 서빙하는 /v1/responses는 스트리밍 경로에서 prompt_tokens_details.cached_tokens를 input_tokens_details.cached_tokens로 유지하고, 사용량 파서는 그 철자와 함께 #1660 이전에 이 라우터가 내보내던 단수형 input_token_details도 읽으므로, 다른 라우터의 /v1/responses 본문을 계측하거나 자체 캐시 본문을 재생하는 라우터가 캐시 버킷을 입력 단가로 청구하지 않고 유지합니다.

  • OpenAI 호환 /v1/models 카탈로그가 장식용 필드인 object와 owned_by를 생략해도 전체 카탈로그를 ModelFetchError::ParseError로 실패시키지 않고 디코딩하도록 고쳤습니다 (#1629). ModelsResponse.object, Model.object, Model.owned_by에 serde(default)가 없었기 때문에, 셋 다 보내지 않는 OpenRouter 같은 애그리게이터는 영구 파싱 실패로 분류되어 해당 백엔드가 채팅 트래픽은 정상적으로 처리하는데도 GET /v1/models가 영원히 비어 있는 상태로 남았습니다. 이제 object는 "list"/"model"로 기본값이 지정되고, 없는 owned_by는 빈 문자열로 디코딩되어 거부되지 않고 process_models_response의 owned_by 보정 로직까지 도달합니다(openai 백엔드는 "openai"로 채우며, vendor/model ID는 #1630에 따라 공급사 네임스페이스를 사용합니다). 설정되지 않은 선택 필드를 null로 직렬화하는 서버가 보내는 object, owned_by, created의 명시적 JSON null도 이제 필드가 없는 경우와 똑같이 디코딩되며, 더 이상 타입 오류로 카탈로그 전체를 실패시키지 않습니다. id는 여전히 필수이므로 id가 없거나 null인 항목은 여전히 디코딩에 실패합니다. 두 개의 엄격한 디코딩 지점(HTTP의 handle_successful_response와 유닉스 소켓의 handle_unix_socket_response)은 이제 decode_models_response 헬퍼 하나를 공유하므로, 앞으로 이 로직을 바꿀 때 한쪽 전송 방식만 고치고 다른 쪽을 놓치는 일이 없습니다. 이 필드들을 선택 사항으로 만들면서 카탈로그에서 허용되는 가장 작은 항목 크기도 36바이트에서 10바이트({"id":""})로 줄었고, 그래서 기본값인 10MiB max_response_size 이내의 응답 본문이 max_models_per_backend로 잘리기 전에 약 100만 개 항목으로 디코딩될 수 있었으며, 실측치로 fetch 1회당 최대 657MB를 사용했습니다(이전에 허용되던 가장 조밀한 카탈로그는 175MB였습니다). fetch_all_models는 전체 fetch 패스가 끝날 때까지 모든 백엔드의 결과를 들고 있으므로, 이런 백엔드가 여럿이면 사용량이 그만큼 누적됩니다. 이제 두 디코딩 지점 모두 디코딩이 끝난 뒤 잘라내는 대신 디코딩 도중에 항목 수를 제한해서 max_models_per_backend개까지만 남기고, 그 한도를 넘긴 위치에 잘못된 항목이 있으면 여전히 카탈로그 전체를 실패시키며, 같은 응답 본문의 피크 사용량은 3MB로 줄었습니다.

  • 애그리게이터 백엔드 모델의 공급사와 업스트림이 보고한 가격, 컨텍스트 윈도우, 출력 상한을 모델 카탈로그에 반영합니다 (#1630). OpenRouter 같은 OpenAI 호환 애그리게이터는 어떤 카탈로그 항목에도 owned_by를 보내지 않고 모든 ID에 공급사 네임스페이스를 붙이므로, process_models_response의 백엔드 타입 치환이 anthropic/claude-haiku-4.5부터 deepseek/deepseek-v4-flash까지 나열된 모든 모델을 owned_by: "openai"로 표시했습니다. 또한 라우터가 업스트림이 보낸 context_length, top_provider.max_completion_tokens, pricing을 무시했기 때문에 이 모델들은 모두 가격이 없고 컨텍스트 윈도우도 알 수 없는 상태로 보였습니다. 이제 업스트림이 vendor/model ID에 owned_by를 보내지 않으면 첫 번째 경로 세그먼트를 공급사로 사용합니다. 업스트림이 보낸 실제 값은 그대로 유지되고, OpenAI의 "system" 같은 명시적 플레이스홀더와 네임스페이스 없는 ID의 누락된 값은 기존처럼 백엔드 타입 소유자로 바뀝니다. 모델별 메타데이터의 owned_by는 계속 우선하며, response_defaults.owned_by는 여전히 플레이스홀더로 남은 값에만 적용됩니다. GET /v1/models는 OpenAI 필드 집합을 유지하고 공급사만 달라집니다. GET /v1/models/extended와 GET /v1/models/{model}에서는 context_length가 vLLM의 max_model_len과 함께 컨텍스트 윈도우 표기로 인식되고(둘 다 있으면 max_model_len 사용), top_provider.max_completion_tokens가 항목의 컨텍스트 길이 이내이면 limits.max_output과 그에 따른 max_tokens를 정하며, pricing.prompt/pricing.completion은 토큰당 USD에서 100만 토큰당 USD인 pricing.input_tokens/pricing.output_tokens로 변환되고 input_cache_read는 cached_input_discount가 됩니다. 업스트림 원본 필드는 확장 목록에서 계속 그대로 전달됩니다. 업스트림 값은 신뢰하지 않습니다. 가격은 100만 토큰당 100,000 USD 이하의 유한한 음이 아닌 숫자로 해석되어야 하므로, 가변 가격을 뜻하는 OpenRouter의 "-1"과 해석할 수 없는 값은 0이 아니라 가격 없음으로 게시됩니다. model-metadata.yaml, model-metadata.d/ 드롭인, 백엔드 model_configs, 내장 OpenAI 카탈로그에서 온 설정 메타데이터는 limits와 그 밖의 모든 필드에서 계속 우선하지만, 모델을 제공하는 공급자마다 다른 가격에서는 그렇지 않습니다. 업스트림 가격은 항목이 어떤 방식으로 매칭되었든 공유 model-metadata.yaml, 그 드롭인, 내장 카탈로그의 가격보다 우선하므로, vendor/ 접두사 제거로 매칭된 셀프 호스팅용 0/0 기본 제공 항목 때문에 minimax/minimax-m2 같은 모델이 무료로 보이는 일이 없어지며, 업스트림 가격보다 우선하는 것은 해당 백엔드의 backends[].model_configs에 설정한 가격뿐입니다. 여러 백엔드가 같은 ID를 제공하면 컨텍스트 윈도우와 출력 상한은 백엔드 간 최솟값을 게시하고, 가격은 백엔드마다 후보 하나(model_configs로 고정한 가격이 있으면 그 가격, 없으면 업스트림 가격)를 구성 요소별 최댓값과 가장 작은 할인율로 합칩니다. 두 값 모두 #1456이 max_model_len을 모으는 방식대로 중복 제거 중에 백엔드별로 수집되므로 풀 순서에 좌우되지 않습니다. 게시 가격은 백엔드들이 밝힌 가격 중 가장 높은 값이며, 가격이 없거나 "-1"인 백엔드는 합산에서 제외될 뿐 그 가격의 상한이 되지는 않습니다. 백엔드의 모델 검색이 실패해 목록에 유지되는 모델은 이제 갱신할 때마다 현재 설정과 유지된 백엔드별 보고로부터 메타데이터를 다시 계산하며, 더 이상 해당 ID를 제공하지 않는 백엔드가 반영되었을 수 있는 이전 갱신의 캐시된 컨텍스트 윈도우, 출력 상한, 가격을 다시 게시하지 않습니다(#1456의 컨텍스트 윈도우에도 적용됩니다). 확장 목록 전달 경로에 이미 있던 두 가지 위험도 함께 해결했습니다. 라우터가 직접 쓰는 키(backends, tier, domains, metadata; type: continuumrouter 업스트림의 자체 목록은 tier와 domains를 내보냅니다)를 담은 업스트림 항목은 하나의 JSON 객체에 같은 키를 두 번 넣었으나, 이제 이런 키는 전달 대상에서 제외됩니다. 업스트림의 metadata 객체는 라우터가 신뢰하는 메타데이터로 디코딩되어 업스트림이 검증되지 않은 가격을 게시할 수 있었고 형태가 맞지 않으면 전체 카탈로그가 실패했으나, 이제 디코딩하지 않습니다.

  • GET /v1/models/{model}와 GET /admin/smart-routing/model-profiles/{model}에서 /가 포함된 모델 ID를 받아들이도록 고쳤습니다 (#1647). 두 라우트 모두 단일 세그먼트만 매칭하는 axum 캡처로 등록되어 있어서, OpenRouter의 anthropic/claude-haiku-4.5나 vLLM의 HF 스타일 Qwen/Qwen3-32B 같은 네임스페이스 ID는 원문 형태로 조회하면 404가 났고 퍼센트 인코딩한 형태(anthropic%2Fclaude-haiku-4.5)로만 조회할 수 있었으며, 이를 PR #1644에서 우회 방법으로 문서화해야 했습니다. 이제 두 라우트 모두 axum 캐치올({*model})로 등록되어 원문 형태와 퍼센트 인코딩 형태가 동일한 응답을 반환합니다. /v1/models의 예약된 정적 세그먼트인 extended, proxy, refresh는 이전과 마찬가지로 캐치올보다 먼저 각자의 핸들러로 라우팅됩니다. src/http/middleware/model_extractor.rs에 새로 추가한 model_id_from_models_path 헬퍼는 axum이 단일 모델 핸들러에 넘기는 것과 같은 디코딩된 ID를 경로에서 추출하며, 이제 EnhancedRateLimitMiddleware::extract_model이 이 헬퍼를 사용합니다(마운트되지 않은 extract_model_middleware도 마찬가지입니다). 이전에는 속도 제한기가 디코딩되지 않은 원문 경로에서 path.split('/')[3]으로 읽었기 때문에, 네임스페이스 ID에서는 모델별 속도 제한 차원을 anthropic/claude-haiku-4.5가 아닌 anthropic으로 키를 매겼고 /v1/models/extended에서는 리터럴 extended로 키를 매겼습니다.

  • 요청 중복 제거를 각 요청이 실행되는 설정 스냅샷 단위로 한정합니다 (#1648). 중복 제거 키는 엔드포인트, 요청 본문, 인바운드 헤더만 해시했고 키가 맞으면 백엔드를 선택하거나 디스패치하기 전에 앞선 결과를 돌려주므로, 앞선 요청으로부터 retry.timeout 안에 반복된 바이트 단위로 동일한 비스트리밍 요청은 설정 게시를 가로질러 그 이전 응답을 재생했습니다. 즉 backends[].request_extensions, 백엔드 구성, 백엔드의 models 목록, URL, 자격 증명, selection_strategy를 바꾼 핫 리로드, Admin API 쓰기, 컨트롤 플레인 config-sync 설치, AppProxy 조정이 이런 반복 요청에만 반영되지 않았습니다. 이제 키에는 프로세스마다 무작위로 뽑는 솔트와 요청이 실행되는 Arc<Config> 스냅샷의 주소가 함께 들어가고, 모든 캐시 항목은 같은 할당에 대한 Weak 핸들을 보관합니다. 이 핸들은 그 주소를 키로 쓰는 항목이 살아 있는 동안 주소를 예약하면서도, 대체된 설정의 내용은 마지막 요청과 함께 해제되게 합니다. 게시된 하나의 스냅샷 안에서는 동작이 달라지지 않습니다. 동일한 요청은 여전히 하나의 업스트림 호출로 합쳐지고, 진행 중인 요청을 기다리던 요청도 그 결과를 그대로 받습니다. 라우팅과 무관한 변경만 담은 게시를 포함해 모든 게시가 이제 새로운 창을 시작하며, 비용은 게시마다 서로 다른 요청당 최대 업스트림 호출 한 번입니다. 스트리밍은 중복 제거 대상이 아니므로 영향이 없습니다. 만료된 항목은 이제 서버와 함께 시작되는 주기적 정리 작업이 회수합니다. 대체된 스냅샷을 키로 삼은 항목은 다시 조회되지 않으므로 조회 시점의 지연 만료 검사에 걸리지 않고, 이 정리 작업이 없으면 max_entries 도달 시의 축출만이 유일한 회수 경로가 되기 때문입니다. AppProxy 워커 모드와 ROUTER 모드에서는 실질적인 창이 retry.timeout보다 짧습니다. 조정(reconcile) 주기마다 변경 여부와 무관하게 설정을 다시 게시하므로 실질적인 창은 appproxy.reconcile_interval(기본 15초)입니다. 라이브러리 표면 변경입니다. 문서화된 serve_embedded 진입점 대신 공개 모듈 continuum_router::services::deduplication을 직접 쓰는 경우에 해당합니다. DeduplicationEntry에 공개 필드 config_snapshot: Weak<Config>가 추가되고, DeduplicationManager::{generate_request_hash, mark_in_flight, cache_success_with_backend, cache_error}와 EnhancedRetryHandler::execute_with_deduplication_attributed는 스냅샷을 새로운 첫 번째 인자로 받습니다.

  • 핫 리로드에 런타임 백엔드가 합류할 때 결합된 설정을 다시 검증합니다 (#1649). 기존 리로드는 파일만 검증하고, POST /admin/backends로 만들어 backends_persistence_file 사이드카에 보관한 백엔드를 병합한 결과는 검증하지 않았습니다. 그래서 다음 시작 시에는 거부될 설정이 유효 설정으로 발행될 수 있었습니다. 예를 들어 리로드된 tracing.headers 이름을 이미 런타임 백엔드의 request_extensions.headers가 사용하는 경우, 또는 이름이 바뀐 파일 백엔드가 런타임 백엔드의 backend_id를 중복하는 경우입니다. 이제 리로드 워커는 후보 설정을 구성한 뒤 발행 전에 공유 검증 게이트를 실행하며, 실패하면 기존의 재시도 후 유지 경로를 따르므로 이전에 발행한 리비전이 그대로 유지됩니다. 파일이 어떤 런타임 항목을 가리는지 기록하는 원장 쓰기는 발행 시점까지 미루므로, 거부된 리로드가 살아 있는 런타임 백엔드를 configured로 보고하게 만들어 다음 Admin 변경을 재시작 시 잃어버리는 일이 없습니다.

  • 모델 검색 로그 줄에 닿기 전에 업스트림이 작성한 텍스트를 이스케이프합니다 (#1651). 설정된 모든 백엔드의 /v1/models는 신뢰할 수 없는 입력이며, tracing-subscriber는 로그 메시지의 ANSI/ESC는 이스케이프하지만 raw \r/\n은 그대로 통과시키므로, id나 owned_by 값에 그런 문자가 있으면 두 번째 로그 레코드를 위조할 수 있었습니다. process_models_response와 models::aggregation의 열여덟 곳이 model.id/model.owned_by를 debug!/trace! 메시지에 그대로 보간했고, 클라이언트나 운영자가 전부 통제하지 않는 카탈로그를 내보내는 백엔드라면 오늘도 그대로 도달합니다. core::text_utils에 새로 추가한 두 헬퍼, escape_upstream_text(제어 문자와 U+2028/U+2029를 경계 내로 잘라 이스케이프)와 describe_upstream_json_error(같은 처리를 serde_json::Error 메시지에 적용하되 줄/열 위치는 유지)를 이런 자리마다, 그리고 /v1/models·/props 파싱 오류 경로 세 곳에 적용해 위조 경로와 파싱 실패 시 지나치게 큰 업스트림 값이 만드는 무제한 로그 증가를 함께 막습니다.

  • 네이티브 형태 사용량 변환 두 곳 모두에서 Gemini의 컨텍스트 캐시 읽기를 드러내도록 했습니다 (#1659). Gemini의 promptTokenCount는 OpenAI Chat Completions의 prompt_tokens와 마찬가지로 usageMetadata.cachedContentTokenCount를 포함한 값인데, 두 네이티브 형태 변환 어느 쪽도 캐시된 토큰 수를 드러내지 않았습니다. 그래서 캐시된 프롬프트 토큰마다 전체 입력 단가로 청구되었고 허브 cached_input_tokens는 계속 0으로 남았습니다. #1642이 OpenAI와 Responses 브리지에서 고친 것과 같은 종류의 과소 보고입니다. Gemini 호환 백엔드가 네이티브 candidates 형태로 응답할 때 /v1/chat/completions의 HTTP·Unix 소켓 릴레이가 적용하는 transform_native_gemini_response는 이제 prompt_tokens_details.cached_tokens를 내보냅니다. :generateContent와 :streamGenerateContent를 네이티브로 호출하는 유일한 인그레스인 /v1/responses의 GeminiConverter::extract_usage도 스트리밍과 비스트리밍 모두에서 이제 input_token_details.cached_tokens를 내보내고, 비스트리밍 통계·컨트롤 플레인 계측 지점이 이를 prompt_tokens_details.cached_tokens로 전달합니다. 그 결과 promptTokenCount: 100000, cachedContentTokenCount: 90000인 Gemini 응답은 100000 전부를 과금 대상 입력으로 기록하는 대신 과금 대상 입력 10000과 캐시 90000으로 기록되고, Anthropic input_tokens: 10000과 cache_read_input_tokens: 90000으로 브리지되며, 라우터는 포함 방식의 100000에서 캐시 읽기를 뺀 값을 계속 청구합니다. 아직 어느 실제 요청 경로에도 연결되지 않은, 향후 네이티브 라우팅 경로를 위해 준비해 둔 직접 Anthropic-Gemini 브리지(gemini_transform::transform_gemini_usage)도 같은 방식으로 고쳤고, 별도 구현을 다시 만드는 대신 bridged_input_usage를 재사용하도록 했습니다. 이 함수의 promptTokenCount/candidatesTokenCount를 잘라내던 두 as u32 캐스팅과 GeminiConverter::extract_usage에 있던 같은 캐스팅도 이제 #1642의 다른 모든 사용량 경로와 같은 saturating 변환을 씁니다. Gemini에는 캐시 쓰기 개념이 없으므로 모든 경로에서 cache_creation_input_tokens는 계속 비어 있습니다. /anthropic/v1/messages나 /v1/chat/completions에 응답하는 type: gemini 백엔드는 보통 Gemini 자체 OpenAI 호환 표면을 통해 연결되며, 그쪽은 이미 usage.prompt_tokens_details.cached_tokens를 보고하고 이미 올바르게 분리하고 있었으므로 그 경로는 그대로입니다.

  • chat template이 선두 system 메시지 하나만 받는 모델에 대해 요청의 system 메시지를 선두 메시지 하나로 합쳐, Claude Code가 Qwen3.8 백엔드에서 두 번째 턴부터 실패하지 않도록 했습니다 (#1663). OpenAI 호환 백엔드가 처리하는 /anthropic/v1/messages는 대화 중간 system 메시지를 지시가 system 우선순위를 유지하도록 system 롤로 제자리에 전달하고, OpenAI 인그레스는 클라이언트가 직접 배열 중간에 넣은 system 항목을 그대로 전달합니다. 모든 Qwen/Qwen3.8-* chat template은 첫 번째가 아닌 위치의 system 항목에 System message must be at the beginning.을 raise하고, vLLM은 이를 HTTP 400으로 응답하며, 라우터는 400 Invalid request sent to backend로 중계했으므로, 두 번째 턴부터 그런 메시지를 보내는 Claude Code는 그 백엔드에서 첫 턴 이후의 모든 턴이 실패했습니다. 이 검사는 엔진이 아니라 모델의 템플릿에 있으며, 첫 번째 바로 뒤에 놓인 두 번째 system 항목도 거부하므로 항목을 옮기는 것만으로는 부족하고 합쳐야 합니다. 제약이 모델과 함께 출하되므로 이는 모델 메타데이터입니다. 새 requires_single_leading_system 키를 내장 카탈로그의 Qwen3.8 계열(공개된 가중치로 확인한 qwen3.8-27b의 provisional 항목과 새로 추가한 qwen3.8-2.4t-a95b, qwen3.8-flash-next)에 설정했고, Qwen/Qwen3.8-27B-FP8 같은 HF 형식의 served id는 통상의 네임스페이스 접두 제거와 FP 포맷 접미 peel을 거쳐 그 항목에 닿으므로 Hugging Face 이름으로 모델을 서빙하는 배포는 설정이 필요 없습니다. 모델 이름을 직접 짓는 배포는 메타데이터 조회의 1순위인 백엔드 model_configs 항목에 키를 선언합니다. 이 키는 소스 간에 필드 단위로 해석되므로 키를 비워 둔 항목은 카탈로그 값을 물려받고, 명시적인 false만 자체 chat template로 띄운 엔진에서 카탈로그 기본값을 해제합니다. 등록되지 않은 모델은 보낸 그대로 전달됩니다. 키가 해석되면 그 모델의 /v1/chat/completions 페이로드의 모든 system 항목이 순서대로 빈 줄로 이어져 선두 system 메시지 하나로 합쳐지고, 나머지 항목은 상대 순서를 유지합니다. system 항목이 없거나 이미 선두에 하나만 있는 페이로드는 건드리지 않고, 이미지처럼 라우터가 평탄화할 수 없는 파트는 보존되어 병합된 내용이 파트 배열이 되며, 내용이 문자열도 파트 배열도 아닌 항목은 요청을 보낸 그대로 전달합니다. 병합은 클라이언트 대화를 실어 나르는 모든 /v1/chat/completions 송신 지점(OpenAI, Anthropic Messages(web_search 질의 추출 라운드 포함), Responses, ACP 인그레스)과 모든 폴백 홉에서 홉 자신의 백엔드와 모델 기준으로 해석되어 실행되므로 스트림 중 릴레이의 다음 홉은 원래 배열에서 출발합니다. 디스패치마다 게시된 설정과 메타데이터를 읽으므로 핫 리로드, Admin 백엔드 편집, config-sync 변경, 메타데이터 drop-in은 다음 요청부터 적용되며, 이 송신 지점을 거쳐 배열을 보내지 않는 anthropic, gemini, bedrock 백엔드 타입에는 아무 효과가 없습니다. 합치면 지시의 우선순위는 유지되지만 위치는 유지되지 않아 N번째 턴부터 적용될 지시가 처음부터 적용되므로 기본값이 아니라 모델의 속성입니다. 이 키가 없는 설정과 카탈로그는 이전과 정확히 같게 디스패치됩니다.

  • 네이티브 형태 Gemini 채팅 스트림에서 usage 청크를 내보냅니다 (#1671). transform_native_gemini_event는 usageMetadata를 아예 읽지 않았고, usageMetadata만 실은 이벤트는 이 함수에 도달하지도 못했습니다. 스트림 디스패치가 candidates 이벤트만 네이티브 경로로 보냈고 choices가 없는 이벤트는 그대로 전달했기 때문입니다. 그래서 네이티브 candidates 형태로 /v1/chat/completions에 응답하는 Gemini 호환 백엔드에서 스트리밍하는 클라이언트는 가공되지 않은 Gemini 객체를 받았고, 라우터는 프롬프트와 완성 토큰이 모두 0인 요청으로 기록했습니다. #1659이 추가한 캐시 입력 분리도 이 경로에서는 적용할 대상이 없었습니다. 이제 네이티브 경로는 스트림의 종료 이벤트에서 OpenAI usage 청크 하나를 덧붙입니다. choices: []와 함께 비스트리밍 변환이 쓰는 것과 같은 컨버터로 만든 usage 객체를 싣기 때문에, Gemini 컨텍스트 캐시 히트가 prompt_tokens_details.cached_tokens로 도착하고 스트리밍과 비스트리밍이 동일한 수치를 보고합니다. 이 청크는 usageMetadata를 실은 이벤트마다가 아니라 스트림당 한 번만 내보냅니다. Gemini는 네이티브 이벤트마다 누적 합계를 반복해서 싣고 사용량 레코드를 쓰는 계측 지점은 사용량으로 파싱되는 첫 청크만 남기므로, 이벤트마다 내보냈다면 첫 이벤트의 부분 완성 토큰 수가 완성 토큰 전체로 계측되었을 것입니다. 이벤트에 usageMetadata가 없는 스트림은 그대로입니다.

  • rate_limiting.limits.per_model 버킷을 클라이언트가 보내는 X-Model 헤더가 아니라 라우터가 요청에서 직접 해석한 모델 이름으로 선택하도록 바꾸고, 이 차원을 추론 요청에 처음으로 적용합니다 (#1670). 리미터는 X-Model을 다른 무엇보다 먼저 읽었고 그 값이 버킷을 결정했습니다. 이 헤더는 문서에 없었고, 라우터의 어떤 코드도 이 헤더를 설정하지 않았으며, 어떤 신뢰 경계도 걸러내지 않았습니다. 그래서 아무 호출자나(허용 모드에서는 익명 호출자도) 자신이 호출하지도 않은 모델의 전체 공유 버킷을 소진시키거나(GET /v1/models의 비용이 0.1이므로 피해 모델 이름을 실은 요청 10번이면 그 모델 버킷에서 토큰 하나가 빠졌습니다), 제한이 없는 모델 이름을 적어 설정된 제한을 통째로 건너뛰거나, 거절 응답의 x-ratelimit-* 헤더로 공유 버킷의 잔량을 읽어낼 수 있었습니다. 동시에 이 차원은 실제 추론 요청의 모델을 전혀 보지 못했습니다. 요청 본문을 읽는 코드가 없었기 때문에, 채팅 완성 요청은 클라이언트가 헤더를 자발적으로 보낼 때만 차감되었습니다. 이제 인증 안쪽, 리미터 바깥쪽에 새로 장착된 미들웨어가 GET /v1/models/{model}에서는 요청 경로로부터, /v1/chat/completions, /v1/completions, /v1/embeddings, /v1/rerank, /embed_sparse, /v1/responses, /v1/responses/compact, /v1/images/generations, /anthropic/v1/messages에서는 JSON 본문의 최상위 model 멤버로부터 이름을 해석합니다. 본문은 해당 경로들이 이미 적용하는 10MB 제한 안에서 한 번만 읽고 그대로 다음 단계로 넘깁니다. 버킷은 클라이언트가 보낸 그대로의 클라이언트 대면 이름을 정확히 일치시켜 사용합니다. 따라서 모델 실험의 클라이언트 대면 모델에 건 제한은 각 변형의 업스트림 용량이 아니라 실험 전체를 제한하고, model_aliases를 통해 두 개의 클라이언트 대면 이름으로 도달할 수 있는 업스트림 모델은 이름마다 항목이 필요합니다. multipart 이미지 편집·변형 경로와 /anthropic/v1/messages/count_tokens는 적용 대상이 아니며 문서에 그렇게 명시했습니다. 본문에 model이 없거나, JSON이 아니거나 형식이 깨졌거나, 읽기 한도를 넘는 요청은 손대지 않은 채 핸들러로 넘어가 핸들러가 자체 오류 형식으로 검증·거절하며, 각각 rate_limit_skipped_total{dimension="model"}에 집계됩니다. 이 차원은 지금까지 그런 경우를 아무 기록 없이 넘겼습니다. X-Model로 버킷을 선택하고 있던 운영자는 이제 모델 이름을 요청 본문이나 경로에 보내야 합니다.

  • 모델 목록 레이트리밋 로그에 호출자의 원본 자격증명 접두사가 기록되던 문제를 고쳤습니다 (#1669). extract_client_id가 Bearer 뒤 16자로 버킷 키를 만들었고 두 목록 핸들러가 그 값을 거절 warn!에 그대로 넣었기 때문에, 배포 기본값인 logging.level: info에서 모든 호출자 키의 안정적이고 벤더가 식별되는 접두사가 로그에 남았고 16자보다 짧은 키는 통째로 남았습니다. 이제 버킷 키는 API 키 id에서 파생됩니다. 이 저장소의 어떤 라우트도 두 핸들러를 마운트하지 않으므로, 유출은 활성 상태가 아니라 embed 소비자나 이후의 라우트 수정으로 닿을 수 있는 공개 라이브러리 핸들러 안에 잠복해 있었습니다.

  • --no-default-features --features redis-cache 그래프가 다시 빌드되게 했습니다 (PR #1623). src/infrastructure/cache/redis_store.rs가 RouterMetrics::global().kv_cache.record_redis_latency로 작업 지연을 기록하는데 metrics 없이 컴파일되는 no-op KvCacheMetrics 대체 구현에는 그 메서드가 없어 E0599 오류 세 개로 실패했습니다. 출하되는 어떤 그래프도 metrics 없이 redis-cache를 빌드하지 않아 아무도 잡지 못했습니다.

CI

  • 기능 없는(no-feature) 그래프에 쌓여 있던 rustc 경고 일곱 개를 정리하고 그 그래프에서 경고를 오류로 처리하도록 CI에 넣었습니다 (PR #1622). crates/continuum-kv-listener가 이 크레이트를 default-features = false로 의존하므로 릴리즈 워크플로의 KV 리스너 빌드는 라이브러리를 기능 없이 컴파일하고, 그 경고들이 Windows 릴리즈 로그에 드러났습니다. 각각은 #[cfg(feature = "metrics")] 소비자만을 위해 계산되던 값이거나 그런 소비자만 호출하던 헬퍼였습니다.

Dependencies

  • RUSTSEC-2026-0285 때문에 rustls를 0.23.45로 올렸습니다 (PR #1638). rustls 0.23.44는 암호화 레벨 경계를 넘어선 TLS 1.3 핸드셰이크 메시지를 받아들였습니다 (GHSA-2mjx-qc3c-rqvc). 루트 Cargo.lock과 crates/continuum-kv-listener/Cargo.lock이 움직이고, perf와 crates/continuum-protocol 잠금 파일에는 rustls가 없습니다. 소스 변경은 없습니다.
  • Cargo minor-and-patch 그룹을 toml과 rmcp 두 건으로 올렸습니다 (PR #1625).
  • 현재 semver 호환 의존성 집합에 맞춰 루트 Cargo.lock을 갱신했습니다 (9a389356).

v1.28.0 - 2026-09-11

v1.27.0 이후 46개 커밋이 KV 캐시 이벤트 수집을 처음부터 끝까지 완성하고, 엔진 부하와 호출자 신원을 라우팅 판단에 넣었으며, 공급자 호환성 결함들을 정리했습니다. 새 continuum-kv-listener 브리지가 vLLM, SGLang, 네이티브 TensorRT-LLM의 이벤트를 라우터 캐시 인덱스로 나르고, 라우터가 발급하는 cache_salt가 그 경로에서 detokenize를 걷어내며, Continuum Hub가 KV 라우팅을 동기화 설정 섹션으로 관리하고 되돌릴 수 있습니다. 스마트 라우팅에는 EngineLoad 선택 전략, 부하 평가에 반영되는 엔진 통계, 그리고 키 등급·조직·언어·클라이언트 헤더로 매칭하는 when 조건이 추가됐고, 규칙 분류기는 표기가 아닌 표현에서 코드 의도를 읽어 59건으로 늘린 라벨 데이터셋에서 도메인 정확도 100.0%에 도달했습니다. 폴백 체인은 Anthropic Messages와 Responses 인그레스에서도 홉별 타임아웃 배수와 함께 동작하고, 시맨틱 응답 캐시는 허브 게이트 뒤에 제공 경로를 갖췄으며, Anthropic·OpenAI·Google의 현행 플래그십 모델이 카탈로그에 들어왔습니다. Fable 5.1과 Mythos 5.1은 강제 도구 호출을 거절합니다.

Added

  • /anthropic/v1/messages와 /v1/responses에서 fallback.fallback_chains를 실행합니다 (#1609). 공급자 형태의 두 인그레스는 정확히 한 번만 디스패치했기 때문에, 클라이언트가 두 경로 중 하나로 보낸 모델을 키로 하는 체인은 발동하지 않았고, 기본 시도의 실패가 그대로 클라이언트의 응답이 되었으며, X-Fallback-* 헤더는 어떤 경로에서도 나타날 수 없었습니다. PR #1598은 이 두 인그레스에 대한 X-Original-Model 기준을 공허한 것으로 기록해야 했습니다. 이제 두 인그레스는 자체 시도별 선택, 승인, 네이티브 디스패치를 chat funnel이 쓰는 실행기인 FallbackService::execute_with_fallback_snapshot을 통해 실행하므로, 트리거 조건, max_fallback_attempts, 백엔드별 홉 다이얼 상한이 chat과 동일하게 적용됩니다. 기본 시도는 네이티브 와이어를 그대로 유지하고, 홉은 모델 이름만 바꾼 같은 타입 요청으로 선택된 백엔드의 backend_type에 따라 디스패치 시점에 변환되며, /v1/responses에서는 passthrough 전략과 모든 convert 전략을 포괄합니다. 각 시도는 만들어진 응답으로 판정합니다. 2xx는 시도를 확정하고, 연결 거부, 타임아웃, 알 수 없는 모델, 허용 가능한 백엔드 없음은 각자의 트리거 클래스로 홉하며(인그레스 오류 매퍼가 응답 extensions에 저장하는 마커로 전달되고 와이어에는 실리지 않습니다), 공급자 상태 코드는 그 코드가 trigger_conditions.error_codes에 있을 때만 홉합니다. 체인이 소진되면 클라이언트는 마지막 시도의 오류를 해당 인그레스 형식으로 받습니다. 스트리밍 arm은 첫 바이트 전에만 홉합니다. 공급자 핸드셰이크 전의 실패는 다음 체인 항목에서 네이티브 스트림을 다시 시작하고, 확정된 핸드셰이크는 홉하지 않으며, 스트림 중간 복구는 OpenAI 호환 와이어에 남습니다. 다섯 개의 X-Fallback-* 헤더는 notify_on_fallback을 존중하며 두 인그레스 모두에서 내보내고, X-Original-Model은 별칭 재작성 후에도 클라이언트가 요청한 이름을 가리키며(#1588), 폴백으로 처리된 요청은 chat과 같은 지점에서 supply 텔레메트리에 집계됩니다. docs/en/architecture/model-fallback.md와 docs/en/error-handling.md(및 docs/ko 미러)에 인그레스와 arm별 적용 범위를 명시했습니다.

  • smart_routing.routing_policies[].when이 요청의 내용뿐 아니라 요청을 보낸 주체로도 매칭합니다 (#1569). 기존 의미(필드 간 AND, 필드 내 OR, 첫 일치 우선, 평가 순서 불변) 아래 선택 필드 네 개가 complexity/domain/requires에 합류합니다. key_tier는 허브 tier id를, org는 API 키의 organization_id를, language는 짧은 BCP-47 기본 서브태그를, header는 헤더 이름과 허용 값의 맵을 매칭합니다. 이제 auto 별칭 하나로 유료 등급을 플래그십 모델로 보내고, 샌드박스 조직을 저가 등급에 고정하고, 한국어 요청을 그것을 잘 다루는 모델로 보내고, 클라이언트가 명시한 힌트를 존중할 수 있습니다. 모든 필드가 serde(default)이므로 기존 설정의 의미는 그대로이며, is_catch_all이 이 필드들을 세기 때문에 신원 필드만 설정한 조건은 더 이상 catch-all 경고를 만족시키지 않습니다. 부재는 fail-closed입니다. control-plane 기능이 없는 빌드는 키 등급을 해석하지 못하고 허브 키가 아닌 경우도 마찬가지이므로, when.key_tier 정책은 모든 요청에 매칭되는 대신 아무것도 매칭하지 않습니다. 인증되지 않은 요청은 조직이 없고, 글자가 없는 요청은 언어가 없어 language 절에 매칭되지 않으며, 검사했으나 판정할 수 없는 텍스트는 und를 갖습니다. when.header는 클라이언트가 통제하는 입력을 읽으므로, 로드된 정책이 참조하는 이름만 캡처하고, 값은 256바이트에서 잘리지 않고 버려지며(설정값으로 시작하기만 하는 더 긴 값이 매칭되면 안 되기 때문입니다), 어떤 값도 메트릭 레이블이나 로그 줄에 닿지 않습니다. 권한과 관련된 것은 key_tier나 org로 게이팅하세요. 등급은 항상 컴파일되는 새 KeyTierContext 요청 확장으로 핸들러에 도달하며 control-plane 강제 미들웨어만 이를 채웁니다(OptimizationDecision과 같은 패턴). 새 RequestIdentity는 HTTP 타입을 갖지 않고, 엔진이 로드된 정책이 읽는 헤더 이름을 미리 계산하므로 요청마다 그 외에는 아무것도 복사하지 않습니다. POST /admin/smart-routing/simulate는 payload 옆에 key_tier, org, headers를 받으므로 해당 등급의 키 없이도 등급 게이트 정책을 확인할 수 있고, GET /admin/smart-routing/policies는 새 필드를 보고합니다. config validate는 잘못된 when.header 이름과 빈 when.language 태그를 거부하고, 키 등급을 결코 해석할 수 없는 빌드에서 when.key_tier를 쓰는 정책에 경고합니다.

  • 코드 의도를 표기가 아니라 표현에서 읽습니다 (#1604). 코드 도메인은 전적으로 펜스나 인라인 코드 표기에 묶여 있어서, 분류기는 붙여넣은 코드 조각은 알아봐도 코드를 써 달라는 요청은 알아보지 못했습니다. "Implement the rate limiting logic for this REST API."에는 백틱이 없어 코드 신호가 발화하지 않았고 general이나 multilingual로 흘러갔으며, 그래서 domain: [code]에 걸린 모든 정책이 표기 없는 절반의 트래픽을 놓쳤습니다. 신호가 언어가 아니라 표기에 묶여 있었으므로 한국어에서도 같은 문장이 동일한 구멍을 가졌습니다. KeywordTable과 그 설정 대응물인 smart_routing.classifier.rule.keywords에 code 키워드 범주가 기존 넷에 합류하고, EN_CODE와 KO_CODE가 내장으로 배포되며 다른 목록과 같은 가산 방식으로 병합됩니다. 덕분에 영어 기술 어휘가 섞인 한국어 요청은 어느 쪽에서든 신호에 도달합니다. 이 신호는 하나가 아니라 서로 다른 두 개의 일치를 요구하는데, 이는 기존 관행이며(detect_analysis_markers도 이미 둘을 요구합니다) function이나 수정 같은 평범한 단어를 목록에 담을 수 있게 해 주는 장치입니다. 임계에 닿으려면 같은 요청에서 독립적인 두 번째 증거가 필요하기 때문입니다. 대신 동작 동사를 요구하는 방식은 시도했다가 기각했습니다. write가 "Write a poem about autumn leaves"에서 발화해 모든 창작 사례를 domain = code로 뒤집습니다. 목록이 동작과 대상을 섞는 이유는 각각이 홀로는 실패하기 때문입니다. 동작만으로는 창작 글쓰기를 가져가고, 대상만으로는 "What is a REST API?"를 가져갑니다. 항목 둘은 의도적으로 빼 두었고 그 부재를 테스트가 고정합니다. 단어 하나짜리 키워드는 단순 부분 문자열 일치라, logic은 biological과 technological 안에 있고 api는 capital과 rapid 안에 있습니다. 둘 중 어느 쪽이든 무해한 두 번째 일치와 함께 임계에 닿아 "Explain the biological function of mitochondria"를 코드로 분류했을 것입니다. API 사례는 대신 rest api가 맡고, 여러 단어 항목은 토큰 단위로 매칭됩니다.

  • 엔진 KV 캐시 이벤트를 kv_cache_index.event_sources[]가 이미 소비하는 백엔드별 SSE 스트림으로 바꿔 주는 독립 브리지 continuum-kv-listener를 추가했습니다 (#1561). KV 인지 라우팅의 4계층은 인덱스가 도입된 이래 백엔드마다 SSE 스트림 하나를 읽어 왔지만, vLLM과 SGLang은 KV 이벤트를 SSE가 아니라 ZMQ 위의 msgpack으로 발행합니다. 그래서 그 어댑터는 배포마다 직접 마련해야 했습니다. 리스너는 자체 맨 페이지(continuum-kv-listener(1))와 Debian 패키징을 갖춘 두 번째 바이너리로 배포됩니다. 백엔드마다 ZMQ 엔드포인트 하나를 구독하고, 블록 체인을 상한 안에서 추적하며, 각 체인의 텍스트를 엔진 자신의 /detokenize 엔드포인트로 복원하고, 접두사 해시는 라우터가 라우팅에 쓰는 것과 같은 extract_prefix_key 이음새로 계산합니다. 따라서 인덱스와 라우터는 관례가 아니라 구조상 같은 키를 씁니다. 리스너 하나가 플릿 전체를 담당합니다. /events/<backend_name>은 구독 중인 모든 라우터로 팬아웃되고, 재연결 시 Last-Event-ID로 보관된 이력을 재생하므로 라우터가 재시작한 동안 엔진이 보고한 블록을 잃지 않습니다. 시퀀스 간극이 생기면 해당 소스의 추적 상태를 초기화하며, 리스너가 더는 재구성할 수 없는 체인에 이후 블록을 귀속시키지 않습니다. 발행자 허용 목록, ZMQ bind/connect 모드, 체인 및 추적 블록 상한, 접두사 길이는 모두 설정 값이며, config.kv-listener.yaml.example이 배포 형태를 담고 있습니다. 라우터 쪽에서 달라지는 것은 event_sources[].endpoint가 가리키는 대상뿐입니다. 엔진 컨테이너에 별도의 HTTP 발행 포트가 더는 필요 없고, 문서상 엔드포인트는 http://vllm-1:8000/v1/kv_events에서 http://kv-listener.internal:7817/events/vllm-1로 바뀝니다.

  • continuum-kv-listener에 네이티브 TensorRT-LLM 소스를 추가했습니다 (#1563). engine: trtllm으로 선언한 소스는 ZMQ를 구독하는 대신 trtllm-serve의 POST /kv_cache_events를 폴링하고, created/stored/removed 레코드를 ZMQ 소스와 같은 처리기와 SSE 허브로 정규화합니다. 덕분에 라우터는 엔진과 무관하게 한 가지 스트림 형태만 소비합니다. 폴링 간격, 요청 타임아웃, 응답 본문 상한은 각각 제한되며 설정할 수 있고, 이벤트 ID로 중복 응답을 거르고 간극 이후의 낡은 상태를 정리합니다. trtllm-serve에는 공개 detokenize 엔드포인트가 없으므로 이 소스는 오직 라우터가 발급한 캐시 솔트로만 접두사를 해석합니다. 처음 사용할 수 있는 저장 블록은 소스의 백엔드와 일치하는 솔트를 지녀야 하고, 이후 블록은 그렇게 해석된 체인 정체성을 물려받으며, 솔트가 없거나 형식이 잘못됐거나 다른 백엔드용으로 발급된 체인은 접두사에 귀속시키지 않고 폐기합니다. cache_level: 0은 GPU로, 그보다 높은 값은 스토리지로 읽고, 저장 시 계층이 생략되면 GPU로 간주하며, 제거는 추적하던 계층을 유지합니다. URL은 명시적으로 허용 목록에 올린 호스트의 정확한 /kv_cache_events 경로여야 하고 자격 증명, 쿼리, 프래그먼트를 가질 수 없습니다. 리다이렉트는 따르지 않으며, 응답 본문과 토큰 ID, 캐시 솔트는 로그와 오류에 남지 않습니다. 이미 vLLM 호환 ZMQ 이벤트 형식을 발행하는 Dynamo 배포는 네이티브 HTTP 소스를 따로 두지 말고 ZMQ 소스를 계속 쓰는 편이 맞습니다.

  • 리스너가 detokenize 없이 KV 블록을 귀속시킬 수 있도록, 라우터가 발급하는 cache_salt를 기본 꺼짐으로 추가했습니다 (prefix_routing.salt_echo, #1562). 활성화하면 vLLM, SGLang, TensorRT-LLM 백엔드로 가는 접두사 라우팅 채팅 요청이 cr1:<backend>:<prefix-hash> 형태의 솔트를 싣고, 엔진은 저장하는 블록에 그 솔트를 그대로 돌려줍니다. vLLM은 extra_keys, SGLang은 metadata, TensorRT-LLM은 blocks에 담습니다. identity: salt_echo로 설정한 리스너 소스는 이벤트에서 접두사 키를 바로 읽으므로 수집 경로에서 /detokenize 왕복이 사라지고, TensorRT-LLM 블록을 귀속시킬 수 있는 유일한 방법이기도 합니다. 클라이언트가 보낸 cache_salt는 보존되며 덮어쓰지 않고, 라우터가 발급한 솔트는 선택된 백엔드 이름에 묶여 다른 백엔드로 재사용될 수 없으며, 기본값 false에서는 요청 본문이 이전 릴리스와 바이트 단위로 동일합니다.

  • control-plane 빌드에서 kv_routing을 허브가 관리할 수 있는 두 번째 설정 동기화 섹션으로 공개했습니다 (#1564). 기존 request_params 섹션과 나란히, 허브가 selection_strategy와 prefix_routing.* 리프, 그리고 제한된 kv_cache_index.* 라우팅 리프를 전달할 수 있습니다. 플릿은 라우터마다 파일을 고치는 대신 KV 인지 라우팅을 중앙에서 켜고, 점수 가중치를 조정하고, 되돌릴 수 있습니다. 이 섹션은 자체 스키마 버전과 구조화된 capability를 지니고, 스냅샷 항목은 값 없이 존재 여부와 다이제스트만 보고하며, control_plane.config_sync.kv_routing_immutable: true로 request_params_immutable과 같은 방식으로 섹션 전체를 로컬에 고정할 수 있습니다. 전달된 값은 명시적인 로컬 핀 아래에서 내구성 있는 설정 동기화 저장소를 거치고, 알 수 없는 경로, 범위를 벗어난 값, 비밀을 담은 이벤트 소스 엔드포인트는 적용하지 않고 거부합니다. 적용에 성공하면 기존 핫 리로드 watch 채널로 게시되므로, 유효 스냅샷이 재시작을 기다리지 않고 라이브 리로드 소비자에게 도달합니다.

  • 함수 tools와 reasoning effort를 함께 보낸 Chat Completions 요청을, 그 조합을 거절하는 OpenAI 모델에 한해 /v1/responses로 우회합니다 (#1547). OpenAI는 요청에 함수 도구가 있고 실제 적용되는 reasoning_effort가 none이 아니면 /v1/chat/completions에 400 invalid_request_error와 param: reasoning_effort로 응답하며, 라우터는 이를 그대로 전달했습니다. 그래서 모델 계열별로 effort를 고정하고 커스텀 base URL에도 Chat Completions 엔드포인트를 고정하는 클라이언트(n8n이 둘 다 합니다)는 이 모델들을 도구와 함께 전혀 쓸 수 없었습니다. responses_only 옆에 추가된 두 개의 모델 메타데이터 키가 이 업스트림 제약을 모델 항목의 데이터로 기술합니다. chat_completions_tools_require_none_reasoning은 Chat Completions 경로가 이 조합을 거절하는 모델을 표시하고, 선택적인 chat_completions_default_reasoning_effort는 요청이 필드를 생략했을 때 업스트림이 적용하는 effort를 기록합니다. 제약의 두 행을 가르는 것이 바로 이 값입니다(gpt-5.6 계열은 업스트림 기본값이 medium이라 생략해도 거절되고, gpt-5.4와 gpt-5.5는 생략하면 허용됩니다). 해석된 모델에 플래그가 있고, 요청에 비어 있지 않은 tools 배열이 있으며, 실제 적용될 effort가 거절 대상이면 그 요청 하나만 기존 responses_only 우회 경로를 통해 조합을 허용하는 /v1/responses로 보내고, 응답을 다시 Chat Completions 형태로 변환합니다. 스트리밍도 포함됩니다. 나머지는 모두 /v1/chat/completions로 직행합니다. 도구와 함께 보낸 reasoning_effort: "none"과 도구 없는 모든 요청이 여기에 해당하므로, 이미 정상 동작하던 경로의 비용과 지연은 달라지지 않습니다. 요청한 effort는 reasoning.effort로 전달되고 생략한 effort는 생략된 채로 남습니다. 라우터가 effort를 none으로 바꿔 쓰지 않는 것은 그렇게 하면 모델 동작이 조용히 달라지기 때문입니다 (#510). 두 키는 gpt-5.6-sol(별칭 gpt-5.6), gpt-5.6-terra, gpt-5.6-luna, gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.4-nano에 내장 OpenAI 레지스트리와 model-metadata.yaml 양쪽에 설정되어 출하되며, 모든 행은 2026-09-03에 실제 API로 측정했습니다. gpt-5.4의 mini와 nano 티어가 포함된 것은 실측에서 이들도 거절된다는 사실이 확인되었기 때문이고, gpt-5.2 이하는 조합을 허용하므로 손대지 않았습니다. 규칙이 계열 접두사 검사가 아니라 모델 단위 데이터인 이유가 여기에 있습니다. 평범한 메타데이터 키이므로, OpenAI가 제약을 푸는 날 릴리스를 기다리지 않고 model-metadata.d/ 드롭인으로 끌 수 있습니다. reasoning 검증은 여전히 우회 판단보다 먼저 실행되므로, 모델이 지원하지 않는 effort는 라우터 자체의 400이 되고 업스트림에 도달하지 않습니다. dispatch 로그 라인과 새 responses_bridge_total 카운터 모두 reason 레이블을 가져 tools_with_reasoning과 무조건 우회되는 responses_only_model을 구분하며, 백엔드 model_configs 항목이 이 모델들에 responses_only: true를 설정하면 continuum-router config validate가 경고합니다. 그 재정의는 우회 이전의 임시 조치이며, 도구 없는 평범한 대화까지 Responses API로 보내는 비용을 감수한다면 기존 릴리스에서의 완화책으로 여전히 유효합니다. 또한 내장 OpenAI 메타데이터가 모델이 선언한 별칭으로도 해석되므로, gpt-5.6이 내장 메타데이터를 전혀 찾지 못하는 대신 gpt-5.6-sol 항목의 기능 플래그를 상속합니다.

  • Anthropic, OpenAI, Google의 최신 주력 모델을 카탈로그에 추가했습니다. claude-fable-5-1과 claude-mythos-5-1, gpt-6-astra, 그리고 3.5부터 3.8까지의 Gemini Flash 계열(gemini-3.5-flash, gemini-3.5-flash-lite, gemini-3.6-flash, gemini-3.7-flash, gemini-3.8-flash)입니다. 각 항목은 공개된 컨텍스트 윈도우, 최대 출력, 지식 컷오프, MTok당 가격을 담고 있어 /v1/models 메타데이터와 smart routing 프로파일 추론, 비용 보고가 더 이상 이름 휴리스틱으로 되돌아가지 않습니다. 이 중 두 가지는 데이터 추가 이상입니다. gpt-6-astra는 내장 OpenAI 카탈로그에도 등록되어(새 gpt6_family 모듈), 네이티브 type: openai 백엔드의 /v1/models 탐색이 파서가 지어내는 기본값 대신 실제 1.05M 컨텍스트와 $10 / $50 가격을 읽습니다. 또한 chat_completions_tools_require_none_reasoning과 chat_completions_default_reasoning_effort: "medium"을 함께 싣습니다. OpenAI는 이 모델의 /v1/chat/completions에서 함수 도구를 거절하고, gpt-5.6 계열과 달리 reasoning_effort: "none" 자체도 거절하므로, 오류 메시지가 안내하는 "effort를 none으로 설정"이라는 해결책이 이 모델에는 존재하지 않고 도구를 실은 요청은 전부 /v1/responses로 우회합니다. 이 모델이 받는 effort 집합(low부터 xhigh, Responses API에서는 max까지)은 supported_reasoning_efforts에 등록해, none 요청이 업스트림 400이 아니라 라우터 자체의 400이 되도록 했습니다. claude-fable-5-1과 claude-mythos-5-1은 Anthropic 백엔드의 기본 모델 목록에 노출되며, 기존 기능 게이트는 Mythos 계열 이름과 파싱된 계열 버전을 기준으로 하므로 이미 두 모델을 모두 포함합니다. 프롬프트 캐시 읽기 가격은 Claude 계열의 다른 모델이 기본 입력가의 10%인 것과 달리 두 모델 모두 2.5%로 기록했습니다. Gemini 3.6, 3.7, 3.8 Flash 항목은 2027-01-01부터 적용될 MTok당 $1.50 / $7.50이 아니라 2026-12-31까지 유효한 프로모션 가격 $0.75 / $3.75를 기록합니다. 또한 이 세 모델은 Gemini supports_thinking_disable 목록에 의도적으로 넣지 않았으므로, 이들에 대한 reasoning_effort: "none"은 minimal thinking 레벨을 거절한다고 문서화된 모델로 전달되는 대신 모델 이름을 알려 주는 라우터 오류로 거절됩니다.

  • Claude Fable 5.1과 Claude Mythos 5.1에 대한 강제 tool_choice를 디스패치 전에 거절합니다. 업스트림이 400으로 답할 요청을 그대로 전달하지 않습니다. 두 모델은 강제 도구 호출을 없애서 {"type": "any"}와 {"type": "tool", "name": ...}이 tool_choice: type "tool" and "any" are not supported for this model.을 반환하며, auto와 none은 영향이 없고 disable_parallel_tool_use도 auto와 함께 그대로 동작합니다. Claude Fable 5와 Claude Mythos 5는 강제 도구 호출을 받으므로, 새 model_rejects_forced_tool_choice 게이트는 비활성 thinking 게이트처럼 Mythos급 이름만 보는 대신 계열 버전(>= 5.1)을 파싱합니다. 덕분에 이후의 Mythos급 마이너 버전과 별칭, 날짜 스냅샷, Bedrock 접두사 id가 자동으로 같은 판단을 받습니다. 검사는 강제 호출을 표현할 수 있는 세 인입 경로에서 모두 동작합니다. /v1/chat/completions 스트리밍과 논스트리밍(OpenAI의 "required"와 {"type": "function", ...}이 Anthropic의 any와 tool이 되는 경로), /anthropic/v1/messages, 그리고 Responses에서 Anthropic으로 변환하는 컨버터입니다. 마지막 경로는 비활성 thinking 검사와 달리 실제 요청이 도달합니다. Responses 클라이언트는 지금도 강제 호출을 표현할 수 있기 때문입니다. /anthropic/v1/messages/count_tokens와 Bedrock Converse 통과 경로는 비활성 thinking 검사와 같은 이유로 검사하지 않습니다. 400 본문은 모델 이름을 밝히고, Anthropic 표기로 번역한 값이 아니라 클라이언트가 실제로 보낸 표기를 되돌려 주며, 문서화된 해결책(auto + 도구를 지시문에 명시, 도구의 strict: true, 구조화된 출력)을 나열합니다. 라우터가 그중 무엇도 대신 적용하지 않는 이유는, 강제 선택을 auto로 낮추면 호출자는 도구 호출이 보장된다고 믿는 사이 모델이 평문으로 답할 수 있고 그 실패가 원인이 된 요청에서 멀리 떨어진 곳에서 드러나기 때문입니다.

  • selection_strategy: EngineLoad을 추가했습니다. prefix 키의 보유자만이 아니라 적격 후보 전체를 엔진이 보고한 큐 깊이로 순위 매깁니다 (#1581, #1447 후속). routing.engine_load 점수 항은 KV overlap scorer를 통해서만 선택에 닿기 때문에, prefix routing이 꺼져 있거나 prefix가 차가운 fleet은 라우팅 결정에 전혀 영향을 주지 못하는 엔진 통계를 모으고 있었습니다. 이 전략은 적격 후보 전부가 신선한 waiting_requests 값을 보고했고 격차가 균형 데드밴드를 넘을 때만 엔진 사실로 순위를 매기며, 그렇지 않으면 새로 추가한 핫 리로드 가능한 routing.engine_load.base_strategy에 위임하고 이유를 기록합니다(strategy_stale_fallback, strategy_hysteresis_hold). 후보 전부의 보고를 요구하는 것은 의도적입니다. 일부만 순위 매기면 불균형 구간의 모든 요청이 통계를 발행하는 백엔드로만 몰리고 발행하지 않는 쪽은 굶습니다. 전략을 지정하는 것 자체가 opt-in이므로 routing.engine_load.enabled를 읽지 않습니다. 대신 engine_stats.enabled가 false면 config validate가 경고하고, base_strategy: EngineLoad는 로드 오류이며 사용 지점에 두 번째 가드를 둬 프로그램으로 만든 풀도 재귀할 수 없습니다. 비교 로직 하나를 전략과 점수 항이 함께 쓰게 되어, 정확히 동점일 때의 승자도 HashMap 순회 순서에 좌우되지 않고 결정적입니다.

  • 엔진 쪽 kv_cache_usage와 waiting_requests를 smart routing 부하 판정에 반영해 티어 강등이 GPU 포화에 반응하도록 했습니다 (#1578). 라우터 로컬 카운터로는 이를 볼 수 없습니다. KV 캐시가 거의 찬 백엔드도 연결을 받고 헬스 프로브를 통과하며, 라우터 지연은 큐가 이미 쌓인 뒤에야 움직입니다. smart_routing.load_management.thresholds.{warning,critical}과 모든 tier_thresholds 재정의가 두 필드를 갖게 되며, 필드 단위로 opt-in이고 kv_cache_usage는 0.0에서 1.0으로 범위 검사합니다. 값은 신선한 스냅샷이 있는 백엔드들 사이에서 평균이 아니라 최대로 접습니다. 평균을 쓰면 유휴 백엔드 셋이 포화된 백엔드 하나를 가리는데, 임계값이 존재하는 이유가 바로 그 사고입니다. 신선도는 기존 EngineStatsStore 정의를 재사용하므로, engine_stats가 없는 배치는 새 임계값을 발동하지 않고 멈춘 poller는 오래된 판정 대신 침묵으로 degrade합니다. smart_routing_load_transitions_total에는 닫힌 네 값 집합에서만 오는 reason 레이블이 추가되고, GET /admin/smart-routing/load-state와 같은 스냅샷을 담는 status 엔드포인트, WebUI 패널이 강등 판단의 근거가 된 엔진 수치 세 개를 보여 줍니다. 값이 없을 때는 거짓 0이 아니라 null로 렌더링합니다.

  • Continuum Hub 최적화 게이트 뒤에서 의미 기반 응답 캐시 적중을 제공합니다 (#1580). 허브 결정은 #887부터 semantic_cache를 계산했지만 이를 소비하는 코드가 없었습니다. 이번 제공 경로는 유계 인프로세스 벡터 인덱스, 적격 요청당 운영자가 지정한 백엔드로의 임베딩 호출 한 번, 그리고 exact와 prefix 경로가 빗나가고 guardrail 입력 게이트를 통과한 뒤 실행되는 세 번째 /v1/chat/completions 조회로 구성됩니다. 파티셔닝이 보안 경계입니다. 다이제스트가 호출자 신원, 모델, 임베딩 모델, 응답 형태를 결정하는 모든 필드, 마지막 사용자 턴 이전의 모든 메시지를 접어 넣으므로, 서로 다른 API 키나 시스템 프롬프트나 도구 집합이 같은 항목을 공유하지 않습니다. response_cache.semantic은 항상 컴파일되고 존재하면 검증되며, 활성화 시 backend가 필수입니다. 서로 다른 임베딩 구현의 벡터는 비교할 수 없기 때문입니다. 조회는 guardrail 입력 게이트 뒤에서 실행되어 차단된 프롬프트가 임베더로 전송되지 않고, 임베더 호출은 embedding_timeout_ms로 유계이며 어떤 실패에도 miss로 degrade합니다. 운영자 임계값과 허브 유사도 어느 쪽도 유사도를 지정하지 않으면 임베딩 호출 전에 경로 전체를 건너뜁니다. 적중은 X-Cache: HIT와 X-Cache-Mode: semantic으로 답하고 continuum_response_cache_semantic_total{result}, continuum_response_cache_semantic_entries에 계상됩니다. 새 의존성은 없고 embed 기능 그래프도 그대로 통과합니다.

Changed

  • 동작 변경: /v1/realtime 모델 별칭을 realtime 자체의 퍼지 파이프라인이 아니라, 채팅·Anthropic Messages·Responses 인그레스가 쓰는 것과 같은 정확 별칭 규칙(proxy::alias_dispatch)으로 해석합니다 (#1589). resolve_audio_model은 find_matching_config_slice의 6단계 매칭 전체(날짜 접미사 제거, 양자화·포맷 벗기기, HuggingFace 접두사 제거, 와일드카드 별칭)를 돌렸고, 백엔드 model_configs보다 메타데이터 캐시를 먼저 확인했습니다. 문서화된 메타데이터 우선순위와 반대였습니다. 그래서 날짜나 양자화 접미사가 붙은 id 같은 퍼지 표기로 들어온 realtime 요청이 같은 표기로 채팅 경로에 도달했을 때와 다른 모델로 해석되거나, 채팅 경로였다면 거절됐을 표기가 해석되기도 했습니다. 이제는 정확한 id 또는 정확한 별칭, 그리고 그것의 models/ 접두사 표기만 해석되며, capability 조회도 canonical_catalog_id와 같은 순서로 메타데이터 캐시보다 백엔드 model_configs를 먼저 봅니다. 문자 그대로의 후보 조회가 비었을 때만 발화하던 요청별 재시도는 proxy::alias_dispatch의 카탈로그 기반 재작성(canonical_catalog_id와 decide_dispatch_model)으로 대체됩니다. 이는 설정된 models: 허용 목록이 아니라 살아 있는 카탈로그를 읽으므로, 백엔드가 models:에 별칭을 정규 id와 나란히 올려 두었지만 실제 탐색은 정규 id만 응답하는 "설정됐으나 제공되지 않는 별칭" 경우도 함께 고칩니다. 설정된 별칭이 언제나 스스로 후보를 만들어 냈기 때문에 옛 재시도로는 결코 닿을 수 없던 경우입니다.

  • 동작 변경: 클라이언트가 보낸 모델 이름으로 백엔드를 고르는 나머지 ingress에도 별칭에서 정본 id로의 디스패치 규칙을 적용합니다 (#1590). /v1/embeddings와 그 네이티브 Gemini 멀티모달 하위 경로, /v1/images/generations, /v1/images/edits, /v1/images/variations, /v1/responses/compact, /anthropic/v1/messages/count_tokens는 원래 이름을 그대로 조회하고 그대로 전달했습니다. 그래서 gemini-embedding-2-preview(gemini-embedding-2의 별칭)나 nano-banana-2(gemini-3.1-flash-image의 별칭)는 채팅에서는 동작하는 같은 별칭이 Google에는 404로 도달했습니다. 이제 각 ingress는 자체 키별 게이트 뒤, 백엔드 선택 앞에서 공통 proxy::alias_dispatch::dispatch_model_for_request를 호출하고, 요청 이름과 디스패치 이름을 쌍으로 들고 다녀서 재작성 이후에 발생하는 404나 403도 클라이언트가 보낸 이름을 답합니다. 어떤 백엔드든 문자 그대로 제공하는 이름은 여전히 건드리지 않으며, 어떤 별칭이 해석되는지에 관한 규칙은 바뀌지 않았습니다. 알아둘 결과가 둘 있습니다. /v1/embeddings는 키별 모델 검사를 디스패치 퍼널에 맡기지 않고 ingress에서 직접 수행하므로, 요청된 이름이 먼저 허용된 뒤에만 정본 id가 비어 있지 않은 allowed_models 목록에 추가됩니다. 그리고 이미지 편집과 변형 엔드포인트는 재작성 전에 허용 모델 이름의 고정 목록을 검사하므로, 별칭은 그 목록에 들어 있을 때만 두 엔드포인트에 도달합니다. ACP는 사용법과 아키텍처 문서가 약속한 대로 문자 그대로의 매칭을 유지합니다.

  • 동작 변경: OpenAI 형식 오류 본문과 SSE 오류 이벤트의 error.code를 HTTP 상태 정수 대신 문자열 또는 null로 내보냅니다 (#1610). OpenAI Error 스키마는 code를 anyOf [string, null]로 정의하고, openai-python은 이 필드를 construct_type으로 만듭니다. 이 함수는 타입이 맞지 않는 값을 그대로 돌려주므로 Optional[str] 주석 아래로 int가 흘러들어, err.code == "insufficient_quota"는 조용히 한 번도 맞지 않고 err.code.startswith(...)는 라우터가 정상으로 여기는 본문에서 예외를 냈습니다. 이제 ErrorDetail.code는 Option<String>이며, src/errors.rs의 표 하나가 모든 RouterError 변형을 안정적인 snake_case 코드(model_not_found, insufficient_permissions, rate_limit_exceeded, invalid_api_key, invalid_request, request_too_large, upstream_error, service_unavailable, timeout, internal_error)에 대응시킵니다. 업스트림 본문이 자체 문자열 코드를 실었다면 그 값이 표보다 우선하며, 이 경로로 malformed_function_call(#1592)이 메시지 본문 안에만 남지 않고 비스트리밍 본문과 스트리밍 오류 이벤트 양쪽에서 error.code로 클라이언트에 도달합니다. 상태 코드는 예전과 같이 HTTP 상태 줄에만 실립니다. 직접 본문을 만들던 경로(어드미션 503, 두 속도 제한기, Responses 핸들러, 인증 거부, Chat Completions SSE 오류 이벤트)는 모두 공용 엔벌로프 하나를 거치므로, 모든 /v1 오류에 message, type, param, code가 함께 실리고 param과 code는 생략 대신 null로 직렬화됩니다. Responses error 스트림 이벤트도 명세가 정의한 평평한 ResponseErrorEvent 형태로 바뀌어 code, message, param, sequence_number가 error 객체 안이 아니라 type 옆에 놓입니다. error.code를 숫자로 파싱하던 클라이언트는 HTTP 상태 줄을 읽어야 합니다.

  • 호환성 변경(동작): /anthropic/v1/messages 인그레스에서 output_config.effort 값 xhigh를 알 수 없는 값으로 처리하지 않고 그대로 받습니다. xhigh는 Anthropic Messages API가 정의하는 다섯 수준 중 하나이며 Claude Code가 기본으로 보내는 값인데, 두 인그레스 변환 어디에도 그 항목이 없었습니다. src/http/handlers/anthropic/transform.rs의 transform_thinking_with_effort와 src/http/handlers/anthropic/responses_transform.rs의 map_effort_string 모두 max, high, medium, low만 매칭하고 나머지는 _ 갈래로 보냈으며, 그 갈래는 Unknown output_config.effort value 경고를 남기고 high로 대체합니다. 그래서 xhigh를 요청한 클라이언트는 한 단계 내려간 값을 받았고, 흔적은 라우터 자체 로그의 경고뿐이었습니다. 어휘에 high가 없는 백엔드에서는 이 강등이 처리 가능한 요청을 400으로 바꿨습니다. xhigh, medium, low를 받는 Qwen3.8 FP8 템플릿을 서빙하는 vllm 백엔드에서 관측한 결과입니다.

POST /anthropic/v1/messages  {"thinking":{"type":"adaptive"},"output_config":{"effort":"xhigh"}}
DEBUG Unknown output_config.effort value effort="xhigh"
DEBUG Mapped output_config.effort to reasoning_effort anthropic_effort="xhigh" reasoning_effort="high"
400 Invalid request sent to backend

항목을 추가하면 같은 요청이 xhigh를 xhigh로 매핑하고 200을 반환합니다. 이제 두 변환 모두 xhigh를 xhigh로 매핑합니다. 다른 수준들이 이미 쓰는 것과 같은 표기입니다. _ 갈래와 경고는 그대로여서 실제로 인식할 수 없는 문자열은 여전히 high로 떨어지며, max를 xhigh로 올리는 처리도 바뀌지 않았습니다. 마이그레이션: 어휘에 xhigh가 없는 백엔드는 이제 기존의 high 대신 xhigh를 받고, 그 필드를 검증한다면 거부합니다. 예를 들어 low/high/max만 받는 vLLM 채팅 템플릿은 400을 냅니다. 대체 처리가 가려 왔던 어휘 불일치가 드러나는 것이므로, 해당 백엔드가 받는 수준을 보내거나 라우터가 이미 맞춤 처리하는 모델 항목을 주어야 합니다. 모델별로 맞춰지는 대상은 영향이 없습니다. Gemini는 validate_reasoning_effort로 여전히 xhigh를 high로 낮추고, 교차 공급자 폴백 홉도 #1530이 남긴 대로 선택된 대상에 수준을 맞춥니다.

  • 응답하지 않는 백엔드 하나가 모든 모델 목록 갱신에 전체 재시도 예산을 물리지 않도록 팬아웃 꼬리를 제한했습니다 (#1553, #1548과 #1551이 미뤄둔 후속 작업). ModelFetcher::fetch_all_models는 백엔드마다 퓨처 하나를 만들어 모두 기다리므로 갱신 비용은 가장 느린 백엔드의 비용과 같았고, /v1/models에 응답하지 않는 백엔드는 갱신마다 (max_retries + 1) × request_timeout + max_retries × retry_delay, 기본값으로 16초를 소비했습니다. 이 16초는 #1548과 #1551의 모든 수렴 시간 한계의 단위였습니다. 이제 페처는 갱신 사이에 백엔드별 실패 기록을 유지합니다. 집계 서비스가 백그라운드 재검증마다 페처를 복제하므로 이 기록은 Arc로 공유됩니다. 직전 갱신에서 일시적 오류(타임아웃, 연결 실패, 5xx)로 실패한 백엔드는 다음 갱신에서 request_timeout 한 번만 시도합니다. 빠른 백엔드 세 개와 연결은 받되 응답하지 않는 백엔드 하나로 측정한 결과, 첫 갱신은 여전히 16.02초, 그 이후 갱신은 매번 5.00초입니다. 시도당 타임아웃은 의도적으로 바꾸지 않았으므로 다시 응답하는 백엔드는 첫 성공에서 곧바로 전체 예산을 되찾고(복구 후 한 번의 갱신 안에), 느리지만 동작하는 백엔드가 절대 맞출 수 없는 타임아웃에 갇히는 일도 없습니다. 이력이 없는 백엔드는 항상 전체 예산을 받으므로 프로세스의 첫 갱신과 새로 추가된 백엔드의 첫 갱신은 잘리지 않습니다. 한 갱신에서 모든 백엔드가 실패하면 한 번만 시도한 백엔드들이 저장 전에 남은 예산을 다 쓰므로, 일시적인 전체 장애가 빈 목록으로 발행되지 않으며 이 경우 갱신 비용은 이전과 같은 16초입니다. 짧게 잘린 백엔드는 일반적인 일시적 실패입니다. failed_backends에 들어가고 model_transient_errors_total에 집계되며, #1422의 유지 로직으로 마지막으로 알려진 모델을 그대로 보존하고, 비정상으로 표시되지 않습니다. Unix 소켓 백엔드도 같은 예산을 따르고, Admin 검색 진입점은 전체 예산을 유지하며, Slow model fetch from backend 로그 라인에 budget_attempts가 추가되었습니다. 새 설정 필드는 없으며 기준은 실패한 갱신 한 번입니다.

  • 풀 멤버십 변경과 캐시 무효화 사이의 순서 계약을 강화하고, 대체된 모델 갱신의 비용을 제한합니다 (#1552, #1548 후속). 저장 시점의 멤버십 비교는 epoch 비교와 달리 풀 변경과 직렬화되어 있지 않아서, 비교와 삽입 사이에 추가된 백엔드가 있으면 추가 전 목록이 soft TTL 내내 Fresh로 게시될 수 있었습니다. 이제 삽입 뒤에 멤버십 세대를 다시 읽고, 바뀌었으면 새 ModelCache::expire_entry로 항목을 그 자리에서 만료시킵니다. 이 프리미티브는 저장을 다시 돌리지도, 무효화 epoch를 올리지도 않습니다. 핫 리로드는 풀을 바꾼 직후, 즉시 실행하는 헬스 체크 라운드 트립보다 먼저 모델 캐시를 무효화하므로 두 스탬프가 await 없이 함께 움직이고, 한 번의 리로드가 진행 중인 팬아웃에 두 번이 아닌 한 번의 대체로 도달해 리로드마다 팬아웃 한 번을 절약합니다. 팬아웃은 설정의 enabled 집합으로만 필터링하고 헬스 상태는 보지 않으며 /v1/models의 가용성은 요청마다 계산되므로 순서 변경은 안전합니다. 대체된 재검증의 같은 자리 재시도는 패스 사이에 집계 잠금을 풀고 200ms에서 시작해 재시도마다 두 배가 되는 지터 섞인 백오프를 두므로 연속 보유 구간 하나가 정확히 팬아웃 하나이고(패스 뒤에 대기하던 POST /v1/models/refresh나 stale-serve 범위를 넘은 읽기가 체인 전체가 아니라 다음 차례에 잠금을 얻습니다), 다시 잠금을 잡을 때 캐시를 재확인해 그 사이에 다른 작업이 게시한 목록을 다시 집계하지 않습니다. 세 패스가 모두 대체된 체인은 항목을 만료 상태로 남기고 다음 재검증을 2초의 재가동 간격 동안 붙잡아 두어, 지속적인 풀 변동을 끊기지 않는 체인이 아닌 제한된 팬아웃 묶음으로 바꿉니다. 이 간격은 첫 대체에는 적용되지 않아 후속 갱신은 여전히 즉시 시작하고, 어느 경로에서든 Fresh 저장이 일어나면 해제됩니다. model_background_refreshes_total은 같은 자리 재시도를 포함해 팬아웃마다 다시 증가하므로 이를 팬아웃 수로 읽는 대시보드가 대체 상황에서도 맞고, model_background_refresh_successes_total과 _failures_total은 재검증마다 1로 유지되며 두 메트릭 문서 모두 그렇게 적었습니다.

Fixed

  • fallback.fallback_policy.fallback_timeout_multiplier를 모든 폴백 홉의 자체 타임아웃에 실제로 적용합니다 (#1615). 이 설정은 파싱되고, 1.0-5.0 범위로 검증되고, Admin 스키마에 노출되고, FallbackPolicy에 저장되고, 설정 템플릿 세 개와 config.yaml.example에 1.5로 실려 있었고, 시도 자체의 타임아웃을 스케일한다고 문서화되어 있었지만, 어떤 요청 경로도 이 값을 읽지 않았습니다. calculate_timeout에는 테스트 밖 호출자가 없었고, 그래서 모든 홉이 기본 시도의 타임아웃 그대로 동작했으며 값을 올린 운영자는 아무 효과도 얻지 못했습니다. 이제 폴백 실행기가 이미 유지하던 시도 번호가 네 개의 드라이버로 전달됩니다. 실행기 클로저가 세 번째 인자로 받고, IngressAttempt가 실어 나르며, 스트림 시작 전 연결 루프는 자신의 홉 카운터에서 유도합니다. 각 드라이버는 공유 헬퍼 하나로 기준값을 스케일합니다. 기본 시도는 attempt 1로 변경되지 않고, 홉 n은 base * multiplier^(n-1)로 동작하며 부팅 시 고정된 timeouts.limits의 해당 종류 상한으로 클램프되므로 체인을 통해 설정 자체가 허용하지 않는 값에 도달할 수는 없습니다. 기준값은 체인이 없었다면 그 시도가 사용했을 값입니다. OpenAI 호환 비스트리밍 경로는 모델별 timeouts.request.standard.total(또는 이미지 생성 total), 스트림 시작 전 연결 단계는 시도별 스트리밍 창, /anthropic/v1/messages는 모델별 프로필의 total과 첫 SSE 바이트 데드라인, /v1/responses는 평평한 standard 또는 streaming total입니다. timeouts.connection, 폴백 다이얼 퍼밋 대기, 홉 내부 재시도, 클라이언트가 이미 스트림을 받고 있는 상태에서의 스트림 중간 홉은 의도적으로 스케일하지 않습니다. 이 스케일링은 timeouts.streaming_fallback_budget_multiplier와 곱해지지 않고 함께 적용됩니다. 스트리밍 연결 단계가 홉의 창을 먼저 스케일한 뒤 공유 체인 예산의 남은 분량으로 다시 상한을 걸기 때문입니다. 기본값 1.5를 그대로 둔 배포는 이제 첫 홉이 이전보다 1.5배 긴 창을 갖게 되며, 이는 그 배포가 이미 설정해 둔 문서상의 동작입니다.

  • 응답 캐시 호출자 식별자에 허브 키 id를 결합해 두 허브 키가 정확 일치 캐시나 프리픽스 캐시 항목을 절대 공유하지 않도록 합니다 (#1579). api_keys.mode: permissive에서는 허브 키 요청이 AuthContext 없이 로컬 인증을 통과하므로 라우터의 모든 허브 키에 대해 로컬 캐시 식별자가 빈 문자열이었고, 정확 일치 캐시와 프리픽스 캐시가 모든 허브 키에 걸쳐 하나의 namespace를 공유했습니다. 키 B가 키 A의 캐시된 결정적 완성 응답을 받았고, 키 B가 저장한 항목이 나중에 키 A에 서빙되었습니다. #1571에서 추가한 시맨틱 캐시는 이미 파티션에 허브 키 id를 결합하고 있었지만 /v1/chat/completions(스트리밍과 비스트리밍), /v1/responses, /anthropic/v1/messages의 정확 일치와 프리픽스 경로는 그렇지 않았습니다. 이제 캐시 namespace의 호출자 절반은 proxy::cache_identity::ResponseCacheCaller 하나의 타입이며, 모든 ingress는 캐시 키를 만들기 전에 제시된 토큰과 허브 결정으로 이 타입을 만들어야 하므로 나중에 추가되는 핸들러도 이 결합을 건너뛸 수 없고, 시맨틱 캐시도 자체 조회 대신 같은 식별자를 사용합니다. 토큰이 더 이상 허브 키로 풀리지 않는 허브 키 요청은 공유 namespace로 떨어지는 대신 아예 캐시하지 않습니다. 로컬 키와 익명 permissive 호출자는 식별자를 바이트 단위로 그대로 유지하므로 단일 테넌트 배포는 이전과 같은 캐시 키를 보며, 로컬 인증도 인식하는 허브 키(blocking 모드)는 그 위에 id가 결합되어 업그레이드 후 한 번 캐시가 비워집니다.

  • Gemini의 MALFORMED_FUNCTION_CALL 턴을 성공으로 전달하지 않고, 모든 Gemini finish_reason을 OpenAI 집합으로 고정합니다 (#1592). Google은 간헐적으로 HTTP 200에 content도 tool_calls도 없는 assistant 메시지, completion_tokens: 0, 그리고 자체 finish reason을 실어 답합니다. 2026-09-10 측정 기준 도구 하나를 붙인 gemini-3.1-pro-preview에서 2회 중 1회, 5회 중 1회였습니다. 클라이언트는 {"finish_reason": "function_call_filter: MALFORMED_FUNCTION_CALL", "message": {"role": "assistant"}}를 받았고, 이는 문서화된 다섯 값으로 분기하는 SDK를 깨뜨리고, 에이전트를 아무것도 없는 도구 턴에서 반복하게 만들며, content를 빠뜨려 Chat Completions 형태를 위반합니다. 이제 두 표기(네이티브 API의 맨 이름, OpenAI 호환 엔드포인트의 function_call_filter: 라벨 뒤 형태)를 map_gemini_finish_reason, 두 스트리밍 경로, 비스트리밍 응답 변환이 공유하는 테이블 하나가 인식하고, 그 결과를 CoreError::upstream_status를 통한 재시도 가능한 업스트림 실패(502, type: upstream_error, 메시지에 malformed_function_call 명시)로 바꿉니다. 그래서 재시도 루프와 설정된 fallback.fallback_chains 홉이 동작합니다. 관측된 모든 사례에서 재시도가 성공했고, 깔끔한 stop으로 답하면 아무것도 생산하지 못한 채 프롬프트 토큰만 쓴 턴을 숨기게 됩니다. 스트리밍 요청에서는 응답이 커밋된 뒤에 감지되므로 상태 코드가 아니라 SSE 오류 이벤트로 전달됩니다. 이로써 모든 Gemini 경로에서 두 가지 형태 보장이 성립합니다. finish_reason은 항상 stop, length, tool_calls, content_filter, function_call 중 하나입니다. 공유 테이블이 인식하지 못하는 값을 그대로 통과시키지 않고 stop으로 줄이기 때문입니다. 그리고 assistant 메시지에는 항상 content 키가 있으며, null은 tool_calls가 함께 있을 때뿐이고 그 외에는 빈 문자열입니다.

  • Gemini 채팅 완성 변환을 stream: true일 때만이 아니라 비스트리밍 경로에서도 실행합니다 (#1591). 요청 변환이 스트리밍 디스패치에만 있었기 때문에, 같은 /v1/chat/completions 요청이 스트림을 요청하면 extra_body.google.thinking_config.include_thoughts: true와 max_completion_tokens: 16384를 달고 Google에 도달했고, 그렇지 않으면 클라이언트 본문 그대로 전달됐습니다. 비스트리밍 클라이언트는 thinking 모델에서 reasoning_content를 전혀 받지 못했고, Gemini의 낮은 암묵적 출력 상한에 걸렸으며, reasoning_effort: "xhigh"가 high로 다운그레이드되지 않고 그대로 전달됐고, 지원되지 않는 effort에 대한 라우터의 400도 스트리밍 쪽에만 있었습니다. 이제 프록시 경로도 transform_request_gemini 다음 strip_non_openai_fields를 실행합니다. GeminiBackend::transform_request가 쓰는 것과 같은 짝, 같은 순서이며, 최상위 strip이 건드리지 않는 extra_body를 만들어내는 context-cache와 cache-salt 주입보다 앞에서 돕니다. 주입만 켜면 마크업이 새어 나갔을 것입니다. Google이 thought 요약을 message.content 안에 <thought>...</thought>로 감싸 넣고 extra_content.google.thought: true로 표시해 반환하는데 이를 벗겨내는 곳이 스트림 변환기뿐이었기 때문입니다. 이제 응답 쪽이 그 텍스트를 message.reasoning_content로 옮기고 content에는 답변만 남기며(모델이 thought만 내놓으면 키가 사라지는 대신 빈 문자열), 소비한 표시는 제거하되 형제 키인 thought_signature는 보존하고, 태그 제거 헬퍼를 스트림 변환기와 하나로 공유합니다. 어떤 모델이 주입을 받는지도 여섯 개의 contains 검사가 아니라 축약 단계를 가진 계열 테이블이 결정합니다. 부분 문자열 목록은 gemini-3.1-pro-preview는 잡고 gemini-3.1-pro는 놓쳤으므로, #1583의 별칭 재작성 이후 호출자가 가장 보내기 쉬운 정식 이름이 아무 처리도 받지 못하는 쪽이었습니다. 두 전송 경로 모두 적용되며, 최신 Flash 계열은 의도적으로 테이블에 넣지 않았습니다. 하나를 추가하면 해당 배포가 이미 받고 있는 동작이 바뀌기 때문입니다.

  • 폴백 홉의 대상 모델이 항상 추론하는 경우 reasoning_effort: "none"을 그대로 보내지 않고 맞춥니다. 이전에는 그대로 보내 업스트림 오류를 받고 체인이 이를 실패한 홉으로 셌습니다. rejects_reasoning_effort_none과 함께 추가한 인입 게이트는 클라이언트가 요청한 모델을 판단하는데, 홉은 운영자의 체인이 고른 다른 모델에 착지하고 그 모델에는 원래 모델에 없던 플래그가 있을 수 있습니다. 죽은 백엔드에서 gemini-3.6-flash로 가는 체인으로 재현했습니다. 홉이 none을 전달하자 Gemini가 400 Request contains an invalid argument.로 답했고, 체인은 이를 실패한 홉으로 처리했으며, 클라이언트는 모델도 필드도 밝히지 않는 500 An internal server error occurred.를 받았습니다. 이제 홉이 effort를 떨어뜨리고 로그를 남기며, 같은 요청이 정상 응답됩니다. 홉을 거절하는 대신 맞추는 이유는 홉이 이미 모델을 바꿨기 때문입니다. 실패를 감수하고 다른 모델을 받아들인 클라이언트에게는 오류보다 그 모델이 추론하며 답하는 편이 낫습니다. 첫 시도는 인입 게이트가 이미 판단했으므로 맞추지 않고, 다른 effort 값은 건드리지 않습니다. 알아둘 결과가 하나 있습니다. 대체 모델이 추론하므로, 추론 없는 답변에는 충분하던 작은 max_tokens가 이제 잘릴 수 있습니다.

  • 별칭을 정규 id로 디스패치했을 때도 클라이언트가 보낸 모델 이름으로 답하도록 했습니다(#1588). PR #1583은 백엔드 선택 전에 정확한 메타데이터 별칭을 그 백엔드가 제공하는 id로 바꾸는데, 각 인입이 요청 이름의 유일한 사본을 덮어써서 클라이언트가 읽는 두 지점이 클라이언트가 보낸 적 없는 모델을 지목하기 시작했습니다. X-Original-Model 폴백 헤더는 gemini-3.1-pro로 보낸 요청에 gemini-3.1-pro-preview를 실었고, 재작성 이후에 발생한 404나 허용 목록 403도 정규 id를 인용했습니다. 재작성을 수행하는 세 인입(/v1/chat/completions, /anthropic/v1/messages, /v1/responses)은 이제 두 이름을 DispatchModel 쌍으로 함께 나릅니다. 선택, 폴백 체인 조회, 캐시 키, 페이로드는 디스패치되는 id를 그대로 쓰고, 폴백 귀속과 model-not-found·not-permitted 오류는 요청 이름으로 구성하며, 논스트리밍뿐 아니라 스트리밍 경로에서도 그렇습니다. X-Fallback-Model은 여전히 실제로 응답한 모델을 가리키고, 응답의 model 필드도 여전히 업스트림이 답한 값 그대로입니다. 별칭 해석 규칙은 바뀌지 않았습니다.

  • Gemini reasoning_effort: "none" 게이트를 라우터에 컴파일된 allowlist에서 모델 메타데이터가 담는 deny list로 바꾸고, /v1/chat/completions 인입에도 적용했습니다. 이전 구현은 표가 모르는 모델이면 none을 거절했는데, 그 "모르는 모델"은 사실상 "마지막 라우터 빌드 이후에 나온 모델"이었습니다. Google은 2026년 5월부터 9월 사이에 Gemini Flash 마이너를 넷 냈고, 그때마다 누군가 측정해 릴리스를 낼 때까지 none 지원이 사라졌습니다. 새 rejects_reasoning_effort_none 메타데이터 키가 이를 뒤집습니다. 목록에 없는 모델은 전달되고 업스트림이 판단하며, 플래그는 거절이 측정된 id에만 설정되어 출하됩니다(gemini-3.6-flash, gemini-3.5-flash-lite, Gemini Pro 티어). 평범한 메타데이터이므로 업스트림이 방침을 바꾸는 날 model-metadata.d/ 드롭인으로 한 행을 설정하거나 해제할 수 있습니다. 검사는 이제 백엔드 선택 전 chat completions 인입에서 실행되어 스트리밍과 논스트리밍, 그리고 이전에는 게이트가 없던 프록시 경로까지 덮으며, 모델과 필드를 밝히는 400으로 답합니다. 두 Flash 티어의 업스트림 본문은 Request contains an invalid argument.뿐이라 둘 중 무엇도 밝히지 않습니다. validate_reasoning_effort는 어휘 검증과 xhigh·auto 정규화는 유지하되 모델 지원 여부는 더 이상 결정하지 않으므로, GeminiBackend에는 모델 표가 남아 있지 않습니다.

  • Gemini thinking 비활성 게이트를 이름 부분 문자열이 아니라 정확한 모델 계열로 판단하도록 바꾸고, 담고 있는 집합도 바로잡았습니다. supports_thinking_disable는 reasoning_effort: "none" 여부를 contains 검사 다섯 개로 결정했고, 이는 양방향으로 틀렸습니다. gemini-3.5-flash-lite는 gemini-3.5-flash 부분 문자열에 걸려 거절하는 업스트림으로 전달됐고, gemini-3.7-flash와 gemini-3.8-flash는 어디에도 걸리지 않아 업스트림이 받는데도 라우터가 거절했습니다. 2026-09-10에 출하 카탈로그의 id마다 OpenAI 호환 엔드포인트로 reasoning_effort: "none" 요청을 한 번씩 보내 측정했습니다. 2.5 Flash, 2.5 Flash-Lite, 3 Flash, 3.1 Flash-Lite, 3.5 Flash, 3.7 Flash, 3.8 Flash는 200, 3.5 Flash-Lite와 3.6 Flash는 400 INVALID_ARGUMENT였고, 2.0 계열은 업스트림에서 퇴역해 404라서 그 이름을 아직 제공하는 프록시의 동작을 유지하도록 미검증 상태로 남겼습니다. 지원 여부는 버전 순서도, Flash와 Flash-Lite의 관계도 따르지 않으므로, 이제 게이트는 -lite 구간은 남기고 버전·날짜·preview 접미사는 떼는 계열 축약으로 키를 만든 측정 표입니다. 지원하지 않는 none에 대한 라우터 자체의 400도 이제 받아 주는 계열을 밝힙니다. 이 게이트는 타입이 지정된 Gemini 백엔드 경로(execute_chat_completion, execute_streaming_request, control-plane probe)와 Anthropic 인입 페이로드 변환에서 참조합니다. 후자는 잘못된 effort를 400으로 답하는 대신 경고와 함께 필드를 제거합니다. 프록시 경로로 Gemini의 OpenAI 호환 엔드포인트에 닿는 요청은 effort를 그대로 실어 보내고 Gemini가 직접 답합니다. 이 사실도 같은 날 측정했습니다. 네이티브 thinkingConfig.thinkingLevel: "MINIMAL" 필드는 별개 축이고 차이가 나는 두 모델에서 답이 정반대입니다(3.8 Flash 거절, 3.6 Flash 허용). 이 사실을 표 옆에 적어 두어 minimal에 대한 문서 서술로 표를 다시 넓히지 않도록 했습니다.

  • routing.engine_load.base_strategy 자기 참조 테스트(#1581)가 finding의 path 대신 메시지에서 문제 필드를 확인하도록 고쳤습니다. 로더 검증 오류는 loader_validate_config를 거쳐 config validate에 도달하는데, 이 경로는 모든 오류를 일반 config path에 넣습니다. 따라서 path 단언은 진단 내용이 아니라 버킷 이름을 고정하고 있었습니다. 이 테스트는 해당 라우팅 변경 이전 base에서 추가되어 이후 main에서 계속 실패했고, src/를 건드리는 모든 PR을 막고 있었습니다.

  • 여러 단어로 된 분류기 키워드를, 표에 적힌 그대로 붙어 있을 때만이 아니라 한 문장 안에서 순서대로 최대 두 토큰 간격까지 떨어져 나타나도 매칭합니다 (#1603). count_matches가 단순 부분 문자열 검사였기 때문에, 영어에서 수식어를 끼우거나("write a short story") 한국어에서 조사를 끼우는("시를 하나 써줘") 자연스러운 표현은 마커를 놓쳤고, 요청은 multilingual이나 general로 흘러갔으며, 그 도메인에 걸린 모든 정책이 함께 놓쳤습니다. 키워드 목록을 늘리는 것으로는 고칠 수 없습니다. 모든 수식어와 모든 조사 위치를 열거하는 일은 끝나지 않기 때문입니다. 그래서 규칙을 매처에 두어 모든 표에 한 번에 적용했습니다. 붙어 있는 표기는 여전히 같은 부분 문자열 검사로 먼저 시도하므로 이전에 매칭되던 키워드가 매칭을 멈추는 일은 없고, 단어 하나짜리 키워드는 이전 동작을 정확히 유지합니다. 문장 종결 부호(마침표, 느낌표, 물음표, 말줄임표 문자, 줄바꿈, 그리고 전각 CJK 형태)가 창을 닫으므로, 종결 부호를 사이에 둔 단어들은 아무리 가까이 있어도 매칭을 이루지 못합니다. 각 단어는 토큰 전체와 맞아야 하며 토큰 가장자리의 문장부호는 무시합니다. 예외는 하나로, 마지막 단어는 비라틴 문자로 끝날 때 같은 토큰 안에서 뒤에 문자가 더 붙어도 됩니다. 써가 써줘에 매칭되는 경우입니다. 이 예외를 마지막 단어에만 한정한 것이 시 한 편의 시가 시스템에 매칭되지 않게 막아 주며, 이는 KO_CREATIVE가 애초에 피하려고 쓰인 충돌입니다. 라틴 문자는 이 예외를 아예 취하지 않는데, 그렇지 않으면 how do i가 "how do you implement"에서 발화합니다. 규칙이 count_matches에 있으므로 simple·complex·creative·analysis 표, 내장된 두 언어, smart_routing.classifier.rule.keywords의 운영자 키워드, 그리고 이후 추가되는 모든 언어 표가 이를 물려받습니다. 이는 운영자에게 보이는 의미이므로 문서에도 명시했습니다. 라벨 데이터셋 측정 결과 도메인 정확도가 전체 94.6%에서 97.3%로, 한국어는 88.9%에서 94.4%로 올랐고 복잡도는 변동이 없습니다.

  • 규칙 분류기의 복잡도 정확도를 62.2%에서 69.5%로 끌어올렸습니다 (#1605). 도메인이 94.6%인 동안 복잡도는 62.2%였고, routing_policies[].when.complexity가 모델 등급을 고르므로 이 신호는 다섯 번 중 세 번쯤만 등급을 맞게 고르고 있었습니다. simple 키워드 항이 뒤집혀 있었습니다. 가중치 0.2에 대해 (1 - strength) * 0.1을 기여했는데, 실효 비율 0.15에서 0.30은 trivial 경계에 걸치거나 그 위여서 "이건 쉽다"는 뜻의 키워드를 더할수록 점수가 올라갔습니다. 그래서 똑같은 인사말이 영어에서는 trivial, 한국어에서는 simple로 읽혔는데 순전히 한국어 쪽에 simple 키워드가 있었기 때문입니다. 이제 다른 모든 항과 같은 ratio * weight 형태를 가지며, 신호가 강해질수록 비율이 낮아집니다. 라벨 데이터셋은 37건에서 59건으로 늘었고 COMPLEXITY_ACCURACY_GATE는 측정치 69.5%에 대해 0.55에서 0.65로 올렸습니다. 언어별로는 영어가 68.4%에서 73.3%로, 한국어가 55.6%에서 65.5%로 이동했습니다. 더 큰 데이터셋에서 도메인이 100.0%에 도달한 것은 이 변경이 아니라 #1603과 #1604가 먼저 반영된 결과입니다. 남은 오분류는 모두 낮게 읽는 방향이라 잔여 편향이 한 방향이며, 덮지 않고 기록해 두었습니다.

  • /v1/chat/completions, /anthropic/v1/messages, /v1/responses에서 모델 별칭을 백엔드가 실제로 제공하는 정본 id로 디스패치하도록 수정했습니다. 이제 gemini-3.1-pro 요청은 실패하지 않고 gemini-3.1-pro-preview로 Google에 도달합니다 (PR #1583). Google이 -preview id만 제공하기 때문에 model-metadata.yaml은 #594부터 gemini-3.1-pro를 gemini-3.1-pro-preview의 별칭으로 선언했고, 샘플 config.yaml은 Gemini 백엔드 아래에 두 이름을 모두 나열합니다. 그런데 백엔드 선택은 요청된 이름을 설정된 models: 목록과 문자 그대로 비교하고 같은 이름을 그대로 디스패치했으므로, gemini-3.1-pro를 요청한 클라이언트는 설정 이름 빠른 경로를 통해 Gemini 백엔드로 선택된 뒤 그대로 전달됐고, Google은 404 models/gemini-3.1-pro is not found for API version v1main으로 응답했으며 라우터는 이를 400으로 노출했습니다. 2026-09-10 실제 API로 검증한 결과 Google 카탈로그에는 3.1 Pro 계열로 gemini-3.1-pro-preview와 gemini-3.1-pro-preview-customtools만 있고, 라우터를 거친 같은 요청은 이제 200을 반환합니다. 새 proxy::alias_dispatch 모듈이 한곳에서 하나의 규칙을 적용하며, 규칙은 의도적으로 좁습니다. 요청된 이름이 다른 정본 id의 정확한 별칭이어야 하고(메타데이터 파이프라인의 날짜 접미사·양자화·접두사·와일드카드 퍼지 단계는 보지 않으므로 gpt-5-2025-08-07 같은 고정 스냅샷이 gpt-5로 조용히 대체되는 일은 없습니다), 요청된 이름을 models:에 나열한 모든 활성 백엔드가 집계된 라이브 카탈로그에 열거되어 있어야 하며(검색이 콜드이거나 실패한 백엔드는 아무것도 기여하지 않고 그 침묵은 증거가 아닙니다), 그 카탈로그에서 어떤 백엔드도 요청된 이름을 제공하지 않아야 하고, 사용자 라우팅 가능한 백엔드가 그 카탈로그에서 정본 id를 제공해야 합니다. 설정된 models: 목록 대신 카탈로그를 보는 이유는 설정 이름이 운영자의 허용 목록 항목이고, 이번 사례에서는 바로 그 항목이 틀렸기 때문입니다. 어떤 백엔드든 문자 그대로 제공하는 이름은 절대 바꾸지 않으므로 unsloth/Qwen3.6-35B-A3B-GGUF를 나열하는 로컬 엔진은 정확히 그 id를 계속 받고, 아무도 제공할 수 없는 요청은 원래 이름을 유지하며 이전과 같이 실패합니다. 재작성은 각 ingress의 키별 allowed_models 게이트와 request_params 정책 스냅샷 뒤에 실행되어 둘 다 클라이언트가 요청한 이름으로 계속 판단하며, 디스패치 퍼널에 넘기는 허용 목록에는 정본 id를 추가해 재검사가 허가를 403으로 바꾸지 못하게 했습니다. Hub 귀속 요청, Hub 정확 모델 예산 가드 아래의 요청, AppProxy ingress 고정 요청은 이미 모델 결정을 담고 있으므로 건너뜁니다. 별칭에 키를 건 fallback.fallback_chains 항목은 별칭이 정본 id로 디스패치되면 더 이상 발동하지 않으며, 라우터는 그런 항목을 만나면 요청 시점에 경고를 남기고 해법은 체인 키를 정본 id로 두는 것입니다. 업스트림은 자신이 제공한 id로 응답하므로 별칭 요청의 응답 model 필드에는 이제 정본 id가 나타나는데, 이는 OpenAI가 유동 이름에 대해 해석된 고정 스냅샷으로 응답하는 방식과 같습니다. 임베딩, 이미지, Responses 압축, ACP, realtime 경로는 바뀌지 않았습니다. 결정을 뒷받침하는 새 읽기 전용 카탈로그 접근자 ModelAggregationService::catalog_snapshot은 캐시에서 답하고, 오래된 데이터는 백그라운드 재검증과 함께 제공하며, 한 번도 집계하지 않은 프로세스에서만 블로킹 집계를 지불합니다. tests/model_alias_dispatch_test.rs는 mock 업스트림이 받은 model을 읽어 chat 경로(비스트리밍·스트리밍), Anthropic Messages 경로, Responses 경로에서 각 조건을 고정하고, 별칭에 범위가 묶인 키, 실패하는 검색 엔드포인트, 날짜 접미사 표기도 함께 고정합니다.

  • continuum-router config validate와 MCP validate 도구가 routing 검증 오류를 필드 경로로 보고하도록 수정했습니다. 이제 routing.engine_load.base_strategy: EngineLoad는 일반 config 경로가 아니라 routing.engine_load.base_strategy의 오류로 기록됩니다 (PR #1583). 로더는 이 절을 Validate 구현으로 검증하고 routing: <errors> 문자열 하나로 빠르게 실패하는데, 보고서가 같은 텍스트를 평탄화한 필드 경로와 함께 렌더링하므로 fail-fast 게이트가 이를 중복 제거하고 운영자는 어느 필드를 고칠지 알 수 있습니다. 이로써 #1581이 추가했지만 main에서 통과하지 않던 a_self_referential_base_strategy_is_an_error 유닛 테스트도 통과합니다.

  • 허브가 관리하는 kv_routing이 재시작 없이 요청 라우팅에 반영되도록 하고, KV 수집이 캐시 전이를 잃거나 잘못 귀속시키지 않게 고쳤습니다 (#1560). 이 섹션은 즉시 리로드되는 것으로 배포됐지만 실제 인덱스와 이벤트 소비자, KV 스코어러는 모두 프로세스 시작 시점에 고정돼 있었습니다. 그래서 적용은 저장된 설정만 바꿀 뿐이었고, 롤아웃과 소스 변경, 점수 변경, 롤백이 모두 반영되려면 재시작이 필요했습니다. 이제 인덱스와 소비자는 라이브로 구성되거나 해제되고, 이름이 붙은 스코어러는 원자적으로 교체되며, 시작 시에는 복원된 허브 스냅샷에서 초기 상태를 잡습니다. 수집 결함 네 건도 함께 고쳤습니다. 토큰이 비어 있는 오프로드가 정체성을 잃었고, 여러 체인이 공유하는 접두사가 참조 계수 없이 먼저 끝난 체인에 의해 제거됐으며, 재사용되는 블록 해시가 재사용 전에 제거되지 않았고, 소스 시퀀스가 해당 레코드를 처리하기 전에 커밋되어 처리 도중 실패하면 그 레코드를 건너뛰었습니다. 이벤트 전달은 캐시 전이를 버리는 대신 백프레셔를 적용하고, 리스너 팬아웃이 밀린 소비자는 SSE 스트림을 닫은 뒤 Last-Event-ID로 재연결해 보관된 이력을 잃지 않고 재생합니다. 형식이 잘못됐거나 없거나 다른 백엔드용으로 발급된 salt_echo 정체성은 detokenize로 물러나지 않고 귀속 불가로 처리하며, 런타임 오류 로그에서 이벤트 소스 엔드포인트를 가립니다.

  • api_keys.mode: blocking에서 OpenAI 호환 및 Anthropic API 라우트가 비어 있지 않은 API 키 scopes를 강제하도록 수정했습니다. 이제 scopes: [read] 키는 모델과 저장된 출력 조회는 할 수 있지만 chat, completion, embedding, rerank, image, Responses 생성/삭제, 모델 refresh, batch 변경, realtime 추론 라우트는 실행할 수 없습니다 (#1557). scopes가 비어 있거나 없으면 기존 호환성을 위해 제한 없음으로 남고, write는 read를 포함하며, admin은 둘 다 포함하고, files는 별도 Files API 그룹만 제어합니다. permissive 모드는 제시된 키의 scope가 좁다는 이유만으로 요청을 거부하지 않습니다. 라우트 등급 표는 이제 build_api_routes 인벤토리와 source audit로 대조되어 새 /v1 라우트가 all-or-nothing 인증을 조용히 상속하지 않고 반드시 scope 등급을 선택해야 합니다.

  • 모델 목록 팬아웃이 진행 중인 동안 핫 리로드로 추가된 백엔드를 soft TTL에 팬아웃 한 번을 더한 시간 동안 숨기지 않고, 남은 팬아웃에 새 팬아웃 한 번을 더한 시간 안에 게시합니다 (#1548, lablup/backend.ai-go#4812에서 보고). ModelFetcher::fetch_all_models는 팬아웃 시작 시점에 풀을 스냅샷하는데, 느린 백엔드 하나가 매 갱신에 16초를 소모하는 그 사이에 핫 리로드가 들어오면 풀을 바꾸고 캐시를 만료시키면서도 이미 실행 중인 팬아웃은 건드리지 않았습니다. 그래서 그 팬아웃은 추가 전 멤버십으로 끝나 결과를 Fresh 항목으로 저장했습니다. 그 구간의 healthy 이벤트 revalidate_now와 요청 경로의 모든 spawn_revalidation은 진행 중인 갱신에 합쳐져 버려졌고, GET /v1/models/{id}는 오래된 목록을 근거로 404를 돌려주었으며, soft TTL이 만료될 때까지 아무것도 다시 집계하지 않았습니다. 기본 설정과 8초짜리 백엔드 하나로 1.24.0에서 측정하면 79.7초였고, 추가가 팬아웃 사이에 들어온 경우는 16.5초였습니다. 이제 각 집계는 풀을 스냅샷하기 직전에 스탬프 두 개를 기록합니다. BackendPool::membership_generation은 add_backend, remove_backend, drain_backend의 쓰기 임계 구역 안에서 성공 경로에서만 증가하며, 핫 리로드, Admin API, AppProxy 레지스트리, 컨트롤 플레인 백엔드 동기화가 모두 이 세 곳을 거치므로 전부 포함됩니다. ModelCache::invalidation_epoch는 clear_cache()와 ModelCache::clear가 증가시키며, 멤버십은 그대로 둔 채 캐시만 무효화하는 models: 허용 목록 편집, 인증 변경, enabled 토글도 잡아냅니다. 저장 시점에 스탬프가 바뀐 결과는 새로 추가된 ModelCache::set_expired로 기록되거나 ModelCache::set_if_epoch_unchanged가 거부하는데, 후자의 epoch 비교와 삽입은 무효화와 같은 락을 공유하므로 그 사이에 아무것도 끼어들 수 없습니다. 어느 쪽이든 결과는 이미 만료된 항목으로 게시되어, #1324의 stale-serve 범위 안에 있는 읽기는 여전히 이전 목록을 받고, 다음 읽기는 Fresh 히트로 합쳐지는 대신 재검증을 시작합니다. 후속 갱신은 그 읽기를 기다리지 않습니다. 백그라운드 재검증은 같은 자리에서 다시 집계하되 Backend.AI GO의 모델 등록이 몰려도 태스크가 묶이지 않도록 세 번으로 제한하고, 요청 경로와 POST /v1/models/refresh는 집계 락을 풀고 백그라운드 재검증 하나를 띄운 뒤 자신이 만든 목록으로 응답합니다. 스탬프는 의도적으로 스냅샷 전에 읽으며, 그 뒤에 읽으면 같은 경쟁이 다시 생기므로 코드에 그 이유를 적어 두었습니다. 풀이 바뀌지 않으면 새로 드는 비용은 없습니다. 스탬프는 팬아웃 도중 무언가 바뀌었을 때만 달라지며, #1324 테스트는 여전히 갱신당 팬아웃 정확히 한 번을 셉니다. 대체된 갱신은 이유와 두 스탬프 값을 담아 info로 기록하되 #1326이 스크레이핑 가능하게 만든 Model refresh: N models in D 줄과는 별도의 줄에 남기고, 새 model_superseded_aggregations_total 카운터로 셉니다. 회귀 테스트는 모의 백엔드에 게이트를 걸어 풀 변경이 매 실행마다 팬아웃 안에 확실히 들어가도록 하며, sleep이 우연히 맞아떨어지는 실행에 기대지 않습니다. 도달 불가능한 백엔드 하나가 매 갱신에 16초를 소모하는 팬아웃 꼬리 자체는 바꾸지 않았고 후속 작업으로 남겨 두었습니다.

  • 스트리밍 /v1/chat/completions에서 tool_calls[].index를 업스트림 콘텐츠 블록 또는 parts 위치가 아니라 응답 안에서 해당 도구 호출이 몇 번째인지를 나타내는 0부터 시작하는 순번으로 내보냅니다(#1546). Anthropic 변환기는 호출별 상태를 Anthropic 콘텐츠 블록 인덱스로 키잉하고 같은 숫자를 그대로 전송했기 때문에, 첫 tool_use 앞에 thinking이나 text 블록이 하나라도 있으면 번호가 밀렸습니다. 문장 하나로 시작한 턴은 도구 호출 두 개를 0과 1이 아니라 index 1과 2로 스트리밍했습니다. OpenAI는 이 필드를 tool_calls 배열의 슬롯으로 정의하고 클라이언트도 그대로 해석하므로, @ai-sdk/provider-utils는 구멍이 있는 배열을 만들었고 도구를 쓰는 모든 턴이 스트림 끝에서 TypeError: Cannot read properties of undefined (reading 'hasFinished')로 죽었습니다. 도구 호출이 없는 턴은 멀쩡했기 때문에 간헐적 오류처럼 보였습니다. 변환기는 content_block_delta와 content_block_stop이 유일하게 전달하는 식별자인 블록 인덱스로 계속 상태를 키잉하되, 시작 이벤트에서 각 tool_use 블록에 순차 순번을 부여하고 여는 청크와 모든 input_json_delta 청크에서 그 값만 내보냅니다. 덕분에 병렬 호출의 인자 델타가 뒤섞여 도착해도 각자의 인덱스를 유지합니다. Gemini 네이티브 변환기에는 같은 결함이 두 가지 형태로 있었습니다. 한 이벤트의 parts 배열 안 위치를 그대로 썼기 때문에 앞선 text 파트가 첫 호출을 밀었고, 이벤트 사이에 상태를 두지 않아 두 이벤트로 전달된 호출 두 개가 모두 0을 보고해 클라이언트가 두 번째를 첫 번째에 합쳐 버렸습니다. 이제 reset()이 비우는 스트림 단위 카운터로 도구 호출을 셉니다. 두 수정 모두 Bedrock Converse 변환기가 #909 이후 지켜 온 규칙을 따릅니다. Anthropic 쪽 변경은 네이티브 Anthropic 백엔드, HTTP 및 Unix 소켓 스트리밍 경로, 같은 변환기를 재사용하는 Bedrock InvokeModel 계열 경로(bedrock-mantle, bedrock-runtime)에 함께 적용됩니다. 비스트리밍 응답의 tool_calls 배열에는 index가 없으므로 동작이 바뀌지 않습니다.

  • 한국어 요청이 domain = multilingual로 뭉개지는 대신 의도로 분류됩니다 (#1567). 규칙 분류기의 키워드 목록이 영어 전용이라 한국어 텍스트에서는 의도 신호가 한 번도 발화하지 않았고, 코드 블록이 없는 요청에서는 비ASCII 비율 검사가 가장 강한 도메인 신호로 남았습니다. 길이 추정이 이를 키웠습니다. UTF-8 바이트 수를 4로 나누는 방식이었고 한글 음절은 3바이트라, 짧은 한국어 인사가 10토큰 trivial 경계를 넘고 같은 뜻의 영어 문장은 넘지 않았습니다. 새 언어 모듈이 탐지기 하나, 문자 체계를 가중한 토큰 추정, 언어별 키워드 표를 담습니다. 영어와 한국어를 내장하고 모든 표가 영어 키워드를 포함하며, 인식하지 못한 언어는 영어로 되돌아갑니다. determine_domain은 이제 가장 강한 의도 신호를 취하고 어떤 의도 신호도 발화하지 않았을 때만 multilingual을 고려하며, ClassificationResult가 탐지된 언어를 담습니다. smart_routing.classifier.rule 아래 핫 리로드되는 선택 필드 두 개(primary_language와 언어별 keywords)가, 인사말처럼 의도 신호가 전혀 없는 요청까지 동등하게 처리해 줍니다. 둘 다 지정하지 않으면 기존 도메인 배정이 모두 그대로 유지되며, admin classify 진단과 WebUI 플레이그라운드가 탐지된 언어를 함께 보여 줍니다.

CI

  • 라벨링된 한국어·영어 분류 데이터셋과, 모드별 규칙 분류기 정확도 및 혼동 행렬을 보고하는 러너를 추가하고 CI에서 게이팅했습니다 (#1568). tests/data/smart_routing/dataset.json은 모든 DomainTag와 ComplexityLevel 조합에 걸쳐 요청에 라벨을 붙이되, 분류기 출력을 베끼지 않고 src/services/smart_routing/types.rs의 doc 주석 정의로부터 독립적으로 라벨링했습니다. 그래서 측정된 정확도가 동어반복이 아니라 실제 회귀 신호가 됩니다. tests/smart_routing_accuracy_test.rs는 모든 사례를 rule_default와 rule_primary_ko 두 배포 모드로 classify_only에 통과시키고, 모드별·감지 언어별로 정확도와 도메인·복잡도 혼동 행렬을 출력하며, 모드마다 게이트를 단언합니다. LLM과 하이브리드 모드는 빠른 결정적 검사가 아니라 실제 또는 모의 백엔드 왕복이 필요하므로 범위 밖입니다. docs/en/development.md와 한국어 대응 문서가 데이터셋 확장 방법을 설명합니다. CI 설정은 바뀌지 않았습니다. .github/workflows/ci.yml과 scripts/local-ci.sh가 이미 cargo test --tests -- --skip integration_test를 실행하며, 이는 기본 기능 아래 모든 최상위 tests/*.rs 타깃을 컴파일하고 실행합니다.

  • realtime 메타데이터 조회를 백엔드 후보 필터 감사에서 면제했습니다 (#1602). every_backend_candidate_set_is_filtered_or_explicitly_exempt는 src/proxy/realtime.rs에 model_config_for_canonical_id가 도입된 이후 main에서 계속 실패하고 있었습니다. 이 함수는 디스패치 경로가 아닙니다. 활성 백엔드의 model_configs를 훑어 이미 해석된 정규 id를 선언한 ModelConfig를 찾아 realtime 핸드셰이크가 그 모델의 audio capability를 읽게 할 뿐이고, 백엔드 집합이 아니라 Option<&ModelConfig>를 반환합니다. 이미 면제된 alias_dispatch::canonical_catalog_id와 구조적으로 동일합니다. 프로덕션 코드는 바뀌지 않았고, 감사가 지키는 가시성 보장은 실제로 백엔드를 고르는 자리에서 여전히 강제됩니다. realtime 핸드셰이크는 디스패치 후보를 find_backends_for_model로 따로 만들고 filter_user_routing_candidates를 적용하므로, internal: true나 enabled: false 백엔드만 제공하는 모델은 여전히 거절됩니다.

Documentation

  • 시맨틱 응답 캐시를 구현된 대로 서술합니다 (#1616). docs/en/architecture.md와 한국어 대응 문서의 Scope 설명은 시맨틱 캐시 경로가 임베딩 클라이언트도 테넌트 격리 벡터 유사도 인덱스도 없는 의도적 no-op이며 CacheHitType::Semantic은 예약돼 있을 뿐 제공 경로가 없다고 여전히 적고 있었습니다. #1571이 그 경로를 만들었으므로 이 문장은 병합된 시점부터 틀린 상태였고, 사용 기록 문단도 cache_hit_type의 semantic 값에 대해 같은 주장을 반복하고 있었습니다. 이제 두 문단 모두 배포되는 내용을 서술하며, 기억에서 되쓰지 않고 docs/en/architecture/kv-cache.md와 config.yaml.example에서 가져왔습니다. 정확 조회와 접두사 조회가 모두 실패한 뒤에만 참조되는 선택적 세 번째 경로, 세 가지 게이트, threshold 또는 허브 정책의 semantic_similarity_bps에 대한 코사인 유사도, X-Cache-Mode: semantic 헤더, 그리고 외부 벡터 데이터베이스 없이 오래된 것부터 축출하는 라우터별 제한 인덱스입니다. src/services/smart_routing/load_monitor.rs도 동작 변경 없이 디렉터리 모듈(assessment.rs, state.rs, tests.rs)로 분할했습니다.

Dependencies

  • Cargo 의존성 그래프를 갱신해 약 서른여섯 개 크레이트를 최신 semver 호환 버전으로 올리고, 언어 감지 경로의 새 전이 의존성으로 core_detect, multiversion, multiversion-macros, multiversion_no_op을 들였습니다. 저장소 변경분은 Cargo.lock에만 국한됩니다.

  • Cargo minor-and-patch 의존성 그룹을 다섯 건 올렸습니다 (PR #1556). tower-http 0.7.0에서 0.7.1, lru 0.18.3에서 0.18.4, toml 1.1.4에서 1.1.5, aws-smithy-types 1.6.2에서 1.6.3, rmcp 3.1.4에서 3.2.0입니다. 저장소 변경분은 Cargo.lock에만 국한됩니다.

v1.27.0 - 2026-09-03

v1.26.0 이후 23개 커밋으로 클라이언트 자격 증명 헤더 전달 누락을 막고, 자체 호스팅 엔진에 Basic 인증을 추가했으며, 교차 제공자 폴백이 추론 의도와 응답된 업스트림 상태를 보존하게 했습니다. 또한 v1.26.0에서 사용 중단을 예고한 백엔드 엔드포인트 URL 형태를 거부하고 폴백 다이얼을 백엔드별로 제한했습니다.

Added

  • OpenAI wire 대상으로 가는 홉에서 Anthropic thinking 설정을 제거만 하지 않고 reasoning_effort로 변환합니다 (#1518, #1517이 남긴 잔여 과제). #1517 이후 모든 OpenAI wire 전송 지점이 선택된 백엔드의 설정값 backend_type을 기준으로 speed, thinking, output_config를 제거합니다. 안전하지만 정보를 잃는 처리였습니다. 확장 사고를 요청한 요청이 대상의 기본 추론 동작으로 실행되는 구조 백엔드로 넘어갔고, 클라이언트의 의도는 흔적 없이 사라졌습니다. 이제 같은 디스패치 게이트가 제거 직전에, 제거가 일어나는 바로 그 요청들에 한해 변환을 수행합니다. 따라서 변환 없이 제거만 하는 전송 지점이 있을 수 없고, 호출 지점은 하나도 바뀌지 않았습니다. output_config.effort는 수준 그대로 매핑되며 max는 대상 어휘에 있으면 xhigh, 없으면 high가 됩니다. thinking: {"type": "enabled", "budget_tokens": N}은 기존 구간표(<= 4096 low, <= 10240 medium, 그보다 크면 high)로 되돌아갑니다. 이 표는 ReasoningEffort::to_budget_tokens의 역함수이며 새로운 어휘를 따로 만들지 않았습니다. budget이 없는 {"type": "enabled"}는 medium이 되고, effort가 없는 적응형 사고와 {"type": "disabled"}는 아무것도 방출하지 않으며, output_config에 effort가 있어도 disabled가 이깁니다. 형식이 잘못된 설정은 홉 실패가 아니라 단순 제거로 격하됩니다. 클라이언트가 보낸 reasoning_effort는 평면이든 중첩(reasoning.effort)이든 항상 우선하며 절대 덮어쓰지 않습니다. 이 변환은 클라이언트가 보낸 값을 검사하는 추론 검증 이후 전송 지점에서 실행되므로, 방출되는 수준을 선택된 대상이 문서화한 어휘에 맞춥니다. openai와 azure는 OpenAI 모델 표를 참조해 reasoning_effort를 지원하지 않는 모델(gpt-4o, chat/instant 계열, 표가 인식하지 못하는 Azure 배포 이름)에는 아무것도 넣지 않고, 지원하는 경우 low < medium < high < xhigh 사다리에서 가장 가까운 허용 수준을 택하되 거리가 같으면 더 높은 쪽을 고릅니다. 그래서 gpt-5-pro로 가는 low는 high, gpt-5.2-pro로 가는 low는 medium이 됩니다. 그 밖의 모든 OpenAI wire 대상은 자체 호스팅 엔진과 Gemini를 포함해 low, medium, high만 받고 xhigh는 받지 않습니다. 로컬 엔진을 포함시킨 것은 의도한 선택입니다. reasoning_effort는 모든 대상이 소비하거나 무시하기 때문에 라우터가 이미 어떤 제거 목록에도 넣지 않은 표기이고, 인바운드 /v1/messages 핸들러는 출시 이후 줄곧 같은 엔진들을 향해 이 값을 만들어 왔으며, 클라우드 Gemini의 변환기는 이 값을 thinking_level로 매핑합니다. none, minimal, auto, max는 절대 방출하지 않습니다. 방어 장치는 모의 백엔드가 실제로 받은 아웃바운드 본문을 비스트리밍과 스트리밍 양쪽에서 검증하는 종단 간 스위트이며, 변환을 제거하면 그중 다섯 개가 실패하는 것을 확인했습니다.

  • 자체 호스팅 엔진 백엔드에 auth.type: basic을 추가합니다 (#1476). 자격 증명은 api_key에 담긴 username:password 쌍이며 RFC 7617에 따라 첫 번째 콜론에서 나뉘고, 라우터는 Authorization: Basic base64(username:password)를 보냅니다. 이는 그동안 backends[].url에 인라인으로 넣던 자격 증명이 옮겨갈 공식 위치입니다. reqwest가 RequestBuilder::new에서 URL userinfo를 벗겨 만들던 헤더가 정확히 이것이므로 와이어 바이트가 동일하고, 역방향 프록시가 보는 값은 달라지지 않습니다. 달라지는 것은 자격 증명이 설정 마스커가 의도적으로 가리지 않고 WebUI가 렌더링하는 backends[].url에서, 마스킹되는 api_key로 옮겨간다는 점입니다. vllm, sglang, ollama, lmstudio, llamacpp, mlxcel에서만 허용되고 나머지 타입에서는 로드 오류입니다. generic도 포함되는데, 그 팩토리 분기는 자격 증명을 전혀 싣지 않는 백엔드로 폴백할 수 있어 여기서 받아주면 자격 증명이 조용히 사라지기 때문입니다. api_key는 필수이고 콜론 앞 사용자명이 비어 있으면 안 됩니다. 형식이 잘못된 자격 증명을 로드 시점에 거부해야, 사용자명만으로 인증하는 헤더가 만들어져 매 요청마다 프록시에서 실패하는 일을 막을 수 있습니다. 해석되지 않은 ${VAR}는 허용되고 보간 이후 시작 시점에 다시 검사됩니다.

Changed

  • 호환성 변경(동작): 모든 chat-completions 경로에서 클라이언트가 보낸 자격 증명 헤더를 제거합니다 (#1523, #1519 보안 리뷰에서 출발). OpenAI wire 전송 경로는 연결 관련 헤더로 인식하지 못한 클라이언트 헤더를 전부 그대로 전달했고, 클라이언트의 authorization은 선택된 백엔드가 마침 자기 자격 증명을 갖고 있을 때만 억제했습니다. 그래서 x-api-key, api-key, cookie, set-cookie, proxy-authorization, x-auth-token, x-access-token은 언제나 백엔드에 도달했고, authorization도 백엔드에 키가 설정돼 있지 않으면 그대로 도달했습니다. 라우터 자신의 인증 미들웨어가 x-api-key를 베어러와 동등한 자격 증명으로 받아들이므로, 클라이언트가 라우터에 인증할 때 쓰는 바로 그 헤더가 제3자 백엔드 운영자에게 그대로 재생됐습니다. 또한 mid-stream 폴백 루프는 홉마다 프로바이더가 바뀔 수 있다는 이유로 백엔드 시크릿을 홉마다 다시 해석하므로, 프로바이더 A의 클라이언트 자격 증명이 구조상 프로바이더 B로 실려 갔습니다. 이제 네 개의 전달 루프가 모두 src/infrastructure/common/secure_header.rs의 공유 is_credential_header 판정을 통해 자격 증명 헤더를 무조건 제거합니다. 스트리밍 빌더 build_openai_chat_request_core(초기 디스패치, mid-stream 홉 루프, 자동 백엔드 선택을 모두 담당), 스트리밍 Unix 소켓 빌더, 비스트리밍 make_http_request, 비스트리밍 Unix 소켓 빌더입니다. 이 판정은 로그 마스킹이 읽는 것과 같은 목록을 읽으므로 제거와 마스킹이 서로 어긋날 수 없고, x-goog-api-key가 그 목록에 새로 들어갔습니다. 백엔드의 자격 증명은 이제 그 백엔드의 api_key, auth 블록, 또는 허브가 전달한 프로바이더 시크릿에서만 나오며, 제거는 무조건 적용되므로 홉마다 다시 판단할 것이 없습니다. 마이그레이션: 자격 증명을 설정하지 않은 백엔드는 이제 호출자의 값을 중계하는 대신 Authorization 헤더를 아예 보내지 않습니다. 과거 조건부 처리의 부작용으로 동작하던 구성이므로, 해당 백엔드 항목에 api_key를 설정하십시오. 마이그레이션하지 않은 백엔드의 증상은 그 백엔드로 가는 모든 요청에 업스트림이 401을 반환하는 것입니다. user-agent, x-request-id, 추적 헤더 같은 비자격 증명 헤더는 그대로 전달되므로 화이트리스트가 아니라 거부 목록이고, Anthropic, Gemini, Bedrock 네이티브 디스패치 경로와 Responses, Realtime 경로는 이미 이렇게 동작하고 있었으며, Responses의 두 헤더 필터는 이제 거부 목록의 자격 증명 부분을 같은 판정에서 가져오되 이름을 하나도 잃지 않습니다. 방어 장치는 wiremock 백엔드가 실제로 받은 헤더를 읽어 이름뿐 아니라 값으로도 검증하는 종단 간 스위트입니다. reqwest는 헤더를 교체하지 않고 덧붙이므로 이름만 검사하면 클라이언트의 값이 두 번째 항목으로 실려 가도 통과하기 때문입니다. 판정을 비활성화하면 일곱 개 테스트 중 여섯 개가 실패하는 것을 확인했습니다.

  • 호환성 변경(동작): 클라이언트 자격 증명 헤더 제거를 Anthropic Messages, count_tokens, 이미지, 멀티모달 임베딩 경로까지 확장합니다 (#1527, #1524 보안 리뷰에서 출발). #1523이 chat-completions 전달 루프 네 개를 고쳤지만, 형제 루프 다섯 개는 #1523 이전 형태를 그대로 유지하고 있었습니다. src/http/handlers/anthropic/handler.rs의 Anthropic Messages HTTP 빌더 build_request_with_auth와 Unix 소켓 빌더 build_unix_socket_headers, src/http/handlers/anthropic/count_tokens.rs의 build_count_tokens_request, 그리고 src/proxy/image_gen.rs의 handle_streaming_image_generation 안에 중복돼 있던 루프 두 개입니다. 이들 경로에서는 클라이언트의 authorization이나 x-api-key가 선택된 백엔드에 마침 자기 자격 증명이 있을 때만 억제됐고, cookie, set-cookie, api-key, proxy-authorization, x-auth-token, x-access-token은 백엔드 설정과 무관하게 전달됐습니다. Anthropic 표면에서 특히 문제였습니다. Anthropic SDK가 x-api-key로 인증하고 라우터 자신의 인증 미들웨어도 그 헤더를 라우터 자격 증명으로 받아들이므로, 호출자가 라우터에 접근할 때 쓰는 바로 그 헤더가 자기 키 없이 설정된 백엔드로 재생됐기 때문입니다. 이제 다섯 루프 모두 chat 경로가 쓰는 것과 같은 무조건 is_credential_header 판정을 호출하고, 여기에 두 루프가 더 합류했습니다. 첫째는 이미지 변형 루프입니다. 이 루프의 openai-* / x-* 허용 목록은 접두사로 x-auth-token과 x-access-token을 통과시켜 둘 다 프로바이더에 도달했고, 좁은 로컬 contains("api-key") 휴리스틱은 공유 거부 목록으로 대체했습니다. 둘째는 /v1/embeddings 뒤의 Gemini 멀티모달 임베딩 네이티브 디스패치(src/proxy/handlers.rs의 handle_multimodal_embedding)입니다. 이 루프는 authorization과 x-goog-api-key를 연결 헤더와 같은 matches!에 나열했을 뿐이어서, 호출자가 라우터에 인증할 때 쓰는 x-api-key를 포함한 나머지 자격 증명 이름을 전부 구글로 전달했습니다. 이미지 생성의 두 루프는 이제 초기 디스패치와 OAuth 401 재시도 빌더가 함께 쓰는 forward_client_headers 헬퍼 하나이므로 사본이 서로 어긋날 수 없습니다. 이 변경 후 호출자가 남지 않은 backend_has_config_auth는 제거했습니다. 마이그레이션: #1523과 같은 안내가 이 경로들에도 적용됩니다. 자격 증명을 설정하지 않은 백엔드는 /anthropic/v1/messages(HTTP와 Unix 소켓), /anthropic/v1/messages/count_tokens, /v1/images/generations, /v1/images/variations, 그리고 Gemini 백엔드로 가는 멀티모달 /v1/embeddings 요청에서 클라이언트 자격 증명을 전혀 받지 않습니다. 해당 백엔드 항목에 api_key를 설정하십시오. 마이그레이션하지 않은 백엔드의 증상은 업스트림이 반환하는 401입니다. user-agent, x-request-id, anthropic-version, anthropic-beta를 비롯한 비자격 증명 헤더는 그대로 전달됩니다. 방어 장치는 실제 핸들러를 wiremock 백엔드에 대고 구동하며 이름뿐 아니라 값으로도 검증하는 새 종단 간 스위트와, 키가 없는 Unix 소켓 변형 테스트입니다. 각 루프에서 판정 호출을 제거하면 해당 검증이 실패하는 것을 모두 확인했습니다.

  • 호환성 변경(설정): 자격 증명을 포함하거나, 쿼리 문자열 또는 프래그먼트를 담거나, 라우터가 접속할 수 없는 전송 방식을 지정한 백엔드 엔드포인트 URL을 거부합니다 (#1476, #1457의 2단계 승격). 이 형태들은 한 마이너 릴리스인 v1.26.0 동안 경고를 냈고, 이제 시작·핫 리로드·Admin 백엔드 API·허브 설정 동기화에서 설정 로드 시점에 거부되며 continuum-router config validate는 오류로 보고하고 0이 아닌 종료 코드를 냅니다. 메시지는 백엔드 이름과 문제 속성을 명시하되 URL은 어느 부분도 반향하지 않습니다. 자격 증명 사례가 바로 반향이 ConfigError를 렌더링하는 모든 표면으로 비밀번호를 흘려보내는 경우이기 때문입니다. engine_stats.metrics_url의 기본 규칙에도 같은 승격이 적용되며, 이 필드의 스킴·동일 호스트·다운그레이드 금지 제약은 원래부터 하드 오류였습니다. 임시 프로브와 허브 관리 경로는 달라지지 않습니다. 원래부터 이 형태들을 거부해 왔고, 사용 중단 기간이 남겨 두었던 유일한 비대칭이 이제 사라졌으므로, 후보 프로브 통과는 그 백엔드를 저장했을 때 벌어질 일을 그대로 예고합니다. 자격 증명이 담긴 URL의 이전 경로는 위의 auth.type: basic이며, type: generic 백엔드가 이에 의존했다면 구체적인 엔진 타입을 지정하십시오.

  • /anthropic/v1/messages 인그레스와 교차 공급자 폴백 홉 매퍼의 budget_tokens → reasoning_effort 구간표를 통일합니다 (#1532, #1530이 요청한 후속 작업). 이 엔드포인트로 들어온 요청이 OpenAI 호환 백엔드로 나갈 때 클라이언트가 체감하는 변화는 세 가지입니다. 2049..=4096 구간의 budget_tokens는 기존 medium 대신 low가 되고, 8193..=10240 구간은 기존 high 대신 medium이 되며, budget_tokens가 없는 thinking: {"type": "enabled"}는 기존 high 대신 medium이 됩니다. 인그레스가 자체적인 구형 구간표를 갖고 있었고 responses_only 모델용 Responses API 변환에도 같은 표의 사본이 하나 더 있었기 때문에, 같은 요청이 어느 문으로 들어왔는지와 폴백 백엔드로 넘어갔는지에 따라 서로 다른 추론 수준을 받을 수 있었습니다. 이제 두 인그레스 변환 모두 ReasoningEffort::from_budget_tokens로 budget을 해석합니다. 이 함수는 #1518과 #1530의 홉 매퍼와 공유하는 단일 구간표이며, 임계값을 ReasoningEffort::to_budget_tokens에서 읽어 오므로 정확한 역함수입니다. 따라서 정방향과 역방향이 다시 어긋날 수 없습니다. 홉 매퍼 자체의 구간은 바뀌지 않았고, output_config.effort는 모든 경로에서 여전히 budget보다 우선합니다.

  • 호환성 변경(API): SecureHeaderValue::bearer가 모듈 내부 전용(pub(in crate::infrastructure::common))이 되었고, 공개 탈출구로 SecureHeaderValue::bearer_for_provider_fixed_scheme가 그 자리를 대신합니다 (#1529). SecureHeaderValue는 공개 모듈에서 재수출되므로 임베더 입장에서는 눈에 보이는 변경입니다. 백엔드 자격 증명의 헤더를 만들던 호출자는 SecureHeaderValue::for_backend_auth(auth_type, secret)로 바꾸거나 BackendCredential을 들고 authorization_value()를 호출하면 됩니다. 둘 다 auth.type: basic을 존중합니다. 스킴이 제공자나 프로토콜에 의해 고정된 자격 증명(OAuth 액세스 토큰, 클라우드 제공자 키, 가드레일 엔드포인트 키)을 다루던 호출자는 새 이름으로 바꾸면 됩니다. 새 이름의 rustdoc이 그 계약을 명시하며, 트리 안의 모든 호출 지점은 tests/authorization_scheme_audit_test.rs에 사유와 함께 등재되어 있습니다. 와이어에 나가는 값은 달라지지 않습니다. 새 생성자는 기존과 동일한 Bearer <token> 값을 만들고, SecureHeaderValue::basic과 for_backend_auth는 그대로 공개입니다.

Fixed

  • 응답을 받은 업스트림 HTTP 상태를 연결 실패로 평탄화하지 않고 타입이 지정된 Gemini 및 Bedrock chat-completion 디스패치 경계를 통해 전달합니다 (#1539). 네이티브 스트리밍은 429가 아닌 4xx 응답을 최종 프로바이더 응답으로 처리하고, 429와 5xx 응답은 OpenAI wire 핸드셰이크와 같은 fallback.fallback_policy.trigger_conditions.error_codes 게이트에 진입시키며, 파싱된 프로바이더 오류 상세와 응답 백엔드 귀속을 보존합니다. 연결 거부는 기존 connection_error 경로를 유지합니다. 비스트리밍 Bedrock Runtime 및 Converse 요청도 같은 분류기를 사용하므로, 응답을 받은 400은 재시도 가능한 502가 되는 대신 HTTP 400으로 유지되고 서킷 실패로 집계되지 않습니다. Generic 타입 HTTP 디스패치도 같은 의존성 없는 상태 전달자를 만들며, 전송 전용 executor 오류는 변경하지 않았습니다.

  • 설정 핫 리로드가 백엔드의 유효 인증 스킴을 변경하면 엔진 통계 폴링 태스크를 다시 시작합니다 (#1521). 이제 태스크 핑거프린트는 결합된 백엔드 자격 증명에서 시크릿과 해석된 스킴을 가져오므로 auth.type만 바꾸어도 태스크가 교체되고 다음 수집 요청은 새 헤더를 사용합니다. auth 블록을 생략한 설정과 auth.type: api_key를 명시한 설정은 계속 동일하게 취급합니다.

  • 설정된 백엔드 기본 URL에서 불필요한 후행 슬래시를 제거한 뒤 헬스 체크, 모델 가용성, 라우팅이 공유 식별자를 만들도록 정규화합니다 (#1492). WebUI에 후행 슬래시가 있는 URL을 붙여 넣거나 파일, 핫 리로드, Admin API, 프로그래밍 방식 설정으로 입력했을 때 백엔드는 정상으로 기록되면서도 사용 불가 상태로 남아 503을 반환하던 문제를 수정하며, 의도적인 경로 접두사는 보존합니다.

  • native 타입 기본 백엔드의 pre-stream 실패를 스트리밍 폴백 체인으로 보냅니다 (#1531, PR #1525 본문에 기록된 관찰). 선택된 백엔드가 type: anthropic, type: gemini, 또는 type: bedrock runtime 엔드포인트인 stream: true 요청이 provider가 응답하기 전에 실패하면(연결 거부, 전송 오류, 핸드셰이크 타임아웃, 서킷 거부), fallback.enabled: true이고 체인이 정상 상태의 구조 백엔드를 가리키고 있어도 HTTP 502 service_unavailable로 응답했습니다. stream_chat_completions_inner가 폴백 디스패치 결정을 계산하기도 전에 native 디스패치 결과를 반환했기 때문이며, 같은 구성에서 기본 백엔드만 type: generic으로 바꾸면 폴백이 동작해 200 SSE 스트림을 서비스했습니다. 이제 native 분기는 provider 응답이 없는 실패를 응답이 아니라 값으로 보고하고, pre-stream 연결 단계는 매 시도마다 현재 백엔드가 쓰는 wire로 다이얼합니다. 그래서 native 기본 백엔드도 OpenAI wire 기본 백엔드와 같은 탐색에 진입하며, 두 fallback.mid_stream_enabled 모드 모두에서 같은 trigger_conditions와 max_fallback_attempts를 따르고, 같은 X-Fallback-* 헤더(항목이 하나인 체인이면 x-fallback-reason: connection_error와 x-fallback-attempts: 2), 같은 백엔드별 다이얼 상한, 같은 시도 간 예산을 적용받습니다. Anthropic 분기는 제한된 시도 구간을 요청 타임아웃에 그대로 적용하고, trait 경계에 타임아웃 매개변수가 없는 Bedrock runtime과 Gemini 분기는 같은 구간으로 핸드셰이크 대기를 제한한 뒤 만료를 timeout 트리거로 보고합니다. 같은 변경으로 #1522가 문서화했던 종결 native 홉도 사라집니다. native 백엔드로 해석되는 체인 항목이 응답 전에 실패하면 이제 다음 항목으로 진행하므로, [죽은 native 항목, 살아 있는 항목] 체인은 살아 있는 항목이 x-fallback-attempts: 3으로 서비스하고, native 홉에서 포화된 다이얼 상한도 다른 홉과 마찬가지로 backend_unhealthy로 진행합니다. 달라지지 않는 것: 체인이 없으면 죽은 native 기본 백엔드는 여전히 SSE 셸 없는 502 service_unavailable로 응답하고, 기본 백엔드와 구조 백엔드가 모두 죽으면 두 모드에서 동일한 502 본문으로 소진되며, Anthropic 형태의 native 백엔드가 응답한 non-2xx는 체인을 진행시키지 않고(해당 파이프라인이 업스트림 상태 코드를 직접 읽어 5xx를 여전히 200 SSE 스트림으로 전달합니다), native 스트림에는 여전히 스트림 중간 복구가 없고, Unix 소켓을 통한 native 기본 백엔드는 탐색 밖에 머물며, native 기본 백엔드가 낸 ValidationError, ConfigError, AuthError는 시도 횟수를 소비하지 않고 그대로 반환됩니다. 관측 컨텍스트는 이제 실패한 native 시도를 넘어 살아남으므로, 결국 서비스하는 스트림이 토큰 사용량을 계속 기록합니다. 검증 장치는 실제 핸들러를 stream: true로 구동해 죽은 anthropic, gemini, Bedrock runtime 기본 백엔드와 양쪽 wire의 모의 구조 백엔드를 상대로 확인하는 여덟 개의 end-to-end 케이스이며, 모두 이전 main에서 502 service_unavailable 신호로 실패하는 것을 확인했습니다.

  • 폴백 다이얼에 실질적인 동시성 상한을 둡니다 (#1522). 하나의 구조 백엔드로 폴백 다이얼이 폭주하는 것을 막던 장치는 50 퍼밋과 5초 획득 타임아웃을 가진 하드코딩된 라우터 전역 tokio::sync::Semaphore였습니다. 네 가지 폴백 디스패치 경로 중 정확히 하나(미드스트림 릴레이의 커밋 후 홉)에서만 획득됐고, 다이얼이 아니라 구조된 스트림 전체 동안 유지됐으므로 실제 의미는 "라우터 전체에서 폴백으로 서비스되는 동시 스트림 최대 50개"였습니다. 이는 그 장치가 완화해야 할 바로 그 장애 상황에서 가용성 절벽이 됩니다. 반면 프리스트림 연결 단계, 프리스트림 전용 모드, 네이티브 프로토콜 홉, 비스트리밍 퍼널에는 폴백 전용 상한이 전혀 없었고, 이 세마포어를 언급하는 유일한 테스트는 로컬 세마포어를 만들 뿐 라우터 코드를 전혀 건드리지 않았습니다. 이를 폴백 서비스가 소유하고 네 경로 모두가 공유하는 백엔드별 상한인 fallback.fallback_policy.max_concurrent_dials_per_backend (기본값 50, 0 = 무제한, 검증 범위 0..=10000)로 대체합니다. 퍼밋은 다이얼만 감쌉니다. 홉의 아웃바운드 요청을 보내기 직전, 포화 타임아웃이 half-open 프로브 슬롯을 소비하지 않도록 서킷 브레이커 승인보다 먼저 얻고, 프로바이더 핸드셰이크가 돌아오는 즉시 반납하며 본문에는 결코 걸치지 않습니다. 제한 대상은 홉뿐입니다. 기본 시도와 선택 시점에 폴백 모델로 걸어간 경우는 제한하지 않는데, 그 상황에서는 구조 백엔드가 사실상 기본 백엔드이기 때문입니다. 포화된 백엔드에서는 다이얼이 유효 timeouts.connection까지 (그리고 스트리밍 체인 예산의 잔여분까지) FIFO로 대기한 뒤 그 백엔드에 대한 홉이 backend_unhealthy로 실패하여 체인이 진행하거나 보존된 업스트림 실패로 소진됩니다. 비스트리밍 Unix 소켓 및 Bedrock runtime 전송은 핸드셰이크 경계가 없어 호출 전체 동안 퍼밋을 유지하며, 포화된 네이티브 프로토콜 홉은 네이티브 디스패치가 종결 홉이므로 해당 홉의 실패로 서비스됩니다 (#1531 이후로는 포화된 네이티브 프로토콜 홉이 다른 홉과 마찬가지로 backend_unhealthy로 진행합니다). 제한값은 문서화된 전환 구간과 함께 즉시 리로드되고, 포화 타임아웃은 fallback_dial_bound_saturated_total{backend}과 streaming_fallback_total의 dial_bound_saturated로 집계됩니다. 검증 장치는 실제 핸들러를 여섯 개의 동시 클라이언트로 구동해, 해제 신호가 올 때까지 핸드셰이크를 보류하는 모의 구조 백엔드를 상대로 모든 경로를 확인하는 동시성 스위트이며, 두 체인 간 기아 없음 테스트와 무제한 양성 대조군을 포함합니다. 획득 코드를 제거하면 상한 테스트들이 다이얼 여섯 개가 동시에 진행되는 것을 관측하고 실패합니다. 공허했던 test_semaphore_limits_concurrent_fallbacks는 삭제했습니다.

  • 자격 증명을 싣는 나머지 모든 지점에서 백엔드에 설정된 auth.type을 존중하고, 다음번 Bearer 하드코딩은 빌드 실패가 되게 했습니다 (#1529). auth.type: basic(#1476)은 실행기, 헬스 프로브, 모델 탐색, 비스트리밍 HTTP 및 Unix 소켓 경로(#1505 계열), 스트리밍 빌더와 Anthropic 인바운드 변환(#1528), 스트리밍 빌더의 OAuth 전략(#1534)까지 차례로 반영되었지만, 매 라운드가 실패하는 테스트가 아니라 코드를 읽어서 대상을 찾아냈습니다. 그렇게 남아 있던 열 개 지점에서 basic 인증 엔진은 여전히 Authorization: Bearer user:password를 받고 있었습니다. OpenAI 호환 백엔드용 /v1/responses 변환 경로(ResponsesApiStrategy::ConvertToChatCompletions)의 비스트리밍과 스트리밍, /anthropic/v1/messages/count_tokens(이 지점은 format!("Bearer {..}")를 헤더에 그대로 써서 로그 마스킹까지 우회했습니다), ACP session/prompt 디스패치, 시작 시 커넥션 예열, 허브가 지시하는 합성 프로브, 실시간 WebSocket 업그레이드, 그리고 스트리밍 이미지 생성, 이미지 변형, 이미지 편집 경로입니다. 이제 열 곳 모두 backends[].auth에서 값을 만들므로, auth 블록이 없거나 auth.type: api_key인 백엔드는 이전과 정확히 같은 값을 보내고, auth.type: basic인 백엔드는 RFC 7617 Basic을 보내며, Anthropic x-api-key 분기와 OAuth 게이트는 건드리지 않았습니다. 이미지 변형과 이미지 편집 경로가 포함된 이유는 그 백엔드 필터가 이름이나 URL에 openai가 들어가기만 하면 받아들이기 때문입니다. 따라서 openai-proxy라는 이름의 generic 백엔드가 지금도 이 경로에 도달하고, generic은 auth.type: basic을 허용합니다. 필터 자체는 바꾸지 않았습니다. 열 지점 각각은 실제 리스너에서 Authorization을 읽어 검증하는 테스트와 그 옆의 Bearer 대조군으로 고정되어 있고, 배선을 되돌리면 해당 테스트가 실패하는 것을 하나씩 확인했습니다. 수작업 열거를 대신하는 방어 장치는 둘입니다. SecureHeaderValue::bearer가 모듈 내부 전용이라 이 심 모듈 밖의 새 지점은 컴파일 오류가 되고, tests/authorization_scheme_audit_test.rs가 남은 표기(가시성 변경으로는 닿을 수 없는 format!("Bearer ..") 형태 포함)를 사유 없이 쓰면 빌드를 실패시킵니다.

  • Anthropic 고유 필드 speed, thinking, output_config가 와이어까지 나갈 수 있는지를 두 모델 이름에서 추정한 공급자를 기준으로 폴백 홉에서 결정하지 않고, 선택된 백엔드의 설정값 backend_type을 기준으로 디스패치에서 결정합니다 (#1517). #1512에서 들어온 홉 쪽 표는 양방향 모두에서 답을 틀렸습니다. 두 모델 이름이 모두 운영자가 붙인 별칭이면 양쪽 다 Provider::Unknown으로 추정되어 표가 이를 "같은 공급자, 정리할 것 없음"으로 읽었고, 세 필드가 그대로 OpenAI 와이어 백엔드까지 나갔습니다. 엄격한 클라우드 엔드포인트는 여기에 HTTP 400을 돌려주므로 요청을 구하려던 홉 자체가 죽습니다. 반대 방향에서는 claude-* 기본 모델이 사용자 지정 별칭을 쓰는 Anthropic 타입 백엔드로 넘어갈 때 공급자가 바뀐 것처럼 보여서, 대상이 존중했을 thinking을 그 사실을 알 길이 없는 계층이 제거해 버렸습니다. 모델 이름 추정으로는 별칭이 붙은 Anthropic 백엔드와 별칭이 붙은 vLLM 백엔드를 구분할 수 없고, 이는 #1512가 다른 자리에서 걷어낸 것과 같은 종류의 결함입니다. 어떤 백엔드가 요청을 처리하는지는 디스패치만 압니다. 이제 field_filter::strip_anthropic_native_fields_for_backend_type이 유일한 집행 지점이며 OpenAI 와이어로 나가는 모든 전송 지점에서 실행됩니다. 비스트리밍 HTTP와 Unix 소켓 패스스루 분기, 스트리밍 요청 코어(직접 스트리밍, mid-stream 홉 루프, 자동 백엔드 선택 포함), 스트리밍 Unix 소켓 경로, 타입 기반 Gemini 변환이 대상입니다. OpenAI 와이어 타입(generic, openai, azure, gemini, vllm, ollama, llamacpp, mlxcel, lmstudio, sglang)은 모두 제거하며, 포괄 분기 없이 전부 나열해 백엔드 타입이 새로 생기면 반드시 결정하게 했습니다. anthropic과 bedrock은 이 필드들을 실제로 읽는 변환기가 자기 자신이므로 유지하고, 그쪽 speed는 변경되지 않은 anthropic_fast_mode 옵트인이 계속 관장합니다. continuumrouter도 유지하는데, 하위 Continuum Router가 동일한 표준 확장 필드를 이해하고 자기 백엔드에 대해 이 규칙을 적용하기 때문입니다. 타입이 설정되지 않은 백엔드는 추측하지 않고 패스스루를 그대로 유지합니다. reasoning_effort와 extra_body는 절대 건드리지 않으므로 클라이언트의 추론 의도는 모든 홉을 건너 전달됩니다. 홉 쪽 PROVIDER_ONLY_PARAMETERS 표는 TranslationResult::removed_parameters와 함께 제거되어 홉이 하는 일은 모델 이름 교체 하나로 남았고, TranslationResult::translated는 무조건 true를 반환하던 동작 대신 실제로 교체가 일어났을 때만 true를 보고합니다. 검증 장치는 모의 백엔드가 실제로 받은 아웃바운드 본문을 스트리밍과 비스트리밍 양쪽에서 확인하는 end-to-end 스위트이며, 현재 main에서는 vLLM 타입 대상에 세 필드가 그대로 남아 실패하는 것을 확인했습니다. type:을 생략했을 때의 기본값인 type: generic으로 설정된 자체 호스팅 엔진이 이전에는 최상위 thinking, speed, output_config 필드를 읽었더라도 이 변경 이후에는 더 이상 받지 않으며, 엔진별 설정을 위한 탈출구로는 여전히 extra_body를 사용할 수 있습니다.

  • 스트리밍 폴백 홉을 OpenAI SSE 릴레이에 억지로 태우지 않고 백엔드별 타입 디스패치로 보냅니다 (#1513, 현장 보고 #1493). ensure_openai_streaming_relay_target이 디스패치 직전의 포괄적 가드로 걸려 있어서, 요청 모델에 폴백 체인이 설정돼 있기만 하면 native Anthropic, Gemini, Bedrock 백엔드가 backend type '<type>' is unsupported by the OpenAI SSE fallback relay로 거부됐습니다. 가드 위치 하나에서 실패 세 가지가 파생됐습니다. 기본 백엔드가 연결을 거부한 스트리밍 요청이 정상 상태의 Anthropic 백엔드로 홉하지 못했고, 선택 시점의 체인 탐색이 이미 그 백엔드로 결정된 경우에도 실제로 처리했을 native 분기에 닿기 전에 거부됐으며, 기본 백엔드가 native인 모델에 체인을 설정하기만 해도 그 모델로 가는 모든 스트리밍 요청이 폴백과 무관한 정상 직접 라우팅까지 포함해 HTTP 400을 받았습니다. 이제 홉은 백엔드를 다시 선택한 뒤 직접 라우팅이 쓰는 것과 같은 타입 디스패치로 재진입하며, 설정값 backend_type을 기준으로 분기합니다. 그래서 native 대상은 자기 파이프라인으로 처리되고(Anthropic이면 x-api-key와 anthropic-version을 붙인 /v1/messages Messages API), 클라이언트는 계속 OpenAI 형식의 SSE를 받습니다. 가드는 릴레이 wire를 실제로 쓰기 직전인 build_openai_streaming_attempt_payload 안에 그대로 남습니다. 기본값인 mid-stream 모드에서는 SSE 응답을 어떤 백엔드도 접촉하기 전이 아니라 첫 provider 핸드셰이크가 성공한 시점에 확정하며, 이것이 pre-stream 홉이 모든 백엔드 타입에 도달하고 notify_on_fallback에 따라 X-Fallback-* 헤더를 실을 수 있게 하는 근거입니다. 여기서 동작 변화 세 가지가 함께 따라옵니다. pre-stream 단계에서 체인이 소진되면 오류 이벤트를 담은 200 스트림이 아니라 정상적인 HTTP 오류로 반환되고, pre-stream 홉은 릴레이 자체의 재시도 가능 오류 판정 대신 fallback.fallback_policy.trigger_conditions와 fallback.fallback_policy.max_fallback_attempts를 따르며, 라우터가 아직 연결 중인 동안에는 SSE keep-alive 주석이 전송되지 않습니다. 마지막 항목은 pre-stream 전용 경로와 체인이 없는 경로가 원래부터 하던 동작과 같습니다. 이 침묵하는 연결 구간에는 릴레이와 공유하는 시도 간 예산(timeouts.request.streaming.total에 timeouts.streaming_fallback_budget_multiplier를 곱한 값, 기본값 기준 20분)이 상한으로 걸립니다. 각 핸드셰이크 시도는 남은 예산만큼으로 제한되고 예산이 소진되면 보존된 업스트림 실패로 응답하므로, 이 단계가 max_fallback_attempts + 1회의 전체 길이 타임아웃 동안 클라이언트 소켓을 붙잡는 일은 없습니다. 연결 단계가 확보한 핸드셰이크를 릴레이가 버리는 일도 없으며, 넘겨받은 스트림의 읽기 구간은 연결 단계 시작이 아니라 인계 시점부터 측정됩니다. SSE 출력이 시작된 뒤의 릴레이는 OpenAI wire만 사용하므로, native 프로토콜이나 Unix 소켓 백엔드로 해석되는 체인 항목은 폴백 시도 횟수를 소비하지 않고 건너뛰며 다음 항목을 시도합니다. 요청 전체를 실패시키는 검증 오류로 바뀌는 일은 없습니다. 검증 장치는 실제 핸들러를 stream: true로 구동해 Anthropic Messages 모의 백엔드를 상대로 wire 양쪽을 확인하는 end-to-end 스위트이며, 기존 가드를 되돌리면 역사적 실패 신호인 unsupported by the OpenAI SSE fallback relay로 실패하는 것을 확인했습니다.

  • 교차 공급자 폴백 홉의 페이로드를 대상의 와이어 형식으로 미리 변환하지 않고 표준 OpenAI chat-completions 형태로 유지합니다 (#1512). ParameterTranslator는 모델이 원본과 다른 모든 홉에서 실행되어 모델 이름 접두사로 대상 공급자를 추정하고 페이로드를 그 공급자의 고유 형태로 변환했습니다. 그 뒤 디스패치가 백엔드의 설정값 backend_type을 기준으로 실제 변환을 수행하는데, 이 변환은 입력이 표준 형태라고 가정합니다. 한 경로에 형식 결정자가 둘이라 어느 쪽도 이해하지 못하는 혼합물이 만들어졌고, OpenAI에서 Anthropic으로 가는 홉은 세 가지로 망가졌습니다. 미리 변환된 tools는 type: "function" 래퍼를 잃어 디스패치의 두 번째 변환이 모든 항목을 건너뛰고 tools: []를 보냈습니다. 미리 변환된 tool_choice는 {"type":"auto"}가 되는데 디스패치가 이를 거부하므로, tool_choice를 보낸 클라이언트는 폴백 대신 실패한 홉을 받았습니다. stop_sequences로 이름이 바뀐 stop은 stop을 읽는 Anthropic 변환기가 끝내 읽지 못했습니다. 이제 홉은 모델 이름을 바꾸고, 교차 공급자 홉일 때 대상이 소유하지 않은 공급자 전용 필드만 제거합니다. 현재는 Anthropic 전용 speed, thinking, output_config 필드가 여기 해당하며, Anthropic이 아닌 디스패치 경로는 이 필드들을 그대로 와이어까지 전달합니다. 셋 다 OpenAI chat-completions 스키마에 없고 클라우드 필드 필터도 걸러내지 않으며, 엄격한 클라우드 엔드포인트는 알 수 없는 최상위 키에 HTTP 400을 돌려줍니다. 메시지, tool, tool_choice 변환과 필드 이름 변경, 라우터의 OpenAI 호환 Gemini 엔드포인트에서는 무의미한 키였던 Gemini 고유 이름(maxOutputTokens, topP, stopSequences), 샘플링 매개변수를 logprob 보고용 노브로 바꿔버리던 top_k에서 top_logprobs, topK에서 top_logprobs 매핑, 중복이던 max_tokens 기본값은 모두 제거했습니다. 디스패치가 이미 스스로 수행하던 Anthropic 방향 제거(frequency_penalty, presence_penalty, logprobs, logit_bias, n, seed, response_format)도 함께 없앴습니다. 홉의 공급자 추정은 Bedrock의 claude-* 모델, OpenAI 호환 엔드포인트 뒤의 Gemini, vLLM에 올린 gpt- 접두 오픈 모델에서 틀리며, 그런 경우 매개변수를 미리 버리면 받아들였을 백엔드에서 그 값을 잃기 때문입니다. 방어 장치는 실패하는 OpenAI 호환 모의 백엔드에서 Anthropic 타입 모의 백엔드로 실제 핸들러를 통해 요청을 흘리고 나가는 /v1/messages 본문을 검증하는 종단 간 테스트이며, 수정을 되돌리면 세 결함 모두에서 실패한다는 것을 확인했습니다. 스트리밍 릴레이 동작은 그대로이며 #1513에서 따로 다룹니다.

  • 설정된 auth.type을 실행기 경로뿐 아니라 자격 증명을 싣는 모든 요청 경로에 적용합니다 (#1505). auth.type: basic은 #1476에서 추가되어 RequestExecutor를 담당하는 HeaderBuilder에 배선됐지만, 프록시 인퍼런스, Unix 소켓 디스패치, 상태 검사, 모델 디스커버리, 엔진 통계 스크레이프는 각자 Authorization 헤더를 직접 만들며 Bearer를 하드코딩하고 있었습니다. 그래서 basic으로 설정한 백엔드는 정작 중요한 모든 곳에서 Bearer username:password를 보냈고, health_checks.enabled가 기본값 true이므로 자신의 상태 검사에 실패해 Unhealthy로 표시되고 트래픽을 전혀 받지 못했습니다. 이제 다섯 경로가 단일 지점 SecureHeaderValue::for_backend_auth로 헤더를 만들며, 이 함수는 설정된 스킴을 필수 인자로 받으므로 새 호출 지점이 잘못된 스킴을 조용히 물려받을 수 없습니다. 인증 스킴은 BackendHealthCheckInfo, 모델 디스커버리 자격 증명 맵, 엔진 통계 대상 구조체를 통해 자격 증명과 함께 이동합니다. 백엔드 타입 자동 감지는 의도적으로 Bearer로 남기고 이유를 주석으로 적었습니다. 이 경로는 type: generic과 핫 리로드 감지 라운드에서만 돌고 generic에서 basic은 로드 오류이므로, #1504가 그 타입에 자격 증명 경로를 주기 전까지 basic 자격 증명이 도달할 수 없습니다. auth 블록이 없거나 auth.type: api_key인 백엔드는 모든 경로에서 이전과 바이트 단위로 동일합니다. 방어 장치는 실제 리스너에서 헤더를 읽는 동작 테스트이며, 수정을 되돌리면 실패한다는 것을 확인했습니다. 이전의 단위 테스트는 인코더를 고정했는데 인코더는 애초에 깨진 부분이 아니었습니다.

  • 임시 Admin 백엔드 프로브 후보에서 auth.type: basic을 허용합니다 (#1503). validate_candidate_auth가 이 스킴을 service_account, sigv4와 함께 거부하고 있어서, POST /admin/backends/probe가 #1476이 운영자에게 이전하라고 안내하는 바로 그 설정을 확인할 수 없었습니다. 로드에서 설정을 거부당한 운영자에게는 고쳐 쓴 결과를 실제로 적용해 보는 것 말고는 검증할 방법이 없었습니다. 이 거부는 기능 공백이 아니라 #1476이 진행 중일 때 보수적으로 배치한 결과였습니다. 두 프로브 경로 모두 이미 스킴을 적용하고 있고, 헬스 프로브는 BackendHealthCheckInfo.auth_type을 통해, 카탈로그 프로브는 fetch_models_from_backend를 통해 적용하며 둘 다 #1505에서 배선됐습니다. service_account와 sigv4는 계속 거부됩니다. 각각 임시 후보가 적재할 수 없는 수명 관리 자료를 요구하기 때문입니다. 후보 검증은 여전히 단일 백엔드 설정에 대해 전체 설정 검증기를 돌리므로 백엔드 타입 제한과 username:password 형식 규칙은 복제되지 않고 상속되며, candidate_static_key_was_applied가 수명 관리 스킴 셋만 제외하므로 credential_status도 이미 basic을 정확히 보고하고 있었습니다.

  • type: generic 백엔드에 자격 증명 경로를 주고 auth.type: basic을 허용합니다 (#1504). generic 팩토리 분기는 자동 감지를 시도하고 아무것도 맞지 않으면 BackendInfo로 만든 백엔드로 폴백하는데, 이 타입은 name, url, weight, supported_models, metadata, enabled만 담고 어떤 자격 증명도 갖지 않으며 HttpBackend에는 Authorization 구성 자체가 없었습니다. 그래서 generic 백엔드에 설정한 api_key는 받아들여지고도 전송되지 않았습니다. 이 타입이 인증이 필요한 엔드포인트에 대해 동작한 것은 오직 reqwest가 HTTP 클라이언트 내부에서 backends[].url의 userinfo를 Basic 헤더로 바꿔 줬기 때문이고, #1476이 그 URL 형태를 로드 오류로 만들면서 type: generic은 인증할 방법을 완전히 잃었습니다. 이제 HttpBackend가 해결된 Authorization 값을 보관합니다. 이 값은 공유 지점 SecureHeaderValue::for_backend_auth(#1505)를 통해 생성 시점에 한 번 만들어지며, 네 개 요청 경로 모두에서 호출자가 넘긴 헤더보다 먼저 적용되므로 명시적으로 전달된 헤더가 여전히 우선합니다. 백엔드 타입 자동 감지도 HTTP와 Unix 소켓 전송 양쪽에서 같은 지점을 거치게 되어, #1505가 의도적으로 Bearer로 남겨 두었던 마지막 사이트가 닫혔습니다. 자격 증명이 없는 generic 백엔드가 Authorization 헤더를 보내지 않는 것은 그대로입니다.

  • 스트리밍 OpenAI 와이어 요청 빌더와 /anthropic/v1/messages 변환 경로에서 설정된 auth.type을 반영합니다 (#1528). #1505 계열은 상태 검사, 모델 디스커버리, 실행기 채팅 경로, 비스트리밍 HTTP 및 Unix 소켓 경로를 스킴을 아는 SecureHeaderValue::for_backend_auth 지점으로 옮겼지만, 자격 증명을 만드는 여섯 곳이 남아 있었습니다. src/http/streaming/handler.rs의 네 곳과 src/http/handlers/anthropic/handler.rs의 두 곳으로, 모두 SecureHeaderValue::bearer를 하드코딩하고 있었습니다. 그래서 auth.type: basic과 api_key: user:password로 설정한 백엔드는 채팅 클라이언트의 주 경로인 모든 스트리밍 채팅 완성 요청과, Anthropic API 요청이 OpenAI 호환 엔진으로 라우팅되는 모든 경우에 Authorization: Bearer user:password를 받았습니다. 반면 #1505에서 고친 상태 검사는 그 백엔드를 계속 Healthy로 보고했으므로, 트래픽이 가장 많이 흐르는 바로 그 지점에서 역방향 프록시가 401을 돌려주는 상태가 조용히 유지됐습니다. 이제 여섯 곳 모두 새 pub(crate) 헬퍼 crate::proxy::oauth_helper::backend_authorization_value로 헤더를 만듭니다. 이는 src/proxy/backend.rs가 하는 것과 같은 호출이므로 두 파일의 어떤 지점도 스킴을 직접 지정하지 않습니다. 대상은 스트리밍 요청 코어(초기 디스패치, 모든 스트림 중간 폴백 홉, 자동 백엔드 선택을 담당), 스트리밍 Unix 소켓 빌더, Bedrock 맨틀 스트리밍 분기 두 곳, Anthropic Messages의 HTTP 및 Unix 소켓 변환 빌더입니다. 코어가 해당 홉의 backend_config를 읽으므로, 스트림 중간 홉은 기본 백엔드의 스킴을 물려받지 않고 홉마다 스킴을 다시 판정합니다. 시그니처는 바뀌지 않았고 #888의 허브 시크릿 치환도 그대로이며, auth 블록이 없거나 auth.type: api_key인 백엔드는 전후로 와이어에서 바이트 단위로 동일합니다. Bedrock 맨틀 분기는 bedrock에서 basic이 로드 오류이므로 항상 Bearer로 해석되며, 호출 지점에서 스킴을 지목하지 않기 위해서만 헬퍼를 거칩니다. 방어 장치는 실제 핸들러를 구동해 wiremock 백엔드나 Unix 소켓 리스너가 실제로 받은 바이트를 검증하는 새 종단 간 스위트이고, 요청이 끝나지 않은 상태에서 헤더 단언이 통과하는 일이 없도록 각 SSE 본문을 끝까지 읽습니다. 각 Basic 단언은 해당 지점을 SecureHeaderValue::bearer로 되돌리면 실패한다는 것을 확인했습니다. 별도로 확인했으나 여기서 고치지 않은 것: 이 스트리밍 빌더들은 등록된 OAuth 전략을 적용하지 않으므로, auth.type: oauth를 선언한 백엔드는 비스트리밍 경로와 달리 정적 키로 또는 아무 자격 증명 없이 디스패치됩니다. 이 문제는 #1534에서 추적합니다.

  • 네 개의 스트리밍 요청 빌더에 등록된 OAuth 전략을 적용하고, auth.type: oauth를 선언한 백엔드에는 정적 키를 보내지 않습니다 (#1534, #1528이 확인하고 남겨둔 간극). build_openai_chat_request_core(초기 디스패치, 모든 스트림 중간 폴백 홉, 자동 백엔드 선택을 담당)와 stream_via_unix_socket은 전략을 전혀 조회하지 않았습니다. auth.type: oauth를 선언한 백엔드는 정적 api_key가 있으면 그 값을 평범한 Bearer로, 없으면 Authorization 없이 디스패치됐고, 토큰은 갱신되지 않았으며, 전략의 Codex 추가 헤더(originator와 Codex User-Agent. ChatGPT 구독 프런트는 OAuth 엔드포인트뿐 아니라 모든 요청에서 이 헤더를 요구합니다)는 라우터 밖으로 나가지 못했습니다. 이제 네 곳 모두 make_http_request가 이미 지키던 계약을 따릅니다. 전략이 등록돼 있으면 토큰을 갱신하고, 클라이언트 헤더 전달 루프 다음에 전략의 헤더 맵 전체를 적용해(두 경로가 적용 순서에서 어긋날 수 없도록) 정적 키를 억제합니다. auth.type: oauth를 선언했지만 토큰 저장소가 없거나 손상돼 전략이 로드되지 않았다면, 정적 키가 없는 토큰을 대신하는 대신 Authorization을 아예 보내지 않습니다. 그 밖의 경우 정적 경로는 이전과 바이트 단위로 동일하며, 빈 레지스트리를 쓰는 Bearer 대조 테스트가 이를 고정합니다. 코어는 async가 되고 호출자가 조회한 전략을 인자로 받으므로, build_streaming_request의 두 호출 지점에는 .await만 붙었고 다른 변경은 없습니다. 스트림 중간 릴레이는 스폰된 태스크로 옮긴 레지스트리 핸들에서 홉마다 전략을 다시 조회합니다. 유효 시크릿 리졸버 옆에 두었고 이유도 같습니다. 릴레이는 어떤 토큰보다도 오래 살기 때문에, 스폰 이전 스냅숏은 스트림 전체를 처음 선택된 백엔드의 자격 증명에 고정시키고, 제공자를 넘나드는 홉 뒤에는 아예 다른 백엔드의 토큰이 됩니다. Unix 소켓 전송은 reqwest::RequestBuilder가 아니라 헤더 Vec을 만들므로, oauth_helper에 strategy_headers를 추가했습니다. 갱신 후 헤더를 스냅숏하는 절반이며 apply_strategy_headers도 이제 이를 호출하므로 두 전송이 하나의 갱신 규칙을 공유합니다. 방어 장치는 실제 핸들러를 구동해 wiremock 백엔드나 Unix 소켓 리스너가 실제로 받은 바이트를 검증하는 새 종단 간 스위트입니다. 모든 OAuth 백엔드에 미끼 정적 api_key를 넣었으므로, 단일 authorization 값이 시드한 토큰과 같다는 검증이 곧 정적 키 억제의 증명이 됩니다. 그리고 Bearer만 보내는 편법이 통과하지 못하도록 originator도 함께 검증합니다. 배선한 일곱 지점을 하나씩 되돌려 해당 검증이 실패하는 것을 확인했습니다. 조용히 미루는 대신 두 가지 결정을 기록합니다. stream_with_auto_backend_selection에는 이번에 native_stream_kind 가드를 넣지 않습니다. 이 함수는 어떤 라우트에도 연결되지 않은 라이브러리 전용 진입점이고, 올바른 가드는 #1531이 동시에 재구성 중인 타입 기반 디스패치에 위임하는 것뿐이며, 자격 증명 수정 자체는 이 지점에도 적용됐기 때문입니다. 그리고 gemini가 아닌 타입의 auth.type: service_account, bedrock이 아닌 타입의 sigv4는 로드 오류가 아니라 시작 로그와 continuum-router config validate 양쪽의 경고가 됩니다. 이 조합은 지금도 로드되고 정적 키를 Bearer로 보내므로, 수정 릴리스에서 거부하면 돌아가던 라우터가 멈추기 때문입니다. #1457 이후 #1476이 밟은, 한 릴리스 동안 경고한 뒤 거부하는 경로를 따릅니다. oauth에는 규칙을 두지 않습니다. 이번 변경 이후 모든 OpenAI wire 경로에서 정상 동작하는 설정이기 때문입니다. 남은 후속 과제: 비스트리밍 경로의 401 강제 갱신 재시도에 대응하는 스트리밍 쪽 처리입니다. connect_with_pre_stream_fallback에 들어가야 하는데 #1531이 그 함수를 재구성 중입니다.

Documentation

  • 인라인 백엔드 URL 자격 증명이 "전달되지 않는다"고 한 1단계 서술을 정정합니다 (#1476). 라우터 자신의 호출 형태로 로컬 리스너에 실측한 결과, api_key가 없는 http://svc:pw@host/v1 형태의 백엔드 URL은 와이어에 authorization: Basic ...을 냅니다. 공유 실행기와 핫 프록시 경로 모두 그렇고, reqwest::RequestBuilder::new가 extract_authority를 호출하기 때문입니다. api_key가 설정되어 있으면 Bearer 헤더가 이를 대체하고 URL 자격 증명은 조용히 폐기됩니다. 아래 v1.26.0 항목을 그 자리에서 정정했고, docs/en/configuration/backends.md와 한국어 대응 문서의 엔드포인트 규칙 절은 이 형태를 거부하는 진짜 이유, 즉 backends[].url은 Admin API 응답에서 의도적으로 마스킹하지 않는 반면 api_key는 마스킹한다는 점을 서술합니다.

v1.26.0 - 2026-09-01

v1.25.0 이후 23개 커밋입니다. 그중 여섯 건이 리다이렉션 리뷰 하나에서 드러난 자격증명 노출 결함 묶음을 닫습니다. 인라인 자격증명이 담긴 백엔드 URL이 정상 동작 경로에서 67개 지점의 로그에 그대로 기록되었고, 미지원 스킴 전송 오류와 잘못된 URL 설정 오류가 같은 값을 반향했으며, Admin 백엔드 프로브가 응답에 담아 반환했습니다. Redis 연결 URL의 비밀번호는 GET /admin/config/full이 반환했고 Debug로도 노출될 수 있었습니다. 에픽 #1448은 엔진 자체 통계 수집, 엔진 부하 인식 백엔드 선택, 일급 백엔드 타입 type: sglang으로 마무리되며, KV 캐시 인식 선택의 결함 다섯 건도 함께 수정되었습니다. 자격증명을 포함하거나 쿼리 문자열 또는 프래그먼트를 담거나 라우터가 접속할 수 없는 전송 방식을 지정한 백엔드 엔드포인트 URL은 폐기 예정이며 v1.27.0부터 설정 로드에서 거부됩니다. 후보를 거부하는 Admin 설정 변경은 이제 200이 아니라 400을 응답합니다.

Added

  • 새 routing.engine_load 섹션으로 엔진 부하 인식 백엔드 선택을 추가합니다 (#1447, 에픽 #1448의 마지막 하위 이슈). routing.engine_load.enabled: true로 설정하면 KV 오버랩 스코어러(#1444)에 가산 항이 추가되어, 요청 접두사를 보유한 백엔드 가운데 엔진이 보고한 waiting_requests가 적고(신선한 후보 전부가 total_slots를 보고하면 그 값으로 정규화하고, 그렇지 않으면 해당 요청 후보 집합의 최대 waiting_requests로 정규화합니다. 유계·무계 기준을 섞으면 llama.cpp·vLLM 혼합 플릿에서 순위가 뒤집힐 수 있기 때문입니다) kv_cache_usage가 낮은 백엔드를 우선하며, 값은 #1446이 수집하는 엔진 통계 스냅샷에서 가져옵니다. 신선도 가드(max_staleness_intervals, 기본값은 폴링 주기 3회분이며, 다음 스크레이프 성공을 기다리지 않고 핫 리로드와 폴러 작업 재시작마다 이미 보유한 스냅샷에도 즉시 재적용됩니다)는 스냅샷이 오래되면 해당 후보를 기본 점수로 되돌리고, 모든 후보가 오래된 경우 패스 전체가 기본 스코어링과 완전히 같게 동작합니다(stale_fallback). 이중 임계값 히스테리시스 데드 밴드(balance_abs_threshold 64, balance_rel_threshold 1.5, SGLang Model Gateway의 balance 게이트를 따름)는 신선한 waiting_requests 편차가 둘 다 넘어서지 않는 한 항을 정지 상태로 유지하므로(hysteresis_hold), 두 백엔드의 큐가 서로 주위를 오가며 진동해도 선택이 요동치지 않습니다. 선택적 포화 어드미션 힌트(routing.engine_load.admission, 기본값 꺼짐)는 정상 후보 전부가 신선한 kv_cache_usage를 kv_usage_threshold(기본값 0.98) 이상으로 보고하면 fallback.fallback_chains에 참여하는 재시도 가능한 503으로 선택을 거부하며, 진짜 포화 사고 때 로그가 읽기 어려워지지 않도록 풀당 60초에 최대 한 번만 WARN 로그를 남기도록 제한됩니다. 결정은 새 routing_engine_load_decisions_total{backend,reason} 시리즈(engine_load, stale_fallback, hysteresis_hold, admission_reject)로 집계되며, Grafana 패널과 EngineLoadAdmissionRejecting Prometheus 알림이 monitoring/에 추가되었습니다. 섹션 전체가 기본값으로 꺼져 있어 이 기능이 없는 빌드와 선택 동작이 바이트 단위로 동일합니다. 모든 필드의 범위는 어떤 프로덕션 호출자도 거치지 않는 전체 Config 검증 체인뿐 아니라 실행 중인 라우터를 바꿀 수 있는 모든 경로(시작, 핫 리로드, Admin API PUT/PATCH)에서 강제되어, 범위를 벗어난 어드미션 임계값이 플릿 전체 트래픽을 거부할 수 있었던 경로를 막습니다. 정상 트래픽까지 걷어낼 만큼 낮은 어드미션 임계값과, 데이터 소스나 실행할 스코어러가 없는데 켜진 항은 config validate 권고로 표시됩니다. 엔진 부하 항의 영향력은 engine_load_weight(기본값 0.3, 합이 1.0인 기본 가중치 위에 더해지는 가산 항)와 기본 스코어러가 이미 강제하는 접두사 보유 백엔드 한정 범위, 히스테리시스 데드 밴드로 제한되며, 어드미션 힌트는 정상 후보 전부가 포화 상태인 요청만 거부할 수 있습니다. docs/en/load-balancing.md와 docs/ko/load-balancing.md에 자체 보고 엔진 수치의 신뢰 모델을 포함해 문서화되어 있습니다.

  • 자체 호스팅 서빙 백엔드에서 엔진 고유 통계를 수집합니다 (#1446). 항상 컴파일되는 새 엔진 통계 계층이 백엔드별 지터를 준 주기로 각 엔진 자체의 부하/메트릭 엔드포인트를 폴링합니다. vLLM GET /metrics(카운터 이름의 접미사 유무 양쪽과 숨겨진 V0 gpu_cache_usage_perc 폴백 수용), SGLang GET /v1/loads?include=core(DP 랭크당 항목이 담긴 객체 래퍼이며, omit_defaults 와이어 포맷 때문에 누락된 숫자는 진실한 0으로 읽음)와 /get_load·/metrics 폴백, llama.cpp는 /props의 endpoint_slots/endpoint_metrics 기능 불리언으로 GET /slots·GET /metrics를 선택(501과 404 모두 조용히 축소, 슬롯당 n_ctx가 컨텍스트 값, mlxcel은 같은 어댑터 사용), Ollama GET /api/ps, LM Studio GET /api/v0/models. 스냅샷은 인플라이트 트래커 옆의 풀 단위 TTL 저장소에 담기고, backend_engine_*{backend} Prometheus 시리즈(0이 아닌 부재 원칙, 스크레이프 실패 시 backend_engine_stats_scrape_success 0만 노출, 카운터는 리셋 감지로 미러링, 핫 리로드로 제거된 백엔드는 다음 스크레이프에서 소멸), GET /admin/backends/{name}/engine-stats, GET /admin/stats/backends의 engine_stats 객체, WebUI 대시보드 배지로 드러나며, 신선한 스냅샷이 있는 동안 vLLM 계열과 llama.cpp 백엔드의 Backend::get_current_load()에도 반영됩니다. 새 핫 리로드 가능 engine_stats 섹션은 enabled가 기본값 false라서 운영자가 켜기 전까지는 폴러 작업도, 시리즈 노출도 없는 옵트인이며, 여기에 백엔드별 engine_stats 블록(enabled, source, metrics_url, interval)을 더해 설정합니다. metrics_url은 섹션의 allow_external_metrics_url(기본값 false)로 허용하지 않는 한 백엔드 URL과 같은 호스트여야 하며, https 백엔드의 metrics_url은 그 옵트인이 켜져 있어도 평문 http로 낮출 수 없습니다. 메트릭 요청에 백엔드의 API 키가 함께 실리기 때문입니다. Grafana 패널과 Prometheus 알림 예시는 monitoring/ 아래에 함께 제공됩니다.

  • SGLang 서버를 위한 일급 백엔드 타입 type: sglang을 추가했습니다 (#1445). 새 BackendTypeConfig::Sglang(별칭 SGLang, sg-lang, sg_lang)은 URL 기본값을 http://localhost:30000으로 두고, /health를 프로브하며 /v1/models로 폴백합니다 (엔진 시작/종료 중의 503은 워밍업으로 다룹니다). vLLM 계열의 OpenAI 호환 경로로 라우팅하면서 SGLang 요청 확장과 reasoning_content를 그대로 전달하고, /v1/responses는 챗 컴플리션으로 변환하며, /v1/messages/count_tokens는 엔진의 /v1/tokenize로 토큰화합니다. Admin API, WebUI 백엔드 목록과 대시보드, 백엔드 프로빙, 컨트롤 플레인 사용량 프로바이더(sglang), 설정 어시스턴트 스킬과 템플릿(config generate --template sglang), 문서가 모두 새 타입을 다루고, 낡아 있던 Admin config-schema 백엔드 타입 enum은 이제 모든 변형을 나열합니다.

Changed

  • 호환성 변경(API): Admin 설정 변경 요청이 제출된 후보를 거부할 때 200 OK 대신 400 Bad Request를 반환합니다(#1466). PUT/PATCH /admin/config/{section}, apply: true이고 dry_run: false인 POST /admin/config/import, config 후보가 있고 hot_reload: true인 POST /admin/config/apply는 지금까지 200을 반환하고 거부 사실을 본문의 "success": false에만 담았습니다. 그래서 상태 코드를 기준으로 삼는 호출자는, curl -f와 대부분의 HTTP 클라이언트 라이브러리가 그렇게 동작하는데, 거부된 설정을 적용된 설정으로 읽고 그대로 진행했고 라우터는 이전 설정을 계속 사용했습니다. 기준은 어느 엔드포인트인지가 아니라 요청이 실행 중 상태를 바꾸려 했는지입니다. POST /admin/config/validate는 판정 엔드포인트이므로 파싱 가능한 입력에는 언제나 200을 유지하고, dry_run: true이거나 apply: false인 가져오기는 아무것도 반영하지 않으므로 200을 유지하며, 후보가 없거나 후보가 실행 중 설정과 같거나 hot_reload: false인 적용도 200을 유지합니다. 응답 본문은 모든 경우에 이전과 같습니다. "success": false와 설명이 담긴 error/validation.errors가 그대로 있으므로 이미 본문을 확인하던 클라이언트는 고칠 것이 없고, WebUI도 종전과 같은 필드 단위 오류 표시를 그대로 사용합니다. 서버 기능 부재를 알리는 거부("핫 리로드 사용 불가")와 가져오기의 크기 및 중첩 깊이 제한은 의도적으로 200을 유지합니다. 둘 다 클라이언트 잘못이 아니기 때문입니다. 4xx에서 예외를 발생시키는 자동화는 이제 이전에 조용히 넘어가던 지점에서 예외를 발생시키며, 그것이 이 변경이 의도한 교정입니다.

  • /v1/models 항목의 엔진 보고값 max_model_len을 모델 메타데이터 컨텍스트 윈도로 반영합니다 (#1445). 모델 항목에 이 필드를 싣는 모든 백엔드(vLLM과 SGLang이 보고)에 계열 전체로 적용되므로, 검색된 모델은 운영자 작업 없이 실제 limits.context_window를 노출하며 limits.max_output에도 같은 값이 채워집니다. max_model_len은 엔진이 생성할 수 있는 토큰 수의 상한이고, 기본값 0은 "해당 없음"을 뜻하는 예약 표기라서 그대로 두면 /v1/models/{model}에 "max_tokens": 0이 실리기 때문입니다. 명시적인 model-metadata.yaml / model-metadata.d/ 제한이 항상 우선하며(의도적인 context_window: 0 포함), 설정된 제한이 없는 모델만 엔진 값을 얻습니다. 이 필드는 운영자 설정이 아니라 엔진이 통제하는 값이므로, 설정 검증기가 운영자 지정 제한에 적용하는 것과 동일한 10,000,000 토큰 상한까지만 허용합니다. 따라서 백엔드가 라우터 자신의 설정 파일에서라면 거부됐을 숫자를 응답에 실을 수 없습니다.

  • 한 모델 id를 서빙하는 모든 백엔드가 보고한 max_model_len 가운데 최솟값을 게시하도록 바꿉니다. 중복 제거에서 살아남은 백엔드의 보고값을 쓰지 않습니다 (#1456, 에픽 #1448 Phase 4의 한 단위). limits.context_window와 limits.max_output 모두 이제 보고한 백엔드 전체의 값을 min으로 접어 채웁니다. 이전에는 기본 MergeBackends 전략에서 풀 순서상 첫 백엔드의 값(LastWins에서는 마지막 백엔드의 값)이 실렸고, 풀 순서는 핫 리로드, Admin API 백엔드 변경, AppProxy 재조정으로 바뀌므로 백엔드를 뺐다가 다시 넣는 것만으로 공개되는 윈도가 운영자에게 보이는 원인 없이 달라질 수 있었습니다. 최솟값을 고른 이유는 그것만이 라우터가 지킬 수 있는 약속이기 때문입니다. 선택기는 모델의 backends 목록 중 어느 백엔드로든 요청을 보낼 수 있고, 엔진이 컨텍스트 초과로 돌려주는 400은 retry.retryable_status_codes에도 기본 폴백 error_codes에도 없어서, 값을 크게 공개하면 어느 레플리카로 갔느냐에 따라 실패하고 재시도 핸들러도 fallback.fallback_chains도 이를 되살리지 못합니다. 아무것도 보고하지 않는 백엔드는 0이 아니라 후보 없음으로 다루고, 0이거나 공유 상한인 10,000,000 토큰을 넘는 보고는 그 항목만 버려서 다른 백엔드의 값을 좁히거나 지우지 않습니다. 남는 후보가 하나도 없으면 종전과 같이 어떤 제한도 만들어 내지 않습니다. 명시적인 model-metadata.yaml / model-metadata.d/ 제한은 여전히 무조건 우선하며, 사양이 섞인 플릿에서 더 큰 윈도를 공개하는 지원된 방법이 바로 이것입니다. /v1/models JSON 본문에는 새 필드가 생기지 않고, 백엔드 간 불일치는 모델 id, 선택된 최솟값, 후보 전체 목록과 함께 디버그 레벨로 기록합니다. 운영자가 체감하는 결과는 이렇습니다. 한 레플리카만 더 큰 --max-model-len으로 띄운 플릿에서는 공개되는 윈도가 가장 작은 레플리카의 값으로 내려가고, 라우팅이 컨텍스트 윈도를 고려하지 않으므로 그 레플리카의 여유 용량은 공유 모델 id로 도달할 수 없습니다. 더 큰 윈도를 공개하려면 해당 백엔드에 별도 모델 id를 주거나 메타데이터 제한을 명시해야 합니다.

Deprecated

  • 자격 증명을 포함하거나, 쿼리 문자열 또는 프래그먼트를 갖거나(끝에 붙은 빈 ?/#도 해당), http/https/unix 이외의 스킴을 쓰는 백엔드 엔드포인트 URL은 config.yaml, Admin 백엔드 API 및 그 밖의 모든 등록 백엔드 경로에서 사용 중단(deprecated) 상태가 되며, v1.27.0 릴리스부터 설정 로드 시점에 거부됩니다 (#1457, 오류로의 승격은 #1476로 예정). 이 형태들은 원래부터 올바르게 동작한 적이 없습니다. 요청 URL은 백엔드 URL 뒤에 요청 경로를 이어 붙여 만들기 때문에 쿼리나 프래그먼트가 경로를 삼켜 백엔드가 모든 요청에 404로 응답하고, 포함된 자격 증명은 백엔드에 api_key가 없을 때 reqwest가 URL userinfo로 만든 Authorization: Basic 헤더로 실제 전달되며 Admin API가 의도적으로 평문 렌더링하는 필드에 놓이고(#1476에서 정정했고, 같은 이슈가 auth.type: basic을 공식 위치로 추가합니다), 지원되지 않는 스킴은 필드를 명시한 로드 오류 대신 요청마다 HTTP 클라이언트 내부에서 실패합니다. 사용 중단 기간에는 이런 설정도 여전히 로드되고 서비스되며, 시작·핫 리로드 로그와 continuum-router config validate JSON 보고서(경로 backends.{name}.url)에 백엔드와 문제 속성을 명시하되 URL 자체는 전혀 노출하지 않는 경고가 남습니다. 이 사용 중단의 이면에서 엔드포인트 규칙은 이제 단일 공유 술어(전송 계층의 validate_endpoint_url)가 되어 Admin 임시 백엔드 프로브, 허브 작성 후보 작업, 허브 백엔드 동기화, 허브 백엔드 내보내기 표현 가능성 판정, 등록 백엔드 검증이 모두 이를 사용합니다. 설정 경로가 느슨해진 원인이었던 바이트 단위로 동일한 사본 세 개를 대체했으며, 프로브와 허브의 오류 계약은 표 기반 판정 테스트로 변경 없이 고정되어 있습니다. engine_stats.metrics_url의 기본 규칙에도 같은 사용 중단 단계가 적용되며, 이 필드의 http(s) 스킴, 동일 호스트, 다운그레이드 금지 제약은 지금처럼 하드 오류로 유지됩니다.

Fixed

  • 엔진 부하 히스테리시스, 정규화, 결정 레이블, 스코어러의 짧은 캐시를 요청마다 확정된 라이브 후보 집합으로 한정합니다. 이전에는 모델 가시성, 상태, 서킷/재시도 상태, 키별 권한으로 제외된 백엔드도 풀에서 추적 중이면 balance 게이트를 열거나 선호 백엔드 메트릭을 정할 수 있었고, 한 후보 집합의 캐시된 결과가 100ms 캐시 구간 안에서 같은 접두사를 가진 다른 요청을 조종할 수 있었습니다. 이제 후보 이름을 스코어링 컨텍스트와 캐시 정체성에 정규화해 넣으므로 비후보가 선택에 영향을 줄 수 없습니다.

  • 하트비트 인벤토리의 BackendInfo.provider를 생성된 백엔드 객체의 런타임 타입이 아니라 운영자가 설정한 타입에서 보고하도록 수정했습니다. 이는 UsageRecord.provider가 이미 사용하는 소스와 같으며, 런타임 타입은 핫 리로드로 이름이 바뀌는 사이 라이브 설정 항목이 없는 풀 백엔드의 폴백으로만 남습니다 (#1454, 에픽 #1448 Phase 4의 첫 결함). control_plane.enabled: true로 운영 중인 기존 배포에는 허브에 보이는 동작 변경입니다. ollama, lmstudio, sglang으로 설정한 백엔드는 이전에 provider를 vllm으로 보고했고(셋 다 공유 vLLM 백엔드 구조체로 서빙), mlxcel은 llamacpp로, bedrock은 anthropic(mantle/runtime 엔드포인트) 또는 bedrock-converse(converse 엔드포인트)로, continuumrouter는 하이픈이 들어간 continuum-router로, azure는 엔드포인트 URL에 리터럴 부분 문자열 azure가 없으면 openai로 보고했으며(프라이빗 엔드포인트나 커스텀 DNS를 쓰는 모든 Azure OpenAI 배포가 여기에 해당), llama.cpp로 자동 감지된 generic 백엔드는 llamacpp로 보고했습니다. 이제 각 백엔드는 설정된 타입을 그대로 보고하므로 인벤토리와 사용량이 모든 백엔드에서 일치합니다. 영향받는 백엔드 타입 운영자를 위한 마이그레이션 안내: 이 와이어 필드는 단순 문자열이라 PROTOCOL_VERSION은 바뀌지 않고, BackendInfo.stable_id가 회계 정체성으로 유지되므로 비용 센터 귀속이 이동할 수 없습니다. 다만 인벤토리 provider 문자열을 키로 쓰는 허브 측 그룹핑, 대시보드, 정책은 이 수정이 포함된 라우터를 배포하기 전에 넓어진 어휘(ollama, lmstudio, sglang, mlxcel, bedrock, continuumrouter, generic)를 수용해야 합니다.

  • AppProxy 리플리카의 백엔드 타입을 코디네이터의 runtime_variant에서 리터럴 vllm 하나만 대조하는 대신 공유 프로브 토큰 어휘를 통해 결정하도록 수정했습니다 (#1455, 에픽 #1448 Phase 4의 마지막 작업). 이전에는 reconcile이 vllm만(ASCII 대소문자 무시) 받아들이고 sglang을 포함한 나머지 토큰을 전부 generic으로 매핑했기 때문에, vLLM이 아닌 엔진으로 서빙되는 리플리카는 라우터가 백엔드 타입에서 끌어내는 동작을 모두 잃었습니다. 이제 parse_probe_backend_type을 거쳐 로컬에서 서빙되는 엔진 타입 여섯 가지 vllm, sglang, llamacpp, mlxcel, ollama, lmstudio를 대소문자 구분 없이 받아들이며, 구두점 표기 sg-lang, sg_lang, llama-cpp, llama_cpp, llama.cpp, mlx-cel, mlx_cel, lm-studio, lm_studio도 함께 인식합니다. variant가 없는 경우, 빈 문자열, 알 수 없는 토큰, 명시적인 generic 토큰, 그리고 모든 클라우드 프로바이더 토큰(openai, anthropic, gemini, azure, bedrock, continuumrouter)은 의도적으로 여전히 generic으로 해석됩니다. AppProxy 리플리카는 http://kernel_host:kernel_port로 접근하는, 로컬에서 서빙되는 OpenAI 호환 HTTP 서버이며 클라우드 타입을 고르면 잘못된 인증 리졸버와 엔드포인트 기본값이 선택되기 때문입니다. 같은 변경으로 바이트 단위까지 동일했던 매핑 사본 두 개(src/appproxy/common/reconcile.rs와 src/appproxy/router/reconcile.rs)를 공유 리졸버 하나로 합쳐, 레거시 워커 경로와 ROUTER 프런트엔드 모드 경로가 같은 구현을 사용합니다. vLLM이 아닌 엔진으로 AppProxy 서킷을 운영하는 운영자를 위한 마이그레이션 안내: 백엔드 이름은 그대로(appproxy-<circuit_id>-r<route_key>)라서 reconcile은 여전히 멱등하고 업그레이드로 백엔드 집합이 요동치지 않으며, 타입 필드만 달라집니다. 새로 타입이 붙은 리플리카가 얻는 것은 제네릭 기본값을 대신하는 엔진 고유의 헬스 체크 계약(sglang은 /health를 프로브하고 /v1/models로 폴백하며 엔진 시작·종료 중의 503은 워밍업으로 읽습니다. llamacpp와 mlxcel은 /health를 프로브하고 /v1/models로 폴백하며, ollama는 /api/tags를 프로브하고 /로 폴백하고, lmstudio는 /v1/models를 프로브하고 /api/v1/models로 폴백합니다), generic 팩토리 분기가 소모하던 리플리카별 /v1/models 자동 감지 프로브와 그에 딸린 핫 리로드 타입 감지 라운드(감지된 타입을 실행 중인 설정에 되써서 두 번째 설정 변경을 전파할 수 있었습니다)를 건너뛰는 백엔드 생성, vLLM 계열이 이미 그러했듯 sglang 리플리카의 /v1/messages/count_tokens를 추정으로 대체하지 않고 엔진의 /v1/tokenize로 처리하는 동작, 그리고 engine_stats.enabled가 참일 때 최상위 engine_stats 폴러(#1446)에 참여하는 것입니다. AppProxy는 백엔드별 engine_stats 오버라이드 블록을 합성하지 않으므로 전역 스위치만이 이를 좌우합니다.

  • KV 캐시 인식 백엔드 선택의 결함 다섯 건을 수정하고, 문서가 이미 서술하고 있던 라우팅 결정을 실제로 기록합니다 (#1444, PR #1449). kv_cache_index.scoring 블록이 스코어러에 배선된 적이 없어 스코어러는 하드코딩된 기본 가중치로 동작했고, 백엔드 부하를 풀의 in-flight 추적기가 아니라 #971 이후 죽어 있던 BackendStats 필드에서 읽었으며, 인덱스에 항목이 없는 백엔드가 부하와 상태 점수만으로 채점되어 인덱스가 선택 불가 백엔드의 데이터만 보유한 상황에서도 채점 선택을 이길 수 있었고, min_overlap_threshold가 검사를 무의미하게 만드는 상수와 비교되어 StorageWarm 전용 보유자가 게이트를 통과했습니다. 이제 선택은 단일 지점에서 선택당 한 번 kv_aware, KV 인덱스 fallback, 그리고 prefix_hash·overflow·모델명 해시 폴백 계열을 기록하며, NaN 하나가 히스토그램 _sum을 영구히 오염시키지 못하도록 유한하지 않은 오버랩 표본은 버립니다. 겉보기보다 적게 동작하는 설정 두 가지는 시작 시점과 config validate에서 경고로 표시됩니다. prefix_routing.enabled: false와 함께 쓴 selection_strategy: PrefixAwareHash는 상한 없는 모델명 일관 해싱으로 격하되며, gpu_tier_weight가 min_overlap_threshold보다 낮은 kv_cache_index.scoring 블록은 채점 선택을 등록해 두고도 영구히 무동작 상태로 둡니다. docs/en/architecture/kv-cache.md와 한국어 대응 문서는 수정 후 동작에 맞춰 다시 작성되었습니다.

  • KV 오버랩 스코어러의 캐시된 패스를 TTL만이 아니라 접두사 키로 검증합니다 (PR #1474). cached_result_for()는 둘 다 검사했지만 score()는 캐시 슬롯을 직접 읽고 100ms TTL만 확인했기 때문에, 그 구간 안에서 어떤 요청이 다른 요청의 접두사 오버랩으로 채점될 수 있었고 요청된 접두사를 전혀 보유하지 않은 백엔드가 채점 선택에서 그대로 이길 수 있었습니다. 실측값은 기본 임계값 0.3에 대해 0.9였고, 이는 설정된 선택 전략까지 덮어씁니다. 이제 CachedResult가 16진 문자열 대신 32바이트 원본 접두사 키를 보관하고 두 읽기 지점이 모두 그 값으로 검증하므로, 검사는 할당 없는 비교가 되고 술어는 둘에서 하나로 줄어듭니다. 이 불일치는 최초 접두사 오버랩 채점 구현(PR #473)까지 거슬러 올라가며 PR #1462의 보안 리뷰에서 발견되었습니다.

  • 유한하지 않은 kv_cache_index.scoring 가중치를 설정 로드 시점에 거부합니다 (#1460, PR #1461). 이 섹션의 모든 범위 검사가 < 또는 RangeInclusive::contains로 비교하는데 NaN과의 비교는 모두 거짓이므로, YAML .nan은 가중치 다섯 개 전부를, .inf는 두 tier 가중치를 통과했습니다. NaN 가중치는 모든 보유자의 점수를 NaN으로 만들고 그 값은 채택되지 않으므로, KV 인식 선택은 스스로를 활성 상태로 보고하면서 매 요청마다 설정된 전략으로 흘러내립니다. 무한대 tier 가중치는 그런 보유자 중 첫 번째가 영구히 이기게 만들어 동일하게 캐시된 보유자들 사이의 부하 분산을 멈춥니다. 유한성 게이트는 섹션 검증기와, 더 중요하게는 시작·config validate·핫 리로드·설정 감시자·control-plane 설정 동기화·Admin 설정 PUT/PATCH가 모두 거쳐 가면서도 kv_cache_index를 전혀 건드리지 않던 validate_config_with_limits에 추가되었습니다. 거부되는 상태에 도달하려면 .nan이나 .inf를 문자 그대로 입력해야 하므로 동작 중인 배포가 회귀할 일은 없습니다.

  • 정확한 메트릭 증분을 단언하는 src/metrics/kv_cache.rs 테스트들을 직렬화합니다 (PR #1459). KvCacheMetrics::new는 모듈의 lazy_static 메트릭 복제본을 테스트 전용 Registry에 등록하지만, prometheus 메트릭 복제본은 하나의 Arc 코어를 공유하므로 새 레지스트리가 값을 격리해 주지 않고 두 테스트 쌍이 각자 서로의 동시 기록자였습니다. 한 쌍은 12회 실행 중 6회 실패로 측정되었습니다. 모듈의 단일 카운터 잠금은 모듈 안 모든 프로세스 전역 메트릭을 포괄하는 GLOBAL_METRIC_WINDOW_LOCK 하나로 대체되어 증분을 단언하는 일곱 테스트가 기록 전후 구간 전체에 걸쳐 잠금을 잡으므로, 어떤 잠금을 잡을지 테스트마다 판단할 일이 없어집니다.

Security

  • 정상 동작 경로의 로그에 기록되던 백엔드 URL 인라인 자격증명을 가립니다 (#1469, PR #1472). http://svc:pw@host/v1 형태의 백엔드 URL이 설정 로드마다 백엔드당 한 번, 트래픽이 전혀 없어도 상태 전이마다 백엔드당 한 번, 그리고 프록시된 요청마다 다시 INFO 수준으로 그대로 기록되었습니다. reqwest는 그 userinfo를 Authorization: Basic 헤더로 올려 보내므로 이는 잘못된 설정이 아니라 실제로 동작하는 배포 형태이며(보통 basic-auth 역방향 프록시 뒤의 자체 호스팅 엔진), 기본 logging.level이 info이므로 운영자 실수 없이도 노출이 켜져 있었습니다. 27개 파일 67개 지점 전부가 로그에 넘기는 값을 redact_endpoint로 감싸며, reqwest에 넘기는 값은 모든 지점에서 그대로입니다. 각 지점에서 조합된 URL이 곧 실제 요청 대상이자 로그 값이기 때문에 리다이렉션은 compose_backend_url과 여섯 형제 함수로 옮길 수 없고 호출 지점에 남습니다. 68번째 지점은 소스 스캔 감사 테스트가 막습니다.

  • 미지원 스킴 전송 오류가 반향하던 백엔드 URL을 가립니다 (#1467, PR #1471). 비밀번호가 담긴 ftp:// 백엔드 URL로 실제 재현했을 때 부팅 한 번과 비스트리밍·스트리밍 요청 각 한 번에서 그 비밀번호가 담긴 로그 줄이 7개 모듈에 걸쳐 9줄 나왔습니다. 클라이언트에 반환되는 본문은 이미 고정 문자열이었으므로 노출 대상은 로그뿐이었습니다. 이제 TransportError::UnsupportedScheme은 Display 속성 안에서 가리는 대신 생성 시점에 가려진 값을 보관합니다. 이 타입은 Debug도 파생하므로 Display에서만 가리면 {:?} 출력으로 비밀번호가 그대로 나가기 때문입니다. 운영자가 입력한 URL을 로그 매크로에 그대로 넣던 호출 지점 아홉 곳도 함께 감쌌습니다. 별개로, 진단에 표시되는 스킴은 split("://").next()로 얻고 있었는데 구분자가 없으면 str::split이 문자열 전체를 내주므로 스킴이 없는 값이 자기 자신을 스킴으로 표시했습니다. 교체된 구현은 RFC 3986 스킴 문법을 만족할 것을 요구하고 그렇지 않으면 unknown을 보고합니다.

  • 두 설정 마스커에서 Redis 연결 URL 자격증명을 가립니다 (#1468, PR #1470). Redis URL은 비밀번호를 인라인으로 담는데 두 마스커 모두 이를 가리지 않았습니다. 기본 빌드에서는 Admin API가 조건 없이 마운트되고 admin.auth 기본값 None이 허용 후 경고로 처리되며 바인드 주소가 0.0.0.0:8080이므로, GET /admin/config/full이 그 포트에 닿을 수 있는 누구에게나 비밀번호를 반환했습니다. SENSITIVE_FIELDS에 url을 넣는 방식은 backends[].url까지 가려 버리고 그것을 막으려는 테스트가 이미 두 개 있으므로, 각 마스커는 대신 redis 키 바로 아래에 있는 url만 비밀로 취급하고 이름만 보는 술어는 바이트 단위로 그대로 둡니다. 가려진 값은 부분 리다이렉션이 아니라 기존 sentinel입니다. 부분적으로 가려진 URL은 현재 sentinel도 레거시 형태도 아니어서, 편집하지 않은 마스킹 결과를 POST /admin/config/import로 되돌리면 그 문자열이 그대로 실 Redis URL로 기록되기 때문입니다.

  • 잘못된 URL 설정 오류가 반향하던 백엔드 URL을 가립니다 (#1463, PR #1464). validate_backends_config가 원본 backend.url을 파싱 실패 메시지에 그대로 넣었기 때문에, 형식이 잘못되고 자격증명이 담긴 URL은 시작 시 stderr 줄, 핫 리로드 오류 로그, config validate JSON 보고서, MCP 도구 응답, 그리고 여섯 개 Admin API 응답 본문과 해당 핸들러의 warn! 줄에 비밀번호를 남겼습니다. 이제 메시지는 백엔드 이름을 명시하고 URL을 redact_endpoint로 렌더링하며 url::ParseError를 보존합니다. ***로 축약되는 값을 그래도 진단 가능하게 남기는 것이 이 오류값입니다. 제어 흐름과 상태 코드는 그대로입니다.

  • Debug 출력에서 Redis URL 비밀번호를 가립니다 (#1465, PR #1499). RedisCacheBackendConfig와 RedisConfig는 같은 파일에서 비밀을 지우려고 Debug를 직접 구현한 형제 설정 타입 다섯 개와 달리 평범한 Debug를 파생해 자격증명이 담긴 url을 그대로 출력했고, 두 타입 모두 Config 자신의 파생을 통해 도달할 수 있습니다. 현재 두 타입을 Debug로 포매팅하는 프로덕션 호출 지점은 없으므로 이는 활성 유출이 아니라 잠재 구멍을 닫는 변경입니다. 위험은 다음에 추가될 debug!(?config)나 새 #[derive(Debug)] 래퍼가 Redis 비밀번호를 통째로 출력하는데 코드에도 CI에도 이를 막을 장치가 없다는 점이었습니다.

  • Admin 백엔드 프로브 응답의 백엔드 URL 자격증명을 가리고, 로그 리다이렉션 감사 스캔이 인라인 포맷 캡처를 보도록 넓힙니다 (#1498, PR #1501). POST /admin/backends/{name}/check가 checked_url로, 그리고 상태 검사 오류 문자열을 통해 error로 백엔드 URL을 반환했습니다. 이제 두 값 모두 각자가 조합되는 지점에서 가려지므로 같은 오류를 출력하는 백그라운드 모니터의 debug!까지 함께 덮이며, unix:<path> 주소는 ***로 축약되지 않고 전체가 보고됩니다. 더 오래 남는 절반은 감사 스캔입니다. #1469가 추가한 스캔은 url로 끝나는 식별자를 찾기 전에 문자열 리터럴을 지웠기 때문에 Rust 2021의 관용적 표기인 info!("target: {backend_url}")가 보이지 않았고, 이는 가리지 않은 프로브 지점이 수정 전 스캔에서 종료 코드 0으로 통과하고 넓힌 스캔에서 101로 실패하는 것으로 실증했습니다. 넓힌 스캔은 #1469가 볼 수 없었던 진짜 유출 한 건, 즉 OAuth 백엔드 상태 검사 건너뛰기 줄을 드러냈고(OAuth 백엔드에 대해 상태 검사 라운드마다 발동합니다) 이는 허용 목록에 넣지 않고 가렸습니다.

CI

  • cargo update를 먼저 실행하는 대신 Cargo.lock이 고정한 의존성 버전을 빌드합니다 (PR #1483). 모든 CI 실행이 그 시점의 최신 버전을 해석했기 때문에 아무도 설치하지 않는 의존성 집합을 검증했고, 빨간 결과가 실제 결함 때문인지 밤사이 움직인 의존성 때문인지 구분할 수 없었습니다. 부동 의존성 검증은 릴리스 준비 시점의 make update-deps와 새 주간 워크플로 dependency-float.yml에 남아 있습니다. scripts/local-ci.sh는 애초에 업데이트 단계를 실행하지 않았으므로, 이 변경은 세 곳에 문서화되어 있던 둘 사이의 차이도 함께 없앱니다.

  • ci 잡을 자체 호스팅 러너 세 대로 분할합니다 (#1484, PR #1487). 열세 단계가 한 대에서 순차로 약 40분 동안 돌았고, 그중 83%는 직전 단계와 공유되지 않는 기능 집합으로 rustc가 워크스페이스를 다시 컴파일하는 시간이었습니다. Cargo가 빌드 산출물을 해석된 기능 집합 그대로를 키로 삼기 때문입니다. 한 단계는 8.1초 분량의 테스트를 실행하려고 789초를 썼습니다. 러너 라벨에 응답하는 기계는 세 대인데 두 대가 실행 내내 놀고 있었습니다. 잡은 같은 기능 그래프를 빌드하는 단계끼리 묶어 ci-default, ci-isolated-graphs, ci-release-graph로 나뉘며 각각 723초, 942초, 1037초로 측정되었으므로 벽시계 시간은 이제 셋의 합이 아니라 셋 중 최댓값입니다. 모든 단계의 명령줄은 그대로입니다. --tests가 이미 라이브러리 유닛 테스트 대상을 선택하므로 중복 실행되던 cargo test --lib은 제거되었습니다.

  • Cargo 빌드 디렉터리를 체크아웃 밖으로 옮깁니다 (#1494, PR #1496). actions/checkout이 매 잡 시작 전에 git clean -ffdx를 실행하고 target/은 gitignore 대상이므로 빌드 디렉터리가 매 잡마다 삭제되어 모든 CI 빌드가 콜드 상태였습니다. 체크아웃 로그의 삭제 목록에 target/이 그대로 찍혀 있습니다. 이제 로컬 복합 액션이 빌드 디렉터리를 워크스페이스 밖 러너별 영구 경로로 지정하고 80GB를 넘으면 비우므로, 오래된 캐시의 최악 사례는 빨간 실행이 아니라 느린 실행 한 번입니다.

Dependencies

  • Cargo.lock을 semver 호환 최신 버전으로 갱신합니다 (PR #1497). 13개 크레이트가 패치 또는 마이너 단위로 이동하며(aws-sdk-sts, chacha20, combine, cpufeatures, deadpool, deadpool-redis, h2, hyper, indexmap, libredox, lru, rand, uuid), 추가되거나 제거된 것은 없고 해석된 그래프는 양쪽 모두 477개 패키지로 동일합니다. 보안 권고에 따른 갱신은 아니며, 2026-08-29로 갱신한 권고 데이터베이스 기준으로 cargo deny check가 이전 잠금 파일과 갱신된 잠금 파일 모두에서 통과합니다.

v1.25.0 - 2026-08-26

v1.24.1 이후 커밋 24개입니다. vLLM 백엔드 하나로 부하를 받던 Backend.AI GO 배포에서 보고된 결함 네 건을 함께 고쳤습니다. 걸리지 않던 warmup 타임아웃, 취소된 스트리밍 요청이 half-open 서킷 프로브를 흘리던 문제, 백엔드가 하나뿐일 때 재시도 소진이 업스트림 오류를 라우터가 만든 503으로 가리던 문제, 폴백 체인이 여전히 서빙하는데도 모델이 카탈로그에서 사라지던 문제입니다. 루트 크레이트와 성능 하네스는 Rust 2024로 옮기고 rust-version = "1.95"를 선언합니다. 추가된 기능 셋은 Hub 연동과 런타임 백엔드 관리를 넓힙니다. 모델별 예산 폴백, Admin API나 WebUI로 만든 백엔드의 영속 사이드카, 라우터 자신에서 수행하는 등록입니다.

Added

  • /v1/chat/completions와 /v1/completions에 model_budget_fallback_v1 능력으로 게이트되는 Hub 지시 모델별 예산 폴백을 추가했습니다 (#1410, PR #1430). 유효 프로바이더와 모델에 대해 Hub가 작성한 월 예산이 소진되고 티어가 폴백을 허용하면, 라우터는 429 insufficient_quota를 반환하는 대신 순서상 첫 번째 적격 대상으로 디스패치합니다. 후보는 티어 허용 목록, 키별 백엔드 권한, 가시성, 프로바이더 동일성, 헬스, 서킷 상태와 교집합을 취하며, 재시도와 스트리밍 페일오버도 승인된 오퍼링으로 제한됩니다. 사용량 레코드는 실제 서빙된 식별자와 예산 적용 이전에 요청된 식별자를 모두 보존하고, 폴백은 경계가 정해진 메트릭과 스트리밍/비스트리밍 응답 헤더로 보고됩니다. lablup/continuum-hub#909의 라우터 측 구현이며, 능력은 영속 usage-sequence 상태와 집행 경로가 붙은 뒤에만 광고됩니다.
  • Admin API나 WebUI로 만든 백엔드를 소유자 전용 YAML 사이드카에 영속화하는 옵트인 기능을 추가했습니다 (#1423, PR #1436). 런타임 백엔드 등록은 메모리에만 존재해 재시작할 때도, 설정 파일이 리로드될 때도 사라졌습니다. 런타임 API 키에는 api_keys.persistence_file이라는 같은 목적의 장치가 이미 있었습니다. 이제 로컬 생성/수정/삭제 변경이 원자적으로 기록되고 시작 시 복원되며, 파일에 정의된 백엔드와 Hub 관리 백엔드는 사이드카에 들어가지 않고, Admin API와 WebUI가 백엔드마다 설정 파일/메모리 전용/영속 중 어느 출처인지 표시합니다. Continuum Hub의 overlay, authoritative 소유 모드 동작도 정의하고 테스트했습니다.
  • 라우터 자신에서 Continuum Hub 등록을 수행할 수 있게 했습니다 (#1424, PR #1437). 지금까지 등록은 YAML의 control_plane.enrollment_token을 고치고 재시작하는 길뿐이었고, 될 것처럼 보이던 Admin 경로는 실제로 반영되지 않았습니다. 기능 게이트된 Admin 엔드포인트가 쓰기 전용 토큰을 교환해 Hub가 발급한 소유자 전용 자격 증명만 영속화하고, 실수로 인한 교체를 거부하며, 거부됨과 재시작 대기 상태를 노출하고, 시작 시 등록과 Admin 등록을 직렬화하며, 거부된 자격 증명이 상태 파일을 손대지 않고도 복구되게 합니다. Integrations 페이지에는 대응하는 입력 폼이 생겼습니다. control_plane의 나머지 설정은 시작 시점 소유로 남고, 런타임 쓰기는 명시적 오류를 냅니다.

Changed

  • 루트 크레이트와 perf/ 하네스에 Rust 2024를 활성화하고 rust-version = "1.95"를 선언했습니다 (#1417, #1418, PR #1420, #1421). 소스 준비는 별도 패스로 먼저 들어갔습니다. 기계적으로 적용 가능한 rust-2024-compatibility 재작성을 전부 적용하고, if let 임시값에서 컴파일러가 사소하지 않을 수 있는 소멸자를 지목한 자리는 명시적 match로 바꿨으며, 209개 지점의 tail-drop 경고 215건을 감사해 관측 가능한 순서를 지켜야 하는 네 곳에만 명시적 바인딩을 남겼습니다. 저장소 툴체인은 1.97.1 핀을 유지하고, rustfmt.toml은 style_edition = "2021"을 유지해 이번 전환이 저장소 전체 재포맷이 되지 않게 했으며, 벤더링된 crates/continuum-protocol은 업스트림 Continuum Hub 워크스페이스에 맞춰 edition 2021과 Rust 1.88에 남겼습니다.
  • 헬스 체커 설정을 시작 시점과 핫 리로드 양쪽 모두에서 권위 있는 health_checks 섹션으로부터 해석하게 했습니다 (#1395, PR #1429). 시작 경로는 timeouts.health_check.*를, 핫 리로드 경로는 health_checks.*를 읽어서, 같은 파일이 부팅 때는 30초 프로브를, 리로드 뒤에는 5초 프로브를 만들었고, --disable-health-checks, --health-check-interval, --health-check-timeout은 시작 경로가 읽지 않는 필드에 값을 썼습니다. 이제 실패 가능한 리졸버 하나가 두 경로를 모두 담당하고, health_checks.enabled는 24시간 간격으로 흉내 내던 것 대신 실제 라이프사이클 의미를 갖습니다. enabled, block_startup, prewarm_timeout은 재시작이 필요하고, 주기·임계값·엔드포인트·warmup 설정은 점진적 리로드로 남습니다.
  • 컨트롤 플레인 아웃박스 확인 응답의 Result<(), ()> 반환 두 곳을, 오래된 결과와 사용 불가 상태와 영속 저장 실패를 구분하는 공개 타입 AcknowledgeError로 교체했습니다 (#1404, PR #1435). 반복 확인 응답은 여전히 멱등이고, 에이전트의 두 드레인 지점은 이제 정확한 사유를 로그로 남기며 영속 쓰기 실패만 재시도 대상으로 표시합니다.

Deprecated

  • timeouts.health_check은 파싱되지만 무시됩니다 (#1395, PR #1429). health_checks 섹션과 다른 값이 들어오면 로드 시점에 해당 필드가 무효임을 알리는 충돌 경고가 나옵니다. Admin API, MCP 도구 표면, 문서, 템플릿, 예제 설정, 생성된 IDE 규칙을 같은 계약으로 맞췄습니다.

Fixed

  • 백엔드 warmup 타임아웃을 래치해 멈춘 백엔드가 Unhealthy에 정착하도록 고쳤습니다 (#1396, PR #1415). WarmingUp에서 빠져나오는 전이가 record_failure()를 호출했고 이 함수가 warmup_started_at을 지웠기 때문에, 다음 HTTP 503 프로브가 warmup 타이머를 새로 시작했습니다. 503을 무한히 내보내는 백엔드는 프로브 주기마다 WarmingUp과 Unhealthy를 오갔고 max_warmup_duration은 사실상 무제한이었습니다. 선택적 타임스탬프 대신 미시작/활성/타임아웃을 구분하는 명시적 상태 기계를 두었고, 래치는 성공적인 헬스 신호가 풀어 주기 전까지 일반 실패에도 유지되며 같은 URL의 백엔드 이름 변경을 건너서도 보존됩니다.
  • 가속 프로브를 필요한 백엔드에만 적용하도록 고쳤습니다 (#1397, PR #1419). WarmingUp이거나 Unknown이거나 최근 추가된 Unhealthy 백엔드가 하나만 있어도 전체 플릿이 30초 정상 간격 대신 1초 warmup 간격으로 넘어가, 건강한 백엔드 전부에 대한 프로브 트래픽이 약 30배로 늘었습니다. 이제 스케줄러가 백엔드별로 단조 증가하는 마지막 디스패치 예약을 추적해 매 주기 가속 대상과 정상 대상을 독립적으로 선택하고, 결과를 안정적인 URL 동일성으로 대조하며, 네트워크 await를 건너 헬스 상태 락을 잡지 않고, 핫 리로드로 주기 설정이 바뀌면 깨어납니다.
  • 제네릭 스트리밍 경로에서 자체 호스팅 백엔드의 reasoning_effort를 Gemini 어휘로 검증하지 않게 했습니다 (#1393, PR #1416). 최초 시도와 폴백 시도가 공유하는 페이로드 헬퍼가 모든 백엔드에 Gemini 정규화를 적용해, reasoning_effort: "max"나 "none"을 담은 vLLM 요청이 다시 쓰였습니다. Gemini 정규화는 Gemini 백엔드 경로 안으로 되돌렸고, OpenAI 계열 정규화와 stream_options.include_usage는 그대로입니다.
  • 스트리밍 요청이 백엔드 결과가 나오기 전에 취소되면 half-open 서킷 프로브를 반납하도록 고쳤습니다 (#1409, PR #1427). 채팅 스트리밍 경로가 proxy::circuit::admit을 직접 호출하고 핸드셰이크 뒤에 결과를 손으로 기록했기 때문에, 그 사이에 클라이언트가 끊으면 record_success, record_failure, record_ignored 중 아무것도 실행되지 않았습니다. HalfOpen 상태에서 이는 half_open_max_requests 슬롯 하나를 흘리는 것이고, 상태 기계는 미결 프로브가 남아 있는 한 HalfOpen을 벗어나지 않으므로, 세 번 새면 재시작하거나 Admin 서킷 리셋을 호출할 때까지 해당 백엔드로 가는 모든 요청이 503으로 거부되었습니다. 이제 모든 핸드셰이크와 비스트리밍 재시도 디스패치가 기존 Admission 가드를 거치며, 표준·폴백·네이티브 프로바이더·유닉스 소켓 경로 전반에서 먼저 확정된 결과가 이깁니다.
  • 원자적 교체 방식 저장에도 설정 핫 리로드가 계속 붙어 있도록 고쳤습니다 (#1398, PR #1428). 워처가 EventKind::Modify(_)만 처리하고 설정 파일 경로 자체를 감시했기 때문에, rename-into-place 저장(vim, VS Code의 atomic save, sed -i, 프로비저닝 도구, 쿠버네티스 ConfigMap 심볼릭 링크 교체)은 리로드를 일으키지 않았고, inotify 감시가 삭제된 아이노드에 붙은 채 남아 이후 모든 변경이 재시작 전까지 누락되었습니다. 감시 대상을 논리적 대상의 상위 디렉터리로 옮기고, 파싱과 검증을 notify 콜백 밖의 경계가 정해진 디바운스 워커로 옮겨 마지막 정상 리비전을 보존합니다.
  • 재시도 소진 후 남은 후보가 없을 때 실제 업스트림 실패를 반환하도록 고쳤습니다 (#1408, PR #1441, #1442, #1443). 모델을 서빙하는 백엔드가 하나뿐일 때 첫 시도가 재시도 가능한 오류(429, 502, 503, 504, 또는 first_byte 타임아웃)로 실패하면 두 번째 시도에 후보가 없어 라우터가 만든 503 AllBackendsUnhealthy로 끝났고, 큐 압력에서 온 일시적인 vLLM 429가 죽은 백엔드와 구분되지 않았습니다. 이제 마지막으로 디스패치된 백엔드 실패를 업스트림 HTTP 상태, 프로바이더 메시지, Retry-After, 증명 가능한 백엔드 귀속과 함께 비스트리밍·스트림 이전·스트림 도중 경로 전반에서 보존합니다. 첫 시도의 unhealthy 선택은 라우터가 작성한 오류로 남고, first-byte 소진은 백엔드별 504로 보고됩니다. 예전의 일반화된 오류를 단언하던 통합 계약 두 건도 같은 흐름에서 바로잡았습니다.
  • 유일한 서빙 백엔드가 unhealthy인 동안에도 모델을 카탈로그에 유지하도록 고쳤습니다 (#1422, PR #1432, #1438). 디스커버리가 해당 백엔드의 항목을 지웠기 때문에, 추론은 설정된 폴백 체인으로 여전히 정확히 라우팅하는 모델이 GET /admin/models, GET /v1/models, WebUI Models 페이지에서 사라졌고, /v1/models로 모델 선택기를 만드는 클라이언트에서는 폴백 경로에 아예 도달할 수 없게 되었습니다. 이제 강제 새로고침이 이전 집계를 먼저 지우지 않고 동기 팬아웃을 수행하며, 실패한 백엔드의 멤버십만 이월하되 카탈로그 크기 상한을 지키고 해당 백엔드의 현재 설정 허용 목록과 교집합을 취하며, 성공했거나 제거되었거나 비활성화되었거나 범위가 좁아진 백엔드의 모델은 되살리지 않습니다.
  • 엄격 모드 스마트 라우팅 분류기 스키마를 필수 필드만으로 제한해 OpenAI가 받아들이게 했습니다 (#1406, PR #1434). 공유 스키마가 reasoning을 선언하면서 required에 넣지 않았는데 strict structured output은 이를 거부하며, 이 필드는 라우팅 핫 패스에서 쓰이지도 않았습니다. 커스텀 complexity/domain 열거 확장은 그대로이고, 비엄격 응답에 대한 관대한 reasoning 파싱도 유지됩니다.
  • GPT-5 토큰 필드 호환 변환을 모든 OpenAI 디스패치 경로에 적용했습니다 (#1407, PR #1440). 스마트 라우팅 분류기가 max_tokens를 담은 OpenAI 형태 요청을 만들어 Backend::execute_chat_completion으로 바로 보냈고, 이 경로는 프록시 경로가 적용하는 max_tokens → max_completion_tokens 재작성 없이 본문을 그대로 전달했기 때문에 GPT-5.x 모델 대상 분류가 실패했습니다. 이제 멱등한 백엔드 경계 헬퍼 하나가 선택된 OpenAI/Azure 프록시, 스트리밍, 타입드 채팅, 타입드 스트림, 프로브, 변환, Anthropic 호환 경로에서 GPT-5, o1, o3의 토큰 상한 필드를 정규화하며, 정규 필드 우선순위와 샘플링 제어, extra_body, 그 밖의 필드를 보존합니다. Gemini와 자체 호스팅 프로바이더는 건드리지 않습니다.
  • WebUI 백엔드 타입 선택기에 MLxcel을 추가했습니다 (#1414, PR #1431). 라우터는 이 백엔드 타입을 이미 지원했지만 Add/Edit Backend 폼이 선택지를 제공하지 않아, WebUI로 MLxcel 백엔드를 추가하는 운영자는 타입을 잘못 지정할 수밖에 없었습니다.
  • AppProxy의 등록되었으나 다운된 서킷 폴백 테스트를 복구했습니다 (#1412, PR #1426). 폴백 적격성 판정이 요청 시점 Config 스냅숏으로 옮겨졌는데 픽스처는 FallbackConfig를 FallbackService에만 붙였고, 이는 프로덕션이 읽는 경로가 아닙니다.

CI

  • 네이티브 x86_64에서 perf/ 단위 테스트가 간헐적 SIGILL을 만났을 때 크래시 증거를 보존하게 했습니다 (#1403, PR #1433). 스위트 실행 전에 커널, Rust, Cargo, CPU 모델, CPU 플래그를 기록하고, 실패 시 코어 덤프와 정확히 대응하는 테스트 실행 파일, 두 파일의 해시, 테스트 로그, 가능하면 디버거 전체 출력을 보존하며, 압축 시간과 CPU 사용에 상한을 두고 압축이 끝나지 않으면 원본 코어를 남깁니다. GitHub 네이티브 x86_64 러너를 Rust 1.98.0으로 강제한 정식 실행 20회(perf 스위트 200개, 테스트 10,000건, 스모크 파이프라인 20회)에서는 크래시가 재현되지 않았으므로, 다음 발생이 남길 것은 이 실패 전용 진단 경로입니다.
  • .github/workflows/ci.yml과 scripts/local-ci.sh 양쪽에서 미러링되는 all-features all-targets 빌드 체크를 cargo clippy --all-features --all-targets --keep-going -- -D warnings로 승격했습니다 (PR #1435). 넓어진 게이트가 테스트 전용 헬스 설정 초기화 경고 하나를, 이후 테스트 환경 락 범위 관련 지적 하나를 더 찾아냈습니다 (PR #1439).
  • 호스팅 CI와 로컬 미러 양쪽에서 appproxy_ingress_test를 레거시 appproxy 기능으로 실행하게 했습니다 (PR #1426).

Documentation

  • 한국어 Extension Points 캐시 예제를 현재의 2단계 CacheStore/ResponseCacheStore 형태로 갱신하고, 실제로 제공되는 RedisCacheStore와 redis-cache 기능 게이트를 문서화했으며, 삭제된 서비스 타입을 제거했습니다 (#1382, PR #1425).

Dependencies

없음. Cargo.lock은 버전 번호 변경 외에 v1.24.1 이후 달라진 것이 없습니다.

v1.24.1 - 2026-08-24

v1.24.0 이후 커밋 네 개이고, 그중 둘은 지난 릴리스가 돌았기 때문에 생긴 것입니다. v1.24.0은 musl 아카이브 두 개와 컨테이너 이미지 없이 발행되었는데, 릴리스 워크플로가 크로스 컴파일 타깃을 실제로 빌드하는 툴체인이 아닌 다른 툴체인에 설치했기 때문입니다. macOS notarization은 개인 Apple ID에서 조직이 발급하는 App Store Connect API 키로 옮겼습니다. 나머지 둘은 Anthropic 인그레스 경로의 타임아웃 수정과 직접 의존성 메이저 갱신입니다.

Fixed

  • Anthropic 인그레스의 백엔드 데드라인을 모든 디스패치 경로에서 설정값으로 읽도록 고쳤습니다(#18, #1029, #1399, PR #1400). Anthropic Messages API 뒤의 핸들러들이 백엔드 데드라인을 고정값으로 들고 있어서, 요청이 그 인그레스로 들어오는 순간 timeouts.request와 model_overrides 항목이 모두 무시되었습니다. 실제로 vLLM 요청 하나가 300.027초에 잘렸고, 설정된 스트리밍 데드라인에서는 같은 요청이 952초에 완료되었습니다. 이제 버퍼링 호출은 timeouts.request.standard.total을 쓰고, 진짜 SSE 호출은 streaming.first_byte·chunk_interval·total을 응답 개시와 본문 소비에 걸쳐 각각 독립적으로 무장되는 데드라인 셋으로 씁니다. 적용 범위는 네이티브 HTTP, Unix 소켓, OpenAI·Responses 변환, Bedrock Runtime 다섯 경로이며 모델별 오버라이드도 전부 반영합니다. 응답 모드는 payload.stream에서 추론하지 않고 요청 빌더에 타입 값으로 전달되는데, 이것이 Responses 브리지가 상류로는 스트리밍하면서 비스트리밍 클라이언트에는 버퍼링할 때 정확성을 유지하는 지점입니다. 모든 타임아웃 단계는 요청 진입 시점에 잡은 불변 CoreConfig 스냅숏에서 해석하고, 기동 시 캐시는 파싱 오류 대비 폴백으로만 남깁니다. SSE 스트림에서 타임아웃은 전송 오류와 구분해 분류하며, Unix·타입드 스트리밍 경로가 빠뜨리던 요청 실패·서킷 브레이커 집계를 채웠습니다. 또 standard.total을 버퍼링 Unix 디스패치의 절대 외곽 데드라인으로 요청 쓰기까지 포함해 적용합니다. 전송 계층의 응답 읽기 타임아웃만으로는 이 구간을 묶을 수 없었습니다.

CI

  • 루트 라우터 크레이트와 독립 perf/ 하네스에서 Rust edition 2024를 활성화하고 두 매니페스트 모두에 rust-version = "1.95"를 선언했습니다(#1418). 명시적 MSRV는 Debian Build-Depends 하한 및 영어/한국어 소스 설치 안내와 맞추고, 저장소 툴체인은 재현 가능한 린트를 위해 Rust 1.97.1에 계속 고정합니다. 루트 rustfmt.toml은 style_edition = "2021"을 고정해 이 언어 edition 변경에 무관한 Rustfmt 2024 기계적 재작성까지 섞이지 않게 하며, 벤더링된 crates/continuum-protocol 크레이트는 upstream Continuum Hub 워크스페이스가 이동할 때까지 의도적으로 edition 2021 / Rust 1.88 혼합 edition 경계에 남겨 둡니다.
  • 릴리스 빌드의 크로스 컴파일 타깃을 고정된 툴체인에 설치하도록 고쳤습니다(PR #1405). rustup 타깃은 툴체인마다 따로 설치되고 rustup은 디렉터리의 rust-toolchain.toml을 기본값보다 먼저 해석하는데, .github/workflows/release.yml은 ${{ matrix.target }}을 dtolnay/rust-toolchain@stable로 설치하고 있었습니다. 그 액션은 rustup toolchain install stable --target <타깃>을 실행하므로 musl std가 1.98.0에 설치되었고, 정작 cargo build --target은 고정된 1.97.1에서 돌아 "can't find crate for core"로 실패했습니다. 그 결과 v1.24.0이 linux-x86_64-musl과 linux-aarch64-musl 아카이브 없이 발행되었습니다. 호스트 타깃 잡 네 개는 계속 통과했는데, 툴체인은 자기 호스트용 std를 항상 가지고 있기 때문입니다. ci.yml과 perf.yml에는 크로스 컴파일이 없어서 릴리스가 돌기 전까지 어떤 게이트도 이 경로를 밟지 않았습니다. 이제 워크플로가 rust-toolchain.toml에서 channel을 읽어 그 툴체인에 타깃을 설치하며, 설치와 빌드 사이의 검사가 활성 툴체인에 타깃이 없으면 컴파일 몇 분 뒤가 아니라 즉시 실패합니다.
  • 툴체인 고정 파일이나 clippy 설정이 바뀌면 Rust CI 잡을 돌리도록 했습니다(PR #1405). .github/workflows/ci.yml의 rust 경로 필터에 deny.toml과 rustfmt.toml은 있었지만 rust-toolchain.toml과 clippy.toml은 없었습니다. 둘 다 그 목록이 작성된 뒤에 추가된 파일입니다. 고정된 채널을 올리는 것은 빌드를 깨거나 새 린트를 드러낼 가능성이 가장 큰 변경인데, 그 변경이 Rust 매트릭스 전체를 건너뛰었을 것입니다.
  • macOS 릴리스 바이너리를 App Store Connect API 키로 notarize하도록 바꿨습니다(PR #1411). notarytool은 --apple-id와 앱 전용 암호로 인증하고 있었는데, 이 자격 증명은 특정 개인의 Apple ID에 묶입니다. 그 사람이 팀을 떠나거나 암호를 교체하거나 2FA를 초기화하면 작동을 멈추고 릴리스도 함께 멈춥니다. App Store Connect API 키는 조직이 발급하고 폐기합니다. 이름이 오해를 부르니 분명히 적어두면, 이 키는 App Store 전용이 아닙니다. notarization 자체가 App Store 바깥 배포를 위한 절차이고, App Store 제출물은 개발자가 notarize하지 않으며, notarytool은 --key·--key-id·--issuer를 --apple-id와 나란한 일급 자격 증명으로 문서화합니다. --issuer는 설정된 경우에만 전달합니다. Team 키에는 필수이고 Individual 키에서는 거부되기 때문입니다. .p8은 base64 인코딩과 원문을 모두 받으며, PKCS#8로 파싱되지 않는 키는 그 자리에서 보고합니다. 나중에 notarytool의 불투명한 invalidAsn1으로 나타나게 두지 않습니다. packaging 환경에는 AC_API_KEY_ID, AC_API_ISSUER_ID(Individual 키는 생략), .p8의 base64를 담은 AC_API_PRIVATE_KEY_P8이 필요하며, 없으면 notarization이 이름 있는 오류로 실패합니다. APPLE_ID·APPLE_TEAM_ID·APPLE_PASSWORD는 참조되지 않게 되므로, 이 경로로 릴리스가 한 번 나간 뒤 제거할 수 있습니다.

Dependencies

  • 직접 의존성 다섯 개를 현재 출시된 메이저로 올렸습니다(PR #1413). base64 0.22.1→0.23.1, jsonwebtoken 10→11, rmcp 2.2→3.1, serial_test 3.5→4.0, validator 0.20→0.21이며, resolver 호환 전이 의존성도 함께 갱신했습니다. 라우터 코드까지 온 API 변경은 rmcp 3.1의 다중 응답 타입 핸들러 표면 하나로, 설정 어시스턴트 MCP 서버가 기존의 완결된 도구·리소스 결과를 새 CallToolResponse·ReadResourceResponse enum으로 변환합니다. generic-array는 0.14.7, matchit은 0.8.4에 머무릅니다. crypto-common과 axum이 그 버전을 exact-pin하기 때문입니다.

v1.24.0 - 2026-08-22

타임아웃 하위 시스템을 설정 가능하고 일관되게 정리했습니다. timeouts: 섹션 전체를 제한하던 DoS 상한이 운영자 조정 대상이 되면서 기동 시점에 고정되고, first_byte는 실제로 의미가 있는 한 경로에서 동작하기 시작하며 의미가 없는 경로에서는 그런 척을 그만둡니다. 남아 있던 하드코딩 타임아웃에도 설정 표면을 붙였습니다. 같은 릴리스에서 툴체인도 고정해, 린트 게이트가 달력을 따라 바뀌는 일을 없앴습니다.

추가됨

  • 타임아웃 검증 상한을 새 timeouts.limits 블록으로 노출하고, max_standard_timeout 기본값을 180초에서 540초로 올렸습니다(#1401, PR #1402). TimeoutLimits는 timeouts: 섹션의 모든 값을 제한하는 방어선인데 설정 표면이 전혀 없었습니다. 컴파일된 상한보다 긴 예산이 필요한 운영자는 소스를 고쳐 다시 빌드하는 수밖에 없었고, 상한 두 개는 기본값과 정확히 같은 값에 붙어 있어 조정 여지가 0이었습니다. 기본 timeouts.request.standard.total은 그대로 180초이므로, 상한을 올린 것은 설정이 요구할 수 있는 값의 범위를 바꾼 것이지 어떤 배포의 동작을 바꾼 것이 아닙니다. 운영자가 값 자체를 올리기 전까지는 아무것도 달라지지 않고, 값을 올린 뒤부터는 멈춘 업스트림이 그만큼 오래 요청 슬롯을 차지하므로 connection과 헬스 체크 및 서킷 브레이커, server.max_concurrent_requests가 그 피해 범위를 제한하는 장치입니다. 이 블록은 부팅 설정에서 한 번 해석한 뒤 프로세스 수명 동안 고정되므로, 핫 리로드와 Admin API, 컨트롤 플레인 설정 동기화는 이를 바꾸는 후보를 모두 거부합니다. 런타임 경로나 허브가 작성한 경로가 라우터 자신의 상한을 넓힐 수 없게 하려는 것입니다. continuum-router config validate <파일>은 고정값이 없는 프로세스에서 실행되므로 검사 대상 파일에서 상한을 해석하며, 임의의 파일을 검증할 때는 그쪽이 올바른 동작입니다. 각 필드에는 컴파일 시점에 박힌 절대 상한도 있습니다(max_standard_timeout 1080초, max_retry_timeout 120초, max_streaming_timeout 3600초, max_connection_timeout 60초, min_chunk_interval 10초, max_chunk_interval 300초, max_image_generation_timeout 1800초, max_first_byte_timeout 1200초, large_model_bonus 1200초). 이 값을 넘기면 조용히 잘리는 대신 로드 오류가 납니다. extended_models는 하드코딩되어 있던 대형 모델 목록을 대체합니다. 그 목록은 gpt-4와 claude-3-opus에는 스트리밍 보너스를 주면서 정작 config.yaml.example이 설정하는 모델은 하나도 맞히지 못하고 있었습니다. 기본값도 은퇴한 목록 대신 현재 프런티어 티어(gpt-5.6-sol과 Terra·Luna 형제 모델, gpt-5-pro, gpt-5.5-pro, claude-fable-5, claude-opus-5, claude-opus-4-8, gemini-3.1-pro, gemini-3-pro)로 바꿨으므로 이 부분은 의도한 동작 변경입니다. max_streaming_timeout을 넘는 모델별 스트리밍 재정의가 현재 프런티어 모델에서는 통과하고 은퇴한 모델에서는 거부됩니다. 운영자가 목록을 지정하면 기본값을 통째로 대체하므로 양방향 모두 그쪽이 탈출구입니다. 같은 작업에서 retry.timeout의 설명도 재시도 루프가 실제로 구현한 의미에 맞췄습니다. 이 값은 모든 시도를 아우르는 예산 하나이며 시도 사이에만 확인되고 진행 중인 시도를 끊지 않습니다. 그리고 "총 재시도 가능 시간" 경고는 그대로 가져가는 대신 삭제했습니다. 두 항 모두 시도별 의미를 전제하고 있었고, 절대 상한이 120초인 상황에서는 올바르게 계산한 값이 자기 임계값인 180초에 결코 도달할 수 없기 때문입니다.
  • 하드코딩되어 있던 타임아웃 다섯 개에 설정 표면을 붙였고, 각각 기존 값을 기본값으로 유지합니다(#1401, PR #1402). server.http_client는 공유 아웃바운드 클라이언트의 pool_idle_timeout(300초), pool_max_idle_per_host(64), tcp_keepalive(30초), http2_keep_alive_interval(15초), http2_keep_alive_timeout(5초), http2_keep_alive_while_idle(true)를 노출합니다. 클라이언트는 기동 시 한 번만 만들어지므로 server.connection_pool_size와 마찬가지로 재시작이 필요합니다. health_checks.prewarm_timeout(10초)은 기동 프리워밍 프로브에 걸려 있던 고정 상한을 대체하며, Anthropic 분기와 일반 분기가 모두 이 값을 읽습니다. 유닉스 소켓 스트리밍 경로 두 곳은 별도로 하드코딩된 10초 대신 timeouts.connection에서 연결 기한을 가져오므로, 소켓 전송과 TCP 전송이 같은 값을 씁니다. timeouts.streaming_fallback_budget_multiplier(2.0, 허용 범위 1.0에서 10.0)는 request.streaming.total을 스트리밍 체인 전체가 공유하는 벽시계 예산으로 바꾸는 배수를 노출합니다. 이는 fallback.fallback_policy.fallback_timeout_multiplier를 재사용하지 않고 새 설정으로 만든 것인데, 그쪽은 개별 시도의 타임아웃을 조정할 뿐 체인이 공유하는 예산을 정하지 않기 때문입니다. 블록을 두지 않거나 그 안의 필드를 비워 두면 이전 클라이언트와 이전 예산이 그대로 재현됩니다.

변경됨

  • timeouts.request.streaming.first_byte를 실제로 강제하고 기본값을 60초에서 120초로 올렸습니다(#1401, PR #1402). 이 필드는 파싱되고 검증되고 캐시된 뒤 어떤 요청 경로도 읽지 않았습니다. 문서화된 설정이 조용히 아무 일도 하지 않았던 것입니다. 미드스트림 폴백 경로에서는 동작하지 않는 정도가 아니라 더 나빴습니다. 그 읽기 루프가 첫 폴링부터 chunk_interval을 적용해서, 첫 청크의 실제 기한이 설정된 60초가 아니라 30초였습니다. 이제 세 갈래 스트리밍 경로 모두에서 첫 청크의 기한으로 동작하고, 첫 청크가 도착한 뒤부터는 chunk_interval이 이어받습니다. 첫 청크 전에 만료되면 별도의 first_byte 타임아웃으로 보고되므로 로그와 지표가 느린 첫 토큰과 스트림 중간 멈춤을 구분할 수 있습니다. 미드스트림 폴백 경로에서는 30초짜리 실효 기한이 120초로 바뀌므로 완화이고, 두 SSE 파이프라인 경로에서는 첫 청크를 제한하는 것이 아무것도 없었으므로 강화입니다. 기본값을 120초로 올린 이유는 추론 모델이 첫 토큰까지 60초를 넘기는 일이 흔하기 때문이며, config.yaml.example이 Gemini thinking 모델에 이미 first_byte: 120s를 지정해 둔 이유도 그것입니다. 로드 시점 검사 두 개가 함께 들어갑니다. streaming.first_byte는 timeouts.limits.max_first_byte_timeout(기본값 480초)을 넘을 수 없고, streaming.total도 넘을 수 없습니다. 후자가 없으면 설정한 기한이 절대 발동하지 않는데도 아무도 그 사실을 알려 주지 않습니다. 모델별 streaming.first_byte 오버라이드도 같은 상한을 받으므로 모델 오버라이드가 우회 통로가 되지 않습니다.
  • Bedrock과 Gemini 스트리밍 경로에도 timeouts.request.streaming.chunk_interval을 적용합니다(#1401, PR #1402). 청크 간 기한은 미드스트림 폴백 경로에만 연결되어 있었습니다. SSE 파이프라인을 만드는 두 곳 모두 이 값을 설정하지 않아서, 멈춘 Bedrock 또는 Gemini 스트림을 제한하는 것은 전체 예산뿐이었습니다. 게다가 청크 간 기한이 설정되지 않으면 그 전체 예산조차 바깥 계층이 태스크를 깨울 때만 다시 평가되므로, 즉시가 아니라 keep-alive 주기 단위로 강제되고 있었습니다. 이제 두 경로 모두 모델별로 해석된 chunk_interval을 전달하므로, 멈춤 감지가 세 경로 중 하나만 맞던 상태에서 세 경로 전부에서 동일해집니다.
  • 호환성 깨짐: timeouts.request.image_generation.total에 600초 상한을 겁니다(#1401, PR #1402). 이미지 생성 블록은 아무것도 검증하지 않았습니다. 그래서 운영자가 한 시간짜리 예산을 넣어도 통과하는데 200초짜리 표준 예산은 거부되었고, image_generation.first_byte를 자기 total과 비교하는 검사도 없었습니다. 이제 image_generation.total을 600초보다 크게 설정한 배포는 로드 시점에 거부됩니다. 더 큰 값을 유지하려면 timeouts.limits.max_image_generation_timeout(절대 상한 1800초)을 올리고 재시작하십시오. 기본값 600초는 실효 기본값 180초보다 충분히 높게 잡았으므로, 현실적인 기존 설정은 그대로 로드됩니다.
  • Gemini 네이티브 멀티모달 임베딩 요청이 설정된 표준 타임아웃을 따르도록 했습니다(#1401, PR #1402). 이 경로는 timeouts: 섹션을 통째로 우회하는 하드코딩 60초에 묶여 있었습니다. request.standard.total도, 해당 임베딩 모델의 model_overrides 항목도 무시했고 다시 빌드하지 않으면 바꿀 수 없었습니다. 이제 다른 모든 비스트리밍 호출과 마찬가지로 모델별로 해석된 표준 총합을 읽으므로 핫 리로드도 됩니다. 이 때문에 해당 경로 하나의 실효 기본값이 60초에서 180초로 올라갑니다. 예전 상한을 유지하려면 timeouts.request.model_overrides.<모델>.standard.total: 60s를 설정하십시오.

폐기 예정

  • timeouts.request.standard.first_byte와 timeouts.request.image_generation.first_byte는 동작하지 않으며 다음 메이저 릴리스에서 제거됩니다(#1401, PR #1402). OpenAI 호환 비스트리밍 완성 응답은 Content-Length로 구분되는 버퍼링된 본문입니다. 업스트림이 완성 전체를 생성한 다음에야 헤더와 본문을 함께 쓰므로, 이 경로에서 첫 바이트까지의 시간은 곧 전체 생성 시간이지 따로 떼어낼 수 있는 이른 신호가 아닙니다. 여기에 기한을 두는 것은 별개의 방어선이 전혀 아니고 두 번째의, 더 짧은 total일 뿐입니다. 기본값 30초를 그대로 강제했다면 모든 비스트리밍 완성 응답에 30초 상한을 씌우는 셈이 되었을 것입니다. 두 필드는 하위 호환을 위해 계속 파싱되고 first_byte <= total 검사도 유지되며, 기본값과 다른 값을 쓰면 이제 로드 시점에 해당 필드가 동작하지 않는다는 경고가 나옵니다. continuum-router config validate도 같은 내용을 보고합니다. 실제 예산은 standard.total과 image_generation.total이고, 추론 모델용 권고도 함께 옮겨 갔습니다. 비스트리밍 경로에서는 standard.total을, 스트리밍 경로에서는 streaming.first_byte를 가리킵니다.

CI

  • 툴체인을 rust-toolchain.toml 파일로 Rust 1.97.1에 고정했습니다. 저장소가 아무것도 고정하지 않고 있었기 때문에, CI는 실행된 날 stable이 가리키는 아무 버전에서나 린트를 돌렸습니다. 이론상의 위험이 아닙니다. Rust 1.98이 clippy::result_large_err를 조이자 코드는 그대로인데도 cargo clippy -- -D warnings가 main에서 실패하기 시작했고, 손대지 않은 커밋 6b0f6e0f에서 main의 마지막 성공 CI 실행을 다시 돌려 이를 확인했습니다. CI는 dtolnay/rust-toolchain@stable로 stable을 설치하며 이 값이 rustup 기본값이 되는데, rustup은 디렉터리의 rust-toolchain.toml을 기본값보다 먼저 해석하므로 로컬이든 러너든 이 저장소의 모든 cargo 호출이 실제로 쓰는 것은 이 파일입니다. components에 rustfmt와 clippy를 적어 두었으므로, 워크플로가 이들을 따로 설치해 주는 데 기대지 않고 고정만으로 자족합니다. 이 고정이 무엇을 가리는지도 적어 둡니다. 1.97.1에서는 더 넓은 cargo clippy --all-targets --all-features가 경고를 하나도 내지 않지만 1.98은 #1404로 추적 중인 clippy::result_unit_err 두 건을 냅니다. 그 이슈는 여전히 유효하되 고정된 툴체인에서는 어떤 게이트도 그것을 드러내지 않습니다. 이 고정을 올리는 일은 의도적인 작업입니다. 채널을 올리고 make ci-local을 돌린 뒤, 새 린트가 짚는 것을 같은 변경에서 고치십시오.

의존성

  • Cargo.lock을 현재 semver 호환 집합으로 갱신했습니다. 크레이트 36개가 올라갔고 itertools 0.14.0이 그래프에 추가되었으며 제거된 것은 없습니다. 직접 의존성 중 움직인 것은 redis 1.5.0에서 1.6.0으로, uuid 1.24.0에서 1.24.1로, aws-config 1.10.1에서 1.11.0으로 세 건입니다. 나머지는 전이 의존성이며, 가장 큰 묶음은 icu_* 계열 2.2.0에서 2.3.x, 그리고 AWS Smithy 런타임 크레이트들입니다.

v1.23.0 - 2026-08-20

공식 릴리스 바이너리에 OpenTelemetry 트레이스 내보내기와 공유 Redis 상태를 담았습니다. 그리고 백엔드 가시성 필터를 빠뜨리면 잘못 라우팅되는 대신 컴파일이 실패하도록 바꿨습니다. 이 저장소가 아홉 번 고쳤던 숨긴 백엔드 버그 계열은 열 번째 패치가 아니라 타입으로 닫았습니다.

추가됨

  • 새로 추가한 기본 비활성 otel Cargo 기능 뒤에서 스팬을 OTLP로 내보냅니다(#1248, PR #1391). 내보내기는 두 번 옵트인해야 합니다. 빌드가 otel을 포함해야 하고(공식 릴리스 바이너리는 포함합니다), tracing.otlp.enabled가 true여야 합니다(기본값은 false). tracing.otlp 설정 타입은 항상 컴파일되므로 어떤 기능 조합에서도 같은 config.yaml이 동일하게 검증되고, 기본 바이너리는 기동을 거부하는 대신 내보내기가 비활성이라고 경고합니다. 스팬 넷과 이벤트 둘이 taxonomy 전부입니다. 원본 경로 대신 매칭된 라우트 템플릿을 담는 router.request, router.select_backend, router.backend_call, 그리고 router.retry와 router.circuit_breaker 이벤트입니다. 각각 같은 것을 이미 세고 있는 seam에서 나오므로 트레이스와 /metrics가 서로 다른 말을 할 수 없습니다. taxonomy는 생성자에서 한 번, 내보내기 경계의 스팬 프로세서에서 한 번 더 강제하며, 후자가 목록 밖의 스팬·속성·스팬 이벤트를 버립니다. 평범한 로그 줄과 공급자 오류 본문이 수집기로 새어 나가지 않는 이유가 이것입니다. 인증 헤더 값의 ${ENV_VAR}는 exporter 생성 시점에만 치환되므로 설정 덤프에 비밀이 담기지 않고, 헤더 이름은 RFC 7230 토큰이어야 하며 값에 제어 문자가 없어야 합니다. 내보내기가 꺼져 있으면 모든 스팬 생성자는 relaxed 원자적 로드 한 번 뒤에 Span::none()을 돌려줍니다.
  • 공식 릴리스 바이너리에 redis-cache를 담고 공유 상태 배포 계약을 정의했습니다(#1249, PR #1390). 공유 상태 코드는 있었지만 어떤 공식 아티팩트에도 들어 있지 않아서, 배포 프로파일은 복제본별 rate limit을 피할 수 없는 한계로 설명해야 했습니다. 기능을 컴파일해도 활성화되는 것은 없습니다. 런타임 옵트인은 여전히 rate_limiting.storage: redis와 response_cache.backend: redis입니다. 라우터는 ConnectionManager 위의 deadpool-redis 풀 하나로 Redis를 다루는데, 이 조합은 Sentinel 마스터 탐색도 클러스터 슬롯 라우팅도 하지 않습니다. 그래서 지원 토폴로지는 관리형 엔드포인트 정확히 하나입니다(standalone, 공급자 관리형 HA 엔드포인트, 클러스터 앞단 프록시, 유닉스 소켓). redis+sentinel://, redis+cluster://, 쉼표로 나열한 다중 호스트 URL, #master-name 프래그먼트는 암시했다가 연결 시점에 실패하는 대신 설정 로드에서 거부합니다. 치환되지 않은 ${VAR}는 받아들이고 치환된 뒤 다시 검사하므로, 프로덕션 Secret이 없는 CI 러너에서도 렌더링된 매니페스트가 검증됩니다. 장애 시 동작은 소비자별로 문서화하고 테스트했습니다. Helm 차트와 Kustomize 번들은 운영자가 만든 Secret을 참조할 뿐 Redis 서버를 담지도, 자격 증명을 지어내지도 않습니다.
  • thinking 스트림 변환기에 버퍼링하는 MaybeReasoning 상태를 추가했습니다(#216, PR #1389). assume_reasoning_first는 근거가 도착하기 전에 unterminated_start 모델의 선두 토큰이 무엇인지 결정해 버렸습니다. 그래서 thinking 단계를 건너뛰고 바로 답하는 모델은 답 전체를 reasoning 채널로 보냈고, content만 렌더링하는 클라이언트에는 빈 응답이 보였습니다. 새 상태는 그 토큰을 붙들고 실제로 도착하는 것으로 분류를 정합니다. 종료 마커가 오면 앞부분을 reasoning_content로 내보내고, 결정 타임아웃이나 버퍼 상한, 스트림 종료면 content로 내보냅니다. 모델별 옵트인이며 buffered, max_buffer_size(기본 50KB, 상한 8MB), reasoning_timeout(기본 10초)로 조절하고 모두 기존 동작이 기본값입니다. 400ms 사고 구간을 대상으로 측정했을 때 버퍼링 없이는 첫 델타가 약 0.14ms, 버퍼링하면 약 401ms에 나갑니다. 결정 구간에 들어가지 않는 스트림은 타이머를 걸지도, 추가 버퍼를 잡지도 않습니다.
  • Helm 차트에 선택적 Argo Rollouts 연동으로 점진적 배포를 자동화했습니다(#1251, PR #1392). 가중치 카나리 단계, 라우터 자신의 Prometheus 시계열을 쓰는 자동 분석, 자동 롤백, 프로덕션 승격 전 운영자 승인 대기를 values로 켜며 기본은 꺼져 있습니다. 기존 프로파일은 모두 변경 전과 바이트 단위로 동일하게 렌더링되며, main과 브랜치의 렌더 결과를 diff해서 확인했습니다. 분석 지표 둘 다 rollouts_pod_template_hash로 선택하므로 나쁜 카나리가 정상 stable 트래픽 뒤에 숨을 수 없고, 두 쿼리 모두 minRequestRate 가드를 달아 밤사이의 얇은 표본을 결론 없음으로 처리합니다. 요청 세 건 중 한 건 실패로 정상 릴리스를 롤백하지 않기 위해서입니다. Argo Rollouts CRD 스키마는 deploy/schemas/에 벤더링해서 kubeconform이 커스텀 리소스를 건너뛰지 않고 오프라인으로 검증합니다.
  • 독립 부하 하네스이자 주간 용량 회귀 게이트인 perf/를 추가했습니다(#1250, PR #1388). 재현 가능한 비스트리밍·SSE 스트리밍 프로파일을 격리된 모의 백엔드에 실행하고, 결과를 버전이 붙은 JSON으로 기록하고, 저장소에 커밋된 검토된 임계값과 대조합니다. 이 크레이트는 빈 [workspace]를 선언하므로 저장소 루트의 cargo build, cargo check --all-targets, make ci-local은 이것을 컴파일하지 않고, 게이트는 if: github.event_name != 'pull_request'로 막아서 커밋별 빌드에 관여하지 않습니다. 드라이버는 reqwest가 아니라 TcpStream 위의 최소 HTTP/1.1 클라이언트입니다. 측정 대상과 풀 구현을 공유하는 드라이버로는 클라이언트 쪽 큐잉과 라우터 쪽 큐잉을 분리할 수 없기 때문입니다. benches/는 Criterion 전용으로 남습니다.
  • 이름이 밝혀진 백엔드가 실제로 돌려준 실패에 x-served-backend를 답니다(#1362, PR #1378). 이 헤더는 2xx가 아닌 모든 응답에서 억제됐는데, 그것은 문제에 대한 사실이 아니라 일괄 안전 조치였습니다. 실제 백엔드가 정말로 돌려준 상류 429가 귀속 없이 돌아왔고, 그 답이 가장 필요한 경우가 바로 그때입니다. 귀속은 오류 값에 실을 수 없습니다. RouterError::RateLimited는 상류 429에서 왔든 라우터 자체 limiter에서 왔든 바이트 단위로 같기 때문입니다. 그래서 출처는 백엔드의 실제 HTTP 응답을 손에 쥔 디스패치 지점 네 곳에서 기록하고, 매 시도가 덮어쓰는 요청별 슬롯으로 재시도 seam 밖까지 운반합니다. 한 백엔드에서 429를 받고 다음 백엔드에서 연결에 실패하면 슬롯에는 낡은 이름이 아니라 아무것도 남지 않습니다. 라우터가 만든 실패는 계속 귀속 없이 나갑니다.

변경됨

  • 백엔드 가시성 필터를 빠뜨리면 컴파일이 실패합니다(#1359, PR #1369 및 #1373). 별개로 보이던 아홉 건(#868, #1336, #1339, #1348, #1349, #1354, #1355, #1356, #1357)은 모두 같은 결함이었습니다. 사용자 요청의 백엔드를 고르는 경로가 filter_user_routing_candidates를 적용하지 않아서 internal: true나 enabled: false인 백엔드가 평범한 후보로 남았습니다. 반복된 이유는 부주의가 아니라 구조입니다. 이 필터는 공유 선택 seam 안으로 옮길 수 없습니다. 숨긴 백엔드만 서빙하는 모델이 오해를 부르는 forbidden 대신 model-not-found를 보고하려면 키별 allow-list보다 먼저 실행돼야 하는데, seam은 그 allow-list를 받지 않기 때문입니다. 그래서 필터는 호출자 쪽에 남았고, 모든 후보 목록은 평범한 Vec<String>이었고, 필터를 거친 목록과 거치지 않은 목록의 타입이 같았고, 필터를 빠뜨려도 컴파일되고 모든 테스트를 통과하고 실제로 백엔드를 숨긴 배포에서만 오작동했습니다. 새 proxy::selection::UserRoutableCandidates 뉴타입은 비공개 Vec<String>을 감싸고 생성자 집합 자체가 증명이 되도록 설계해서, seam 기반 경로가 필터를 건너뛰면 컴파일되지 않습니다. 함께 가시성 테스트 두 벌은 라우트 구성 함수에서 읽어 낸 실제 마운트 라우트 표로 구동되고, 소스 스캔 감사가 후보를 만드는 지점마다 판단을 요구하므로, 둘 다 이력을 기록하던 수기 목록이기를 그만둡니다.
  • 두 아키텍처 문서의 Dependency Injection 절을 다시 썼습니다(#1367, PR #1381). 배선되기 전에 골격만 세워 두고 폐기된 컨테이너 해석 설계를 설명하고 있었습니다. 예제의 이름 넷이 해석되지 않았고 그중 둘은 애초에 존재한 적이 없습니다. setup_services는 src/ 이력에 커밋이 하나도 없고, HttpClient::new(&config.http_client)?는 어떤 버전의 코드에서도 컴파일될 수 없었습니다. 이 절은 라우터가 실제로 쓰는 패턴, 즉 유일한 프로덕션 구현이 BackendManager인 BackendService 트레이트 seam과 src/server/state.rs의 두 생성 지점을 중심으로 다시 썼습니다.

수정됨

  • Anthropic Messages 인그레스에서 vLLM reasoning 출력을 읽습니다(#1386, PR #1387). /anthropic/v1/messages의 Chat Completions 갈래가 reasoning을 reasoning_content에서만 읽어서, reasoning을 쓰는 현재 vLLM 응답은 과금된 thinking 출력을 조용히 잃었습니다. 같은 엔드포인트의 Responses 갈래는 이미 그 필드를 변환하고 있었습니다. 이제 비스트리밍 블록과 스트리밍 thinking_delta 이벤트 모두 reasoning_content를 먼저 읽고 reasoning으로 폴백하며, 출력 가드레일은 여전히 변환 결과를 검사하므로 이 별칭이 설정된 reasoning 검사를 우회하지 않습니다.
  • control-plane 후보 집합 네 곳에서 숨긴 백엔드를 걸러 냅니다(#1355, PR #1361). handle_disaggregated_request, compute_arbitrage_decision, target_provider_backends, provider_for_model이 각각 config.backends나 find_backends_for_model에서 가시성 필터 없이 후보를 만들었습니다. 넷 다 숨긴 백엔드로 디스패치할 수는 없었습니다. 공유 seam이 키별 allow-list보다 먼저 필터를 적용하고, allow-list 항목으로 들어온 숨긴 이름은 교집합을 비우기 때문입니다. 대신 호출자가 받은 것은 틀린 답이었습니다. 문제의 원인이 아니었던 키를 탓하는 403, 숨긴 백엔드만 그 모델을 서빙할 때의 404, disaggregated 경로의 503, 또는 아무 오류도 없이 조용히 잘못 결정된 치환 규칙입니다. 넷 다 안전했던 유일한 이유는 지금의 모든 소비자가 allowed_backends를 가시성 필터 뒤에 적용되는 필터로 다룬다는 것뿐이었고, 타입 시스템에는 그런 말이 없었습니다.
  • smart-routing LLM 분류기가 숨긴 백엔드로 프롬프트를 보내지 않게 했습니다(#1356, PR #1363). classifier.method가 llm이나 hybrid이고 classifier.llm.backend를 지정하지 않으면 가시성 검사 없이 config.backends.first()로 폴백했습니다. 그래서 분류 대상 요청의 프롬프트 텍스트가 맨 앞에 정렬된 백엔드로 갔고, 그것은 internal: true 가드 백엔드거나 꺼 둔 enabled: false 대기 백엔드일 수 있었습니다. 이 계열의 다른 사례와 달리 새어 나가는 것은 라우팅이 아니라 프롬프트 내용입니다. 해석은 설정 세대당 한 번 일어나므로 노출은 간헐적이 아니라 지속적이었습니다. 이제 암묵적 폴백은 첫 번째 사용자 라우팅 가능 백엔드를 고르고 어느 것을 골랐는지 info로 남깁니다. internal: true 지정은 그 플래그의 용도이므로 그대로 인정하고, enabled: false 지정은 warn과 함께 거부하며 라우터는 규칙 기반 분류로 계속 동작합니다.
  • web_search 정책 스캔에서 숨긴 백엔드를 걸러 냅니다(#1357, PR #1364). find_backend_for_model이 config.backends를 필터 없이 훑어 도구 주입 정책을 결정했습니다. 이 경로에서 숨긴 백엔드로 디스패치되는 일은 없습니다. 다만 그 모델을 주장하는 숨긴 백엔드가, 실제로는 다른 정상 백엔드가 서빙한 요청에 web_search를 주입할지, 어떤 web_search.per_backend 재정의를 적용할지, 지표 레이블에 어떤 백엔드 타입 값을 기록할지를 결정할 수 있었습니다.
  • 가드레일 가드 모델 참조가 비활성 백엔드를 거부합니다(#1371, PR #1377). backend: 참조가 순전히 이름으로만 해석돼서, 운영자가 enabled: false로 꺼 둔 백엔드가 요청 핫 경로에서 계속 가드 모델 프롬프트를 받았습니다. internal: true는 그대로 예외입니다. 평범한 라우팅이 닿지 않는 백엔드를 예약하는 것이 가드 모델을 서빙하는 통상적인 방식이기 때문입니다. 거부된 참조는 강제 허용도 강제 차단도 아닙니다. 해석 불가능한 가드 백엔드가 이미 만들던 것과 같은 평범한 검사 오류를 내므로 공급자의 기존 on_error 정책이 판단하고, 운영자가 고른 fail_open이나 fail_closed를 어느 쪽으로도 뒤집지 않습니다.
  • 이미지 편집 백엔드 해석마다 설정 스냅샷을 한 번만 찍습니다(#1372, PR #1376). image_edit::find_openai_backend가 /v1/images/edits 요청 하나를 해석하면서 state.current_config()를 세 번 읽어서, 두 읽기 사이에 핫 리로드가 들어오면 한 번의 해석이 설정 두 세대에 걸쳐 결정되고 어느 한 세대만으로는 나올 수 없는 백엔드가 선택될 수 있었습니다. 이제 스냅샷을 한 번 찍으며, 이는 쌍둥이인 image_gen::find_openai_backend가 이미 갖고 있던 모양입니다.
  • response_cache.redis.fallback_to_memory: false가 실제로 fail-closed로 동작합니다(#1249, PR #1390). 플래그를 꺼도 스토어가 메모리 계층을 계속 읽고 썼기 때문에 fail-closed 선택지는 존재하지 않았습니다. 이제 장애를 보고하고 응답 캐시가 그것을 기록한 뒤 미스로 처리합니다. 같은 작업에서 rate_limiting.redis가 실제 로드 경로에서 한 번도 검증된 적이 없다는 것이 드러났습니다. 이제 그곳에서 엔드포인트를 검사하되, 대상은 URL로 한정했습니다. RateLimitConfig::validate 전체는 그 수치 범위들 역시 로드 시점에 실행된 적이 없어서, 지금 잘 도는 라우터의 기동을 거부하게 만들 것이기 때문입니다.
  • 1388, #1389, #1390 머지 이후 세 경로를 보강했습니다(PR #1394). MaybeReasoning 버퍼가 들어온 청크를 버퍼에 넣은 뒤에야 바이트 상한을 확인해서, 큰 청크 하나가 통째로 버퍼링될 수 있었습니다. 이제 버퍼링 전에 상한을 강제하고, 상한 지점에서 마커 우선순위를 그대로 지키며, 스캔을 제곱 비용으로 만들던 전체 버퍼 복제와 재스캔을 없앴습니다. Redis 폴백 상태와 노출되는 게이지가 서로 어긋날 수 있었고, fallback_to_memory: false인데도 복구 경로를 통해 메모리 계층이 활성화될 수 있었고, health·clear·statistics 연산에 명령 타임아웃이 없었습니다. 성능 하네스는 첫 content 델타가 비어 있어도 거기서 TTFT를 읽어서, 첫 SSE 프레임의 content가 빈 백엔드에 대해 잘못된 TTFT를 보고했습니다.

  • 안전한 continuum_router::streaming 호환 익스포트를 복원했습니다(PR #1385). PR #1380이 이 모듈이 담고 있던 필터 없는 선택기와 함께 모듈 자체를 지웠는데, 아직 1.x 라인인 크레이트에서 문서화된 공개 호환 네임스페이스까지 사라진 셈이었습니다. 파서·변환기·스트리밍 헬퍼의 안전한 재익스포트만 복원했습니다. 필터 없는 백엔드 선택기는 전부 삭제된 채로 두고, 소스 감사와 컴파일 경로 양쪽 커버리지로 그런 선택기가 되돌아오지 못하게 했습니다.
  • SelectionStrategy::WeightedRoundRobin을 실제로 검증하고 tests/integration/을 정리했습니다(#1366, PR #1374). 출시된 선택 전략에 동작 커버리지가 전혀 없었습니다. 그 공백은 tests/integration/이 가리고 있었습니다. 파일 일곱 개와 테스트 함수 63개가 어떤 Cargo 테스트 타깃에도 속하지 않았습니다. Cargo는 tests/*.rs와 tests/<dir>/main.rs를 찾는데 이 디렉터리는 mod.rs만 두었고, 어떤 최상위 타깃도 이것을 선언하지 않았습니다. 약 열두 달 동안 죽어 있었고, 스물한 개 커밋에 걸쳐 실행되고 있다고 믿을 만했던 사람들이 손으로 고쳤으며, 마침내 빌드하니 컴파일 오류 57개가 나왔습니다. 이제 가중치 3/2/1의 비례 분배, 가중치 0인 백엔드, 가중치 단위가 하나뿐인 분기, 전부 0일 때의 라운드로빈 폴백을 테스트 넷이 덮고, 탐색 가드가 Cargo가 컴파일하지 않는 위치에 놓인 테스트 파일을 빌드 실패로 만듭니다.
  • 테스트 flake 둘을 없앴습니다(#1331, PR #1375 및 #1383, PR #1384). 토큰 버킷 refill 테스트가 1.1초 sleep 뒤 고정 구간으로 단언했는데 tokio::time::sleep은 하한만 보장하므로 부하가 걸린 러너에서는 정당하게 그 구간을 넘길 수 있었습니다. 이제 측정한 경과 시간에서 기대값을 유도합니다. Gemini 컨텍스트 캐시 테스트는 매니저 생성, 백그라운드 create 왕복, 폴링 대기를 모두 담아야 하는 3초 벽시계 만료 구간을 썼는데, 이제 스케줄러 지연만으로는 실패할 수 없을 만큼 구간을 넓혔습니다. HTTP 연결 재사용과 형제 테스트 실행 순서에 의존하던 health probe·모델 타이밍 테스트 둘도 스스로 전제를 갖추도록 바꿨습니다(PR #1385).

제거됨

  • 도달 불가능한 세 함수짜리 select_backend 사슬을 삭제했습니다(#1358, PR #1365). 건강 상태만 보고 백엔드를 고르면서 가시성 필터, 키별 allow-list, 서킷 브레이커, 설정된 선택 전략을 모두 우회했습니다. 프로덕션 호출자는 없었지만 세 타입 중 둘이 pub use로 익스포트돼 있어서, 백엔드를 어떻게 고르는지 grep하는 사람이 바로 찾아낼 필터 없는 선택기였습니다. ProxyServiceImpl, BackendServiceImpl, BackendManager::select_backend, 그리고 BackendService 트레이트의 select_backend 요구사항이 사라졌습니다.
  • ModelServiceImpl을 삭제했습니다(#1370, PR #1379). get_backends_for_model이 가시성 필터도 키별 allow-list도 없이 풀 전체를 열거했습니다. 자체 단위 테스트 밖에서는 생성되지 않았고 ServiceRegistry 등록은 주석 처리돼 있었습니다.
  • 모델·건강·서킷 필터를 하나도 적용하지 않던 필터 없는 counter % len 선택기 BackendPool::get_backend_async를 삭제했습니다(#1322, PR #1380). 이런 것을 손닿는 곳에 두는 비용은 #1316이 이미 보여 줬습니다. ACP 경로가 이것을 집어 들어 (N-1)/N 오라우팅률로 출시됐습니다. 이제 재시도 루프 안에 있지 않은 모든 디스패치 경로는 여기를 거친다는 select_admissible_backend의 계약이 이 축에서는 관례가 아니라 컴파일러로 강제됩니다.

v1.22.0 - 2026-08-16

라우터가 백엔드를 고르는 모든 경로에서 x-backend가 동작하게 하고, 실제로 어느 백엔드가 응답했는지 클라이언트가 확인할 수 있게 했습니다. 그리고 internal: true나 enabled: false로 숨긴 백엔드가 사용자 트래픽을 받을 수 있던 경로 여섯 곳을 막았습니다. 그중 하나는 v1.21.2 이하에 존재하던 권한 우회입니다.

추가됨

  • 라우터가 백엔드를 선택하는 모든 경로에서 x-backend를 인정합니다(#1332, PR #1335). v1.21.2는 이 헤더를 채팅 완성, 스트리밍, Responses에 도입하면서 커버리지를 엔드포인트 표로 문서화했습니다. 그 표는 양방향으로 틀렸습니다. 공유 재시도 seam을 통해 이미 헤더를 인정하던 /v1/completions, /v1/embeddings, /v1/rerank, /v1/sparse_embeddings가 빠져 있었고, 반대로 /v1/responses가 스트리밍에서도 인정한다고 적혀 있었으나 그 경로는 헤더를 읽은 적이 없습니다. 이제 Anthropic Messages API와 토큰 카운터, 이미지 생성·편집·변형, 스트리밍 /v1/responses, /v1/responses/compact, Gemini 네이티브 이미지 하위 경로, 멀티모달 /v1/embeddings 하위 경로까지 확장됐습니다. 문서는 낡기 쉬운 엔드포인트 목록 대신 메커니즘(라우터가 요청에 대해 백엔드를 선택하는 곳이면 헤더가 적용된다)과 예외 세 가지(/v1/realtime 세션, ACP 프롬프트, Batch API)를 명시합니다.
  • 응답한 백엔드를 새 응답 헤더 x-served-backend로 알려줍니다(#1333, PR #1342). 백엔드 이름을 자체 상태에서 조합하는 클라이언트는 선호가 반영된 경우와 조용히 무시된 경우를 구분할 방법이 없었습니다. 한 글자만 틀려도 계속 200을 돌려주면서 선택은 무시하는 시스템이 됩니다. 이 헤더는 요청이 x-backend를 실어 보냈을 때만 돌아가므로 선호를 보내지 않는 배포는 영향을 받지 않으며, 재시도나 fallback 체인 hop 이후에도 실제로 응답을 만든 백엔드를 가리킵니다. 응답한 백엔드를 증명할 수 없는 곳에서는 추측하지 않고 생략합니다. mid-stream fallback이 걸린 스트리밍 채팅은 어떤 백엔드가 답하기도 전에 응답 헤더를 확정하고 이후에 백엔드를 바꿀 수 있으므로, 그 경로는 첫 시도 이름을 대는 대신 아무것도 보고하지 않습니다. 2xx가 아닌 응답, 캐시 재생, 스트리밍이 아닌 응답의 가드레일 차단, web_search 도구 루프도 같은 이유로 생략하며 문서에 목록으로 적었습니다.
  • GET /admin/capabilities가 backend_preference_header_v1을 광고합니다(#1334, PR #1343). 클라이언트가 헤더를 보낼지 판단하려고 1.21.2 리터럴과 버전을 비교해야 했는데, 그 엔드포인트는 바로 그것을 없애려고 존재합니다. 커버리지가 균일해진 지금은 표면별 목록이 아니라 토큰 하나가 정직합니다. 추론용 키만 가진 클라이언트는 관리자 엔드포인트를 읽을 수 없고 읽을 필요도 없습니다. x-backend를 보내고 x-served-backend가 붙는지 보면 지원 여부가 증명됩니다. 다만 헤더가 없다고 미지원이 증명되지는 않습니다.

수정됨

  • /v1/realtime에 키별 백엔드 allow-list를 강제합니다(#1337, PR #1345). WebSocket 업그레이드는 인증을 거쳤지만 핸들러가 인증 컨텍스트를 꺼내지 않아 allowed_backends가 세션 백엔드에 한 번도 적용되지 않았습니다. 백엔드 하나로 제한된 API 키가 그 모델을 서빙하는 다른 아무 백엔드로도 realtime 세션을 열 수 있었고, 이는 그 설정이 막으려는 바로 그것입니다. v1.21.2 이하에 존재합니다. 교집합이 비면 이제 Anthropic·Responses 경로와 같은 permission_error 형태로 403을 돌려주며, "정상 백엔드 없음"과 구분을 유지합니다.
  • 이미지 변형이 숨긴 백엔드로 가지 않게 했습니다(#1336, PR #1344). 변형 경로의 find_openai_backend는 풀을 열거하면서 키별 allow-list와 OpenAI 기능 필터는 적용했으나 가시성 필터는 적용하지 않아, internal: true나 enabled: false인 백엔드가 평범한 라운드로빈 후보로 남아 있었습니다. 방향이 거꾸로였습니다. x-backend로 그 백엔드를 명시하면 정확히 거부되는데, 헤더 없는 암묵적 선택은 거기에 닿을 수 있었습니다.
  • 멀티모달 임베딩이 숨긴 백엔드의 자격 증명을 쓰지 않게 했습니다(#1339, PR #1347). 멀티모달 /v1/embeddings 요청의 Gemini 후보 집합이 가시성 필터 없이 만들어졌고, 핸들러는 선택된 백엔드의 자격 증명을 실어 디스패치하므로 숨긴 백엔드의 비밀이 사용자 트래픽에 쓰였습니다. 필터가 들어가면서 이 경로도 이제 x-served-backend를 보고합니다. #1333이 바로 이 이유로 그곳에서만 보류했던 것입니다.
  • 모델 없는 스트리밍 채팅이 숨긴 백엔드를 고르지 않게 했습니다(#1348, PR #1352). model 필드 없이 온 스트리밍 POST /v1/chat/completions는 allow-list만 적용하는 헬퍼로 백엔드를 정했습니다. 같은 경우의 비스트리밍 쌍둥이는 #868 이후로 필터를 적용해 왔고, 이번이 빠졌던 나머지 절반입니다.
  • mid-stream fallback이 숨긴 백엔드로 hop하지 않게 했습니다(#1349, PR #1353). pre-stream 쪽은 필터를 적용하는데 릴레이는 필터 없는 설정에서 fallback 대상을 찾았습니다. 대상 모델을 주장하는 숨긴 백엔드가 hop 목적지가 될 수 있었습니다. 필터가 후보를 비운 대상은 이제 도달 불가 hop과 똑같이 취급하므로, 스트림 도중 응답에 새로운 실패 유형이 생기지 않습니다.
  • 배치 디스패치가 숨긴 백엔드를 고르지 않게 했습니다(#1354, PR #1360). resolve_batch_backend는 hub 자격 증명 디스패치 게이트만 확인했는데 그것은 가시성 검사가 아니고 게이트가 없으면 모든 이름을 통과시키므로, 앞에 놓인 internal: true OpenAI 계열 백엔드가 모든 배치 작업을 가져가고 그 자격 증명을 썼습니다. 암묵적 첫 일치 fallback은 이제 필터를 적용합니다. control_plane.batch.backend로 명시적으로 지정한 경우 internal: true 백엔드는 그대로 해석됩니다. 그것이 그 플래그의 용도입니다. 다만 enabled: false는 더 이상 해석되지 않습니다. 공식 릴리스 바이너리가 컴파일하는 control-plane 기능이 필요합니다.
  • 이미지 백엔드 자격 증명을 실행 중 설정에서 읽습니다(#1341, PR #1350). 이미지 생성·변형·편집에 걸친 자격 증명 조회 여섯 곳이 기동 시점 설정 스냅샷을 읽어서, API 키를 교체하는 핫 리로드를 해도 프로세스를 재시작하기 전까지 옛 키를 계속 보냈고 증상은 상류 인증 문제처럼 보였습니다. 같은 함수 안의 백엔드 타입 분류 읽기 다섯 곳도 함께 바꿨습니다. 한 함수 안에서 한쪽은 실시간, 한쪽은 낡은 값을 읽는 상태 자체가 결함이기 때문입니다.
  • 로깅 전에 클라이언트가 보낸 x-backend 값을 자릅니다(#1340, PR #1351). 로그 지점 열네 곳이 헤더 값을 길이 제한 없이 그대로 기록했습니다. 헤더 파싱이 제어 문자를 거부하므로 인젝션 위험은 없었지만 크기 제한이 없었고, 쓸 수 없는 값은 실패가 아니라 fall-through하도록 설계돼 있어 요청은 그대로 200을 돌려주고 반복을 막을 장치가 없었습니다.

제거됨

  • 사용되지 않던 비귀속 중복 제거 진입점을 제거했습니다(#1338, PR #1346). 귀속 변형과 캐시를 공유하므로, 호출자가 생기면 coalesce된 후속 요청이 백엔드 귀속이 없는 항목을 읽게 되고 그 값은 요청 통계, control-plane 사용량, 그리고 이제 x-served-backend 헤더까지 흘러갑니다. 호출자가 없었고, 계속 없도록 제거했습니다.

v1.21.2 - 2026-08-15

모델 목록을 주기적으로 조회하는 클라이언트가 겪던 /v1/models 꼬리 지연을 약 16초에서 150ms 아래로 줄였습니다. ACP 프롬프트와 x-backend 요청은 이제 그 모델을 실제로 서빙하는 백엔드로 가며, macOS 릴리스 바이너리는 Gatekeeper가 받아들이는 인증서로 서명합니다.

추가됨

  • 채팅 완성, 스트리밍, Responses API에서 x-backend 요청 헤더를 라우팅 선호로 존중합니다 (#1317, PR #1320). 같은 모델 id를 여러 백엔드가 서빙할 수 있고 /v1/models도 집계 항목의 backends 배열에 그 전부를 이미 보고했지만, 클라이언트는 어느 쪽도 지목할 수 없었습니다. 호출자가 어떤 행을 골랐든 라우터가 그 사이에서 부하를 분산했기 때문입니다. 이 헤더는 라우터가 원래 자유롭게 고를 수 있었던 선택지를 좁히기만 합니다. 첫 시도에 거는 편향이며, 지목한 백엔드가 노출되어 있고 호출자 키의 허용 목록에 있으며 요청한 모델의 후보군에 속하고 서킷이 받아들이는 상태일 때만 존중됩니다. 그 밖의 경우에는 경고를 남기고 평소 선택으로 흘러갑니다. 이 헤더 때문에 요청이 실패하는 일은 없고, 헤더가 없는 요청은 이전과 완전히 같게 동작합니다. 폴백 체인의 각 홉은 평소 선택을 유지하며 /metrics 귀속은 여전히 클라이언트가 준 값을 신뢰하지 않습니다. 의미론은 #674 이후 proxy::image_edit가 써 온 그것이고, 이번에 중복 구현 대신 공유하도록 바꿨습니다.
  • 스마트 라우팅 웹 UI 페이지에 Classifier 탭을 추가했습니다 (#1314, PR #1319). 관리 API는 classifier.method, classifier.rule.confidence_threshold, classifier.llm.* 필드를 이미 읽고 쓰며 즉시 핫 리로드했지만, 페이지는 분류기 상태를 보여주기만 했습니다. 새 탭은 핵심 컨트롤 일곱 개를 /admin/backends에서 채운 백엔드 이름 datalist, 저장 확인, 검증 패널과 함께 노출하며 Status 탭은 classifier_method와 has_llm_classifier를 표시합니다. LLM 분류기의 고급 필드(타임아웃, temperature, 토큰 한도, 구조화 출력 전략, 프롬프트)는 일반 Configuration 페이지에 남겨두고 새 탭에서 링크로 연결했습니다.
  • 모델 집계 타이밍을 프로덕션 메트릭 레지스트리로 내보냅니다 (#1326, PR #1329). 지금까지 느린 모델 목록을 진단하려면 소스 코드를 읽어야 했습니다. model_backend_fetch_duration_seconds는 backend로 라벨링된 새 히스토그램이고, model_refresh_duration_seconds는 레거시 수집기 목록에만 있던 것이 프로덕션 레지스트리에 등록됩니다. 매 리프레시마다 기록되면서 아무도 읽지 않던 집계 카운터 13개도 노출됩니다(시도 횟수, 빈 응답, 사용 불가 백엔드, 레이트 리밋, 일시·영구 오류, stale-while-revalidate 서빙, 합쳐진 요청, 백그라운드 리프레시 결과, singleflight 획득). 시도별 request_timeout을 넘긴 백엔드는 이름과 소요 시간, 시도 횟수를 담은 WARN 한 줄도 남기므로 Prometheus 없이도 확인할 수 있습니다. http_request_duration_seconds는 10초 상한을 그대로 두고 새 히스토그램 둘만 60초까지 버킷을 잡았습니다. 그래서 16초짜리 리프레시가 공용 히스토그램의 +Inf에 묻히지 않고 책임 백엔드를 지목하는 메트릭에서 읽힙니다.

변경됨

  • 모델 목록 캐시가 만료되면 블로킹하는 대신 오래된 목록을 내주면서 뒤에서 재검증합니다 (#1324, PR #1327). 만료되거나 무효화된 캐시는 다음 /v1/models 요청을 풀에 있는 모든 백엔드로 향하는 동시 팬아웃으로 만들었고, 기본 설정에서 최악 약 16초가 걸렸으며 동시에 들어온 목록 요청은 singleflight 뮤텍스 뒤에 줄을 섰습니다. 캐시 계층은 만료됐지만 남아 있는 본문을 이미 추적하고 있었는데, 집계 진입점이 그 본문을 버리는 게터로 읽고 있었습니다. 이제 캐시가 정말로 내줄 것이 없을 때만 요청이 블로킹됩니다. 오래됨의 상한은 새 YAML 필드가 아니라 내부 상수 cache_ttl * 3.0(기본 60초 TTL에서 180초)이라 공개 설정 스키마는 그대로이고 운영자는 cache_ttl로 조절합니다. 상한을 넘으면 예전처럼 블로킹하므로 리프레셔가 멈춰도 임의로 오래된 목록이 나가지는 않습니다. 무효화는 본문을 남기고 나이 시계를 무효화 시점부터 다시 세므로, 백엔드를 등록하자마자 모델을 조회하는 흔한 순서에서 경합이 사라집니다. 요청별 응답 필터(키별 백엔드·모델 허용 목록, 내부 백엔드 제거)는 여전히 현재 설정으로 돌기 때문에 잠시 오래된 멤버십 목록이 호출자의 열람 범위를 넓히지는 못합니다.

수정됨

  • 백그라운드 리프레셔가 오래된 캐시 항목을 실제로 갱신합니다 (#1323, PR #1330). StaleButUsable 가지가 캐시를 거쳐 읽는 집계를 호출했고 그 집계는 stale-but-usable 항목을 적중으로 처리했으므로, 어떤 백엔드에도 연결하지 않고 오래된 본문을 그대로 돌려줬습니다. 소프트 TTL 구간은 아무 일도 하지 않았고 항목은 하드 만료에 도달한 뒤에야 갱신됐으며, TTL 주기마다 들어오는 /v1/models 요청이 팬아웃 전체를 인라인으로 수행하는 구간이 남았습니다. 주 트래픽이 모델 목록 폴링인 배포에서는 이것이 지연의 지배 요인이었습니다. 이제 이 가지는 합쳐진 재집계를 실제로 수행하므로 항목은 소프트 만료와 하드 만료 사이에서 갱신되고 정상 상태에서는 만료에 아예 도달하지 않습니다. healthy 이벤트 리스너도 같은 무동작 경로를 타면서 공용 경로가 이미 기록하는 카운터를 손으로 또 올려 이중 계산하고 있었는데, 이제 같은 슬롯을 공유합니다. 그래서 요청 경로, 주기 루프, 리스너 중 무엇이 시작했든 집계는 한 번에 하나만 진행됩니다. 300ms 백엔드에 TTL 2초, 리프레셔 주기 250ms로 측정하면 세 주기를 넘겨도 요청 지연은 150ms 아래로 유지됩니다.
  • 모델 목록을 서빙할 수 없는 백엔드는 더 이상 프로브하지 않습니다 (#1325, PR #1328). 리프레시는 주기마다 풀의 모든 백엔드를 프로브했고 401과 403을 뺀 모든 비2xx를 재시도했습니다. 팬아웃이 동시 실행이라 가장 느린 무의미한 프로브가 집계 전체의 지연을 정했습니다. Bedrock 백엔드는 존재하지도 않는 /v1/models를 상대로 5초짜리 시도 세 번에 500ms 대기 두 번, 곧 매 리프레시마다 약 16초를 썼습니다. 이제 Anthropic과 Bedrock 백엔드는 HTTP 요청 없이 설정된 models:에서 바로 제공되며, 이는 관리 디스커버리 경로가 이미 하던 방식과 같습니다. 비활성 백엔드는 집합 원소 검사로 조회 대상에서 빠지므로 AppProxy 동적 레플리카 같은 풀 전용 백엔드는 영향받지 않습니다. 404와 405는 401, 403과 함께 즉시 실패하는 영구 실패가 되고 5xx와 연결 오류는 재시도 예산을 그대로 씁니다. 응답이 하나 의도적으로 달라집니다. 그동안 조회 자체가 실패해 빠져 있던 Bedrock 모델이 집계 목록에 나타납니다. 내부 백엔드는 의도적으로 계속 조회합니다. 관리 카탈로그, control-plane 인벤토리, find_backends_for_model이 모두 사용자용 내부 필터 너머를 읽기 때문입니다.
  • ACP session/prompt가 모델을 아는 백엔드 선택을 거칩니다 (#1316, PR #1321). 선택이 풀 길이로 나눈 단순 카운터였던 탓에 요청한 모델도, 백엔드 헬스도, 서킷 상태도 무시했습니다. 백엔드마다 서빙 모델이 겹치지 않는 라우터에서는 전체 프롬프트의 (N-1)/N이 상류 404로 잘못 흘러갔고 -32603으로 드러났습니다. 같은 핸들러에서 acp.default_model은 선언되고 문서화되고 화면에 표시됐지만 한 번도 읽히지 않아, 모델을 지정하지 않은 프롬프트는 문자열 "default"를 그대로 상류로 보내 실패했습니다. 이제 후보는 모델 조회에서 나오고 사용자 라우팅 가시성 필터를 거쳐 다른 모든 디스패치 경로가 쓰는 공용 admission 이음매를 지나며, 이로써 헬스 필터링과 서킷 admission, 설정된 selection_strategy가 적용됩니다. ACP 트래픽도 결과를 서킷 브레이커에 반영합니다. 모델 해석은 요청 오버라이드 다음 acp.default_model 순이고 그 아래에 리터럴은 없습니다. 모델을 지정하지 않은 프롬프트는 임의 백엔드로 답하지 않고 -32602로 거절합니다. 오류에는 구조화 데이터가 실립니다(모르는 모델이면 data.field, data.value, data.source, 백엔드 HTTP 오류면 data.backend와 data.status). 빈 acp.default_model은 설정 검증에서 거절합니다. ACP는 설계상 얇은 디스패치 경로로 남아 HTTP 재시도 루프, 폴백 체인, 키별 백엔드 허용 목록을 물려받지 않으며, 이 결정은 모듈 문서와 아키텍처 문서에 적어 뒀습니다.
  • macOS 릴리스 바이너리를 Developer ID Application 인증서로 서명하고 공증합니다 (PR #1313). v1.21.0까지의 모든 macOS 릴리스는 서로 다른 두 이유로 다운로드 시 Gatekeeper에 거부됐습니다. 서명 리프가 App Store와 TestFlight 제출용 신원인 Apple Distribution 인증서였는데, 이 리프에는 App Store 밖에서 받은 파일을 Gatekeeper가 통과시킬 때 요구하는 1.2.840.113635.100.6.1.13 확장이 없습니다. 그래서 서명은 로컬에서 검증되면서 사용자 머신마다 거부됐습니다. codesign --sign "Distribution"이 신원 이름을 부분 문자열로 맞추는 탓에 키체인에서 그 인증서가 뽑혔습니다. 릴리스는 notarytool을 부른 적도 없었습니다. 공증 티켓은 별도 요건이라 인증서가 옳았더라도 격리 속성이 붙은 다운로드에서는 '악성 소프트웨어가 없는지 확인할 수 없다'는 거부가 그대로 났을 것입니다. 이제 서명은 복합 액션 두 개를 거치며 패키징 전에 Developer ID Application 권한, hardened runtime 플래그, 고정된 코드 서명 식별자를 단언하고, notarytool --wait에 제출해 status: Accepted를 게이트로 겁니다. 예전 워크플로에는 결과 권한을 확인하는 단계가 없었고 그래서 여덟 달 동안 조용히 출하됐습니다. 이제 잘못된 인증서는 조용히 나가는 대신 릴리스를 실패시킵니다.
  • Configuration 검토 대화상자의 'Confirm & save' 버튼이 눌립니다 (#1315, PR #1318). :disabled 표현식이 맨 .length 피연산자 두 개로 끝나서 막는 항목이 없을 때 전체 체인이 숫자 0으로 평가됐습니다. Alpine은 null, undefined, false일 때만 boolean 속성을 제거하므로 disabled가 그대로 남았고, 콘솔 오류 하나 없이 버튼이 영영 눌리지 않았습니다. 이제 두 피연산자를 > 0으로 비교합니다. src/webui/assets/partials/ 아래의 :disabled, :checked, :readonly, :required, :selected 바인딩을 전수 조사한 결과 안전하지 않은 표현식은 이것 하나뿐이었습니다.

v1.21.1 - 2026-08-12

실행 중인 태스크 실행기를 근거로 backend_tasks_v1을 광고해 Continuum Hub가 릴리스된 라우터에 백엔드 후보 프로브를 배정할 수 있게 했습니다. 지금까지 어느 카탈로그 항목에도 걸리지 않던 양자화·컨테이너 모델 id 16종을 해석하며 웹 UI 차트도 다크 모드에서 읽을 수 있게 고쳤습니다.

추가됨

  • NVIDIA Nemotron 3/3.5 카탈로그 항목 4개를 추가해 model-metadata.yaml을 230개에서 234개로 늘렸습니다 (#1306). nemotron-3.5-lightning-30b-a3b(총 30B, 활성 3B, Mamba-2와 어텐션을 섞은 하이브리드 MoE), nemotron-3-ultra-550b-a55b(총 550B, 활성 55B LatentMoE에 MTP 레이어), nemotron-3-nano-omni-30b-a3b-reasoning(31B 옴니모달, CRADIO v4-H 비전 인코더와 0.6B Parakeet 음성 인코더 탑재), nemotron-3.5-content-safety(4B Gemma-3-4B-it 기반에 LoRA 안전 어댑터)입니다. 별칭 감사는 새로 추가된 별칭을 모두 LOAD-BEARING으로 분류했고 깨진 기준선은 없습니다.

변경됨

  • 릴리스 빌드 기능 목록에 hot-reload를 명시하고 CI와 로컬 CI의 릴리스 합집합 단계도 거기에 맞췄습니다 (#1307). 동작은 그대로입니다. release.yml은 --no-default-features 없이 빌드하므로 default = ["full"]이 이미 hot-reload를 공급했고 배포된 바이너리에는 설정 핫 리로드가 늘 컴파일되어 있었습니다. 빠져 있던 것은 그 보장이며 지금까지는 상속에만 기대고 있었습니다.

수정됨

  • 아웃바운드 백엔드 태스크 채널이 붙어 있으면 하트비트 인벤토리가 backend_tasks_v1을 광고합니다 (#1305, PR #1310). 공개된 v1.20.0과 v1.21.0 바이너리는 실행기를 붙여놓고도 이 능력을 보고하지 않아 Hub가 후보 프로브 배정을 매번 HTTP 409로 거절했고, 백엔드 프로파일 워크플로를 릴리스된 라우터로 검증할 수 없었습니다. 원인은 세 가지입니다. build_inventory_heartbeat가 보고할 정책 상태를 정책 저장소의 뷰로 통째로 갈아치우면서 바로 앞에서 덧붙인 능력을 버렸습니다. 그래서 등록 시점에는 광고되고 하트비트마다 사라졌는데 Hub가 보관하는 쪽은 하트비트 인벤토리이므로, 릴리스 바이너리가 등록 직후에는 멀쩡해 보이다가 그 뒤로는 미지원으로 보였습니다. 능력을 정책 저장소가 있을 때만 덧붙이기도 해서 control_plane.policy를 끈 라우터는 아예 광고하지 못했습니다. 여기에 더해 inventory.rs가 실제로 붙인 실행기를 보는 대신 핫 리로드되는 설정 스냅샷에서 부착 여부를 다시 유도해 두 답이 어긋날 수 있었습니다. 이제 부착 여부는 런타임 래치이며 신원이 묶인 BackendTaskStore와 그 루프가 실제로 돌기 시작한 뒤에만 세워지고 루프가 끝나면 해제됩니다. 정책 저장소 유무와 무관하게 보고되며, 비활성 사유와 함께 구조화 로그와 GET /admin/control-plane/status에 드러납니다.
  • 그동안 어느 카탈로그 항목에도 걸리지 않던 모델 id 16종을 해석합니다 (#1297, PR #1309). layered_format_strip은 하이픈으로 끊긴 뒤쪽 토큰을 오른쪽에서 왼쪽으로 벗기다가 모르는 토큰을 만나면 거기서 멈췄고 그 왼쪽에 있는 아는 토큰에는 닿지 못했습니다. Trinity-Large-Thinking-FP8-Block은 block에서 멈춰 fp8을 끝내 벗기지 못했습니다. 이제 compressed-tensors 스케일 한정자 -block, -dynamic, -static을 인식하되 바로 왼쪽 토큰이 양자화 토큰일 때만 벗기므로 평범한 끝단어를 잘라내지 않습니다. 가중치·활성 표기 -W4A16, -W8A8, -W4A8은 RedHatAI가 붙이는 quantized. 접두 형태까지 인식하고 컨테이너 토큰 -ONNX, 재배포자 표식 -unsloth, -mxfp8도 인식합니다. Ministral-3-14B-Instruct-2512처럼 날짜 뒤에 플레이버가 오는 이름은 날짜를 떼어낸 형태를 벗기기 루프에 되먹여 해석합니다. -abliterated, -REAP-<params>, -DFlash처럼 동작이 달라지는 파생물은 직렬화만 다른 것이 아니라 다른 모델이므로 의도적으로 해석하지 않습니다.
  • Usage와 대시보드 화면의 uPlot 차트 축 라벨과 격자선을 다크 모드에서 읽을 수 있게 고쳤습니다 (#1304, PR #1308). uPlot은 축 색을 차트를 만들 때 결정해 <canvas>에 그리므로 내장 기본값인 #000 라벨과 rgba(0, 0, 0, 0.07) 격자가 .dark CSS 재정의를 모두 뚫고 살아남아 그래프 선만 읽히는 상태였습니다. 이제 축·격자·눈금·테두리 색을 공용 테마 헬퍼에서 가져오고 이미 그려진 차트도 라이트와 다크 토글, 자동 모드에서의 OS 테마 변경에 맞춰 페이지를 새로 고치지 않고 다시 칠합니다. 캔버스가 아니라 DOM인 드래그 선택 사각형과 십자선 커서는 CSS에서 바로잡았습니다.

v1.21.0 - 2026-08-11

서빙 인터페이스가 양방향 WebSocket 하나뿐인 음성 백엔드도 라우팅할 수 있도록 /v1/realtime을 서빙하고, 업그레이드마다 다른 무엇보다 먼저 Origin을 검사하며, 모델 카탈로그를 1차 출처만으로 189개에서 230개 항목으로 늘렸습니다.

추가됨

  • GET /v1/realtime?model=<id>와 /realtime SDK 호환 별칭을 추가했습니다. OpenAI Realtime 호환 WebSocket 프록시입니다 (#1299, PR #1301). 텍스트와 바이너리 프레임을 양방향 모두 바이트 단위로 동일하게 중계하며 프로토콜 변환은 하지 않습니다. 라우터는 라우팅, 인증, 상한 적용, 중계만 맡습니다. 이로써 이 WebSocket 외에는 아무것도 서빙하지 않는 NVIDIA NemotronLabs VoiceChat 추론 컨테이너 같은 백엔드에 닿을 수 있습니다. 업그레이드 전 처리는 HTTP와 같은 6단계 메타데이터 매칭 파이프라인으로 모델을 해석하므로 별칭과 접미사 정규화가 그대로 동작하고, audio 능력을 요구하며, find_backends_for_model과 filter_user_routing_candidates로 후보를 계산하고, HTTP 디스패치와 같은 선택·서킷 승인 이음매인 select_admissible_backend로 백엔드를 고릅니다. 호출자가 보낸 원본 모델 문자열이 별칭으로만 해석될 때는 백엔드에 정본 카탈로그 id를 보내고, 클라이언트에 돌려주는 404는 호출자가 보낸 문자열을 그대로 되비춥니다.
  • realtime 카고 기능을 추가했습니다 (#1299). 인바운드 업그레이드용 axum/ws와 아웃바운드 다이얼용 기존 선택적 tokio-tungstenite를 켭니다. full에 포함되며 embed에서는 의도적으로 도달할 수 없습니다. embed의 부정 계약이 tokio-tungstenite를 배제하기 때문이고, scripts/assert-embed-feature-graph.sh는 여전히 통과하며 cargo check --no-default-features --features realtime --lib로 이 기능이 단독으로 성립함을 확인했습니다.
  • 항상 파싱되는 선택적 realtime 설정 섹션을 추가했습니다 (#1299). enabled(기본 false), max_sessions(256), handshake_timeout(10s), idle_timeout(60s, "0"이면 비활성), max_session_duration("0"이면 무제한), backend_path(/v1/realtime)이며 모두 공유 parse_duration 계약을 따릅니다. 검증은 impl Validate for Config와 실제 로드 경로 퍼널 양쪽에 배선했으므로, 잘못된 섹션은 시작, 핫 리로드, config validate, 관리 설정 API에서 모두 실패합니다. 두 타이머를 동시에 끄면 실패 대신 경고합니다. 그 조합은 세션에 마감 시한이 없는 상태를 만들어, half-open 클라이언트가 OS TCP keepalive가 만료될 때까지 max_sessions 슬롯을 붙들 수 있기 때문입니다. 섹션은 라우트를 구성할 때 스냅샷으로 잡으므로 변경에는 재시작이 필요합니다.
  • 관측 가능한 부수 효과가 생기기 전에 교차 사이트 WebSocket 업그레이드를 거부합니다 (#1299). WebSocket 핸드셰이크는 CORS와 동일 출처 정책을 모두 우회하고, fetch와 달리 업그레이드를 시작한 페이지에 스트림 양방향 읽기 권한을 통째로 넘깁니다. 그래서 Origin 검사를 엔드포인트 게이트 직후, 모델 파싱과 모델 해석, 후보 계산, 백엔드 선택, 세션 슬롯 획득보다 앞에 둡니다. 거부된 업그레이드는 백엔드를 다이얼하지 않고, 서킷 이벤트를 남기지 않으며, 세션 슬롯도 소모하지 않습니다. Origin이 없으면 허용합니다. 비브라우저 SDK는 모두 보내지 않고 브라우저는 위조할 수 없기 때문입니다. 값이 있으면 요청과 동일 authority이거나, server.cors.enabled가 켜져 있고 server.cors.allow_origins가 CORS 레이어와 같은 매처로 일치할 때 허용합니다. 그 밖의 모든 경우는 * 패턴 아래의 불투명 리터럴 null까지 포함해 HTTP 403과 오류 코드 origin_not_allowed로 거부합니다. 브라우저 출처는 realtime 전용 목록을 따로 두지 않고 server.cors 한 곳에서 선언합니다.
  • realtime 세션에 모든 축의 상한을 두었습니다 (#1299). max_sessions 세마포어, 양쪽 레그의 4 MiB 메시지·프레임 상한, handshake_timeout 안의 다이얼, 프레임마다 갱신되는 유휴 타이머, 선택적 절대 세션 상한입니다. HTTP timeouts는 세션에 적용되지 않습니다. 중계는 방향마다 펌프 future 하나를 두고 단일 select!에서 돌리며, 각 펌프는 자기 레그의 읽기 절반과 반대 레그의 쓰기 절반을 소유합니다. 그래서 close 프레임이 원래 코드와 사유를 그대로 달고 전파되고, 한쪽의 정상 종료는 반대쪽을 1000으로, close 핸드셰이크 없이 사라진 피어는 살아남은 쪽을 1011로 닫습니다. 배압은 send await 하나뿐입니다.
  • metrics와 realtime을 함께 켰을 때 realtime 지표 네 개를 추가했습니다 (#1299). realtime_active_sessions, 닫힌 6개 결과 어휘를 쓰는 realtime_sessions_total, 방향별 realtime_frames_relayed_total, realtime_session_duration_seconds입니다. 호출 지점은 항상 컴파일되고 cfg로 갈리는 no-op 이음매를 지납니다.
  • Admission::settle_success를 추가했습니다 (#1299). WebSocket 업그레이드는 101로 응답하는데 settle_status는 이를 잘못 기록했을 것이고, 다이얼 실패는 HTTP 페일오버가 이미 공유하는 서킷에 연결 실패로 정산됩니다.
  • 모델 메타데이터 41개 항목을 추가하고 항목이 아예 없던 9개 계열을 채웠습니다 (#1298). Liquid AI LFM2.5(11개), Ai2 Molmo 2, Microsoft Florence-2, Arcee AI Trinity와 AFM, Xiaomi MiMo V2.5, M3까지의 MiniMax 구 카탈로그, Jina VLM, NVIDIA LocateAnything 3B, Pokee Isaac, Alibaba qwen3.8-27b, 그리고 한국 소버린 AI 파운데이션 모델 두 개인 a.x-k2(SK텔레콤)와 k-exaone-2.0-750b-a37b(LG AI연구원)입니다. 모든 값은 1차 출처에서 왔습니다. 컨텍스트 윈도와 전문가 수는 HuggingFace config.json, 파라미터 구성과 라이선스는 벤더 모델 카드, 가격은 벤더 요금표입니다. 벤더가 아무것도 공개하지 않은 필드는 추정하지 않고 문서화된 0으로 두고 이유를 주석에 남겼습니다. 해석은 양자화와 컨테이너 접미사 사슬을 포함한 실제 HuggingFace 저장소 id 60개로 검증했습니다.
  • nvidia-nemotronlabs-voicechat-11b를 추가했습니다 (#1300). OpenMDW-1.1 라이선스의 NVIDIA 11B 전이중 speech-to-speech 모델이며, nemotron-voicechat-11b와 nemotronlabs-voicechat-11b 별칭을 명시적으로 등록했습니다. 벤더와 랩 세그먼트는 접미사로 벗겨지는 토큰이 아니어서 짧은 형태가 정규화만으로는 도달하지 않기 때문입니다. context_window는 문서화된 0입니다. 저장소가 transformers 설정이 아니라 NeMo 학습 설정을 싣고 있고 모델 카드도 토큰 한도를 밝히지 않았기 때문이며, 백본 자체의 128K 윈도는 이 체크포인트가 주장한 적이 없으므로 의도적으로 단언하지 않았습니다.
  • muse-glimmer-30b를 추가했습니다 (#1302). Apache-2.0 라이선스의 Meta 30B 밀집 멀티모달 에이전트 모델이며, context_window: 131072는 공개된 config.json의 max_position_embeddings에서 가져왔고 code 능력은 가정이 아니라 SWE-Bench 점수를 근거로 부여했습니다. 별칭은 필요 없습니다. HuggingFace 저장소 형태와 공식 GGUF 양자화 꼬리표가 모두 저장소 접두사 제거와 기존 peel로 해석되며, id 형태 7종 프로브로 확인했습니다.

변경됨

  • qwen3.8-max-preview를 qwen3.8-max로 승격했습니다 (#1298). 알리바바가 2026-08-03에 공개 요율과 이미지·비디오 입력을 확정해 GA로 출시했는데, 항목은 여전히 가격이 0이고 비전 능력이 없는 크레딧 전용 프리뷰로 기술돼 있었습니다. 프리뷰 id는 별칭으로 남겨 기존 호출자가 계속 해석되도록 했습니다.
  • 카탈로그에서 minimax-m2.5 가격을 UNVERIFIED로 표시했습니다 (#1298). MiniMax가 이 모델을 레거시 카탈로그로 옮기고 요금표 공개를 중단해 물려받은 요율을 확인할 방법이 없습니다. 출처 없는 숫자로 대체하는 대신, 비용 라우팅에 이 값을 신뢰하지 말라고 주석으로 경고합니다.
  • 요청 로깅에서 Sec-WebSocket-Protocol과 proxy-authorization을 마스킹합니다 (#1299). OpenAI의 브라우저 Realtime 클라이언트는 WebSocket에 Authorization을 설정할 수 없어 API 키를 서브프로토콜 토큰에 실어 보내고, 그대로 두면 trace 레벨 로그 줄에 남습니다.

수정됨

  • minimax-m2.7-highspeed 가격을 바로잡았습니다 (#1298). 표준 M2.7과 같은 요율로 기록돼 있었지만 highspeed 티어는 실제로 2배로 과금합니다.

제거됨

  • mimo-v2-pro와 mimo-v2-omni를 제거했습니다 (#1298). 둘 다 가중치 공개 없는 호스팅 전용 엔드포인트였고 샤오미가 2026-06-30에 내렸습니다. MiMo V2.5 항목이 이들을 대체합니다. mimo-v2-flash는 MIT 가중치가 계속 공개돼 있고 자체 호스팅이 가능하므로 남깁니다.

보안

  • realtime 프레임 페이로드는 오류 텍스트를 포함해 어떤 레벨에서도 로깅하지 않습니다 (#1299). 두 WebSocket 라이브러리 모두 잘못된 프레임의 손실 복사본을 UTF-8 디코드 오류 안에 담기 때문에, 읽기 오류는 오류를 문자열에 끼워 넣는 대신 상한이 있는 분류기로 보고하며, 프레임 바이트가 로그 줄에 닿을 수 없음을 단위 테스트로 확인합니다.

문서

  • realtime 운영자 문서 docs/en/configuration/realtime.md와 docs/ko/configuration/realtime.md를 추가하고 두 Zensical 내비게이션에 넣었습니다 (#1299). 브라우저 출처 표와 세션당 메모리 산정(최악의 경우 max_sessions * 2 * 4 MiB)을 담았고, 영문과 국문 아키텍처 가이드에 세션 수명 주기 절을, config.yaml.example에 주석 블록을 함께 추가했습니다.
  • 설정 어시스턴트를 스키마 1.36.0으로 올리며 realtime 섹션 항목을 넣고, "브라우저 realtime 클라이언트가 403을 받는" 행을 포함해 IDE 규칙 파일을 재생성했으며, 드리프트 테스트 인벤토리도 같은 변경에서 갱신했습니다 (#1299). 섹션 수는 36개입니다. 항상 켜진 34개에 기능 게이트 2개를 더한 값입니다.

CI

  • webpki-roots에 크레이트 단위 cargo-deny 라이선스 예외를 추가했습니다 (#1299). 이 크레이트는 CDLA-Permissive-2.0(허용적 데이터 라이선스)의 Mozilla CA 번들이고, realtime이 full에 들여온 rustls-tls-webpki-roots 기능을 통해 기본 그래프에 들어옵니다. 공식 릴리스 바이너리는 이미 control-plane을 통해 이 크레이트를 실어 왔지만 cargo-deny는 기본 기능만 해석하므로, 이번이 처음 마주친 빌드입니다. 예외는 전역 허용 목록에 라이선스를 추가하는 대신 이 크레이트 하나로 좁혔으므로, 앞으로 같은 라이선스의 코드 크레이트가 들어오면 여전히 큰 소리로 실패합니다.
  • Azure/setup-helm을 v4.3.1에서 v5.0.1로 (#1295), Azure/setup-kubectl을 v4.0.1에서 v5.1.0으로 (#1293), actions/attest-build-provenance를 v3.0.0에서 v4.2.2로 (#1294) 올렸습니다.

의존성

  • clap을 4.6.5에서 4.6.6으로, lru를 0.18.1에서 0.18.2로 올리고 (#1296) Cargo.lock을 현재 semver 호환 집합으로 갱신했습니다.

알려진 이슈

  • .github/workflows/ci.yml의 dorny/paths-filter rust 필터에 model-metadata.yaml이 없어서, 메타데이터만 바꾼 PR은 그 파일을 파싱하고 단언하는 통합 테스트가 여럿 있는데도 Rust 잡을 전부 건너뜁니다. 이번 릴리스의 메타데이터 변경 3건은 대신 로컬에서 게이트를 통과시켰습니다. 필터에 model-metadata.yaml과 model-metadata.d/**를 추가하면 닫힙니다.

v1.20.0 - 2026-08-09

백엔드 토폴로지와 백엔드 자격 증명을 아웃바운드 전용 채널로 Continuum Hub에 넘기고, TTFT 텔레메트리에 남아 있던 마지막 공백을 메우고, 메타데이터 로더가 버리고 있던 이미지 가격·제한 키를 실제로 서빙하며, 스마트 라우팅 LLM 분류기를 실 트래픽을 처리하는 것과 같은 백엔드 구현 위로 옮겼습니다.

추가됨

  • 허브가 관리하는 백엔드 스냅샷을 위한 라우터 측 backend_config_v1 실행기를 control_plane.config_sync.backends로 추가했습니다 (#1264). 옵트인 mode(disabled, overlay, authoritative), apply-id로 키를 잡은 결과 아웃박스를 갖춘 신원 결속 last-known-good 스냅샷 저장소, 기존 단일 풀 라이터를 통한 원자적 검증과 핫 리로드, graceful drain 근거, 하트비트의 메타데이터 전용 수렴 상태를 포함합니다. 허브가 준 안정 id는 그대로 쓰고 재도출하지 않으며, 해석할 수 없는 토큰은 추측 대신 거부합니다. 사전 활성화 상태 검사는 새로 들어오거나 실질적으로 바뀐 활성 백엔드에 대해서만 수행합니다. 협의된 continuum-protocol 백엔드 설정 계약은 출처를 기록한 채로 벤더링했고, 허브의 픽스처 9개와 정규 다이제스트 벡터 2개를 테스트로 고정해 두었으므로, 양쪽의 정규화가 어긋나면 조용히 다른 내용을 가리키는 대신 테스트가 실패합니다.
  • backends[].enabled를 추가했습니다. 기본값은 true입니다 (#1264). 비활성 백엔드는 라우팅 후보와 모든 모델 목록 표면(OpenAI, extended, 단일 모델, Anthropic)에서 빠지지만, 풀에는 그대로 생성되고 상태 검사와 후보 프로브, 모델 디스커버리 대상으로 남습니다. 제외는 forbidden이 아니라 model-not-found로 읽힙니다. 값이 없으면 와이어에도 실리지 않으므로 기존 설정의 동작은 그대로이고, 이 플래그를 바꾸면 핫 리로드 변경 감지에 걸립니다.
  • credential_id로 색인되는 백엔드 범위 허브 자격 증명 번들을 추가하고 backend_credentials_v1로 광고합니다 (#1269). 프로바이더당 자격 증명 하나에 마지막 항목이 이기던 제약이 사라지고, 자격 증명이 붙은 스냅샷을 모두 credential_unavailable로 거부하던 리졸버 이음매가 닫힙니다. credential_ref를 가진 허브 소유 백엔드는 안정 백엔드 id로 정확히 그 자격 증명만 해석하고, 로컬 api_key 없이도 동작하며, 프로바이더 전역이나 로컬 시크릿으로 절대 폴백하지 않습니다. ref가 없는 허브 소유 백엔드는 허브 자격 증명을 보내지 않고, 로컬 백엔드의 우선순위는 그대로입니다. 확인 다이제스트는 id, 프로바이더, 버전만 덮고 시크릿 바이트는 절대 포함하지 않으며, 완전한 실행기가 살아 있을 때만 기능을 광고합니다.
  • 아웃바운드 허브 백엔드 태스크 채널을 추가하고 backend_tasks_v1로 광고합니다 (#1270). 라우터가 허브가 작성한 백엔드 후보 프로브와 마스킹된 로컬 백엔드 내보내기를 자신의 아웃바운드 연결로 실행하므로, 허브는 고객 네트워크 안의 후보에 닿고 라우터가 무엇을 설정해 두었는지 읽으면서도 라우터에는 허브를 향한 인바운드 엔드포인트가 하나도 생기지 않습니다. 프로브는 두 번째 구현이 아니라 기존 Admin 원시 기능 그대로입니다. control-plane이 admin을 켜지 않으므로 도메인은 항상 컴파일되는 src/backend_probe/로 옮겼고, src/admin_config/backend_probe_support.rs는 얇은 axum 어댑터가 되었으며, Admin 요청·응답 형태는 바뀌지 않았습니다. 두 호출자는 프로세스 전역 동시성·요청률 상한 하나를 공유합니다.
  • /v1/responses 스트리밍 사용량 탭 두 곳 모두 ttft_ms를 보고합니다 (#1268). 이전에는 둘 다 TTFT 없이 사용량 레코드를 올렸기 때문에, Responses API로 트래픽이 흐르는 플릿은 prefill·decode 패널이 비어 있었습니다. 상류 디스패치를 라우터가 직접 하고 스트리밍 이벤트도 전부 전달하므로 두 시점 모두 존재했고 다만 측정하지 않았을 뿐입니다.
  • 비스트리밍 완성에도 ttft_ms를 기록합니다 (#1271). /v1/chat/completions, /v1/completions, /v1/responses, /anthropic/v1/messages 네 계열 모두 해당합니다. 프로바이더 디스패치부터 상류 응답 헤더 도착까지의 구간은 라우터가 실제로 관측한 첫 바이트 시점이지 지어낸 값이 아닙니다. API 라우트 그룹 전체에 한 번 설치되는 요청 범위 tokio::task_local!로 전달하므로 시그니처 변경은 없습니다.
  • 로컬 캐시 재생에 ttft_ms를 찍고, 프로바이더 배치 예외를 테스트로 고정했습니다 (#1272). 재생은 본문 전체를 한 번에 쓰므로 첫 바이트와 마지막 바이트가 실제로 같은 시점이고, 도출 로직은 재생 7개 지점 뒤에 있는 단일 생산자에 한 번만 둡니다. 재생은 completion_tokens: 0을 보고하므로 decode와 TPOT 도출에는 영향이 없습니다.
  • per_image, image_input_tokens, cached_image_input_tokens를 선택적 PricingInfo 필드로 추가했습니다 (#1279). PricingInfo에는 필드가 정확히 3개뿐이고 deny_unknown_fields도 없어서, 배포되는 카탈로그의 이미지 가격이 역직렬화 단계에서 조용히 버려졌습니다. 유료 이미지 모델 9종이 pricing: {input_tokens: 0, output_tokens: 0}을 광고했고 gpt-image-2는 텍스트 요율만 내보냈습니다. per_image는 생성 이미지당 USD를 담는 untagged flat 또는 품질별 맵으로, 100만 토큰당 요율과는 단위가 다릅니다.
  • ModelMetadata가 버리고 있던 이미지 limits 키 8종을 추가했습니다 (#1284). max_prompt_length, supported_sizes, max_n, supported_qualities, supported_output_formats, supports_streaming, 그리고 dall-e-3의 qualities/styles이며 뒤의 둘은 supported_qualities/supported_styles로 정규화합니다. 모두 API 소비자를 위한 정보성 필드이고 이미지 요청 처리 방식은 그대로입니다. 알 수 없는 메타데이터 키는 이제 사라지는 대신 모델 id가 붙은 점 표기 경로를 나열하는 로드 시점 경고를 한 번 남기며, 여전히 거부하지는 않습니다. metadata download가 구버전 바이너리로 신버전 카탈로그를 받을 수 있어야 하기 때문입니다.

변경됨

  • 스마트 라우팅 LLM 분류기를 하드코딩된 엔드포인트 경로와 인증 헤더를 가진 자체 reqwest 클라이언트 대신 Backend::execute_chat_completion으로 디스패치합니다 (#1290, PR #1292). URL 구성, 프로토콜 변환, 실효 인증을 실 트래픽을 처리하는 백엔드 구현이 그대로 담당하므로, 알려진 사례 몇 개가 아니라 전송 계층 불일치라는 부류 자체가 사라집니다. 백엔드는 분류 호출마다 이름으로 라이브 풀에서 해석하므로 핫 리로드 뒤에 낡은 전송 경로가 남을 수 없습니다. 설정에는 있지만 라이브 풀에 없는 classifier.llm.backend 이름은 호출 단위로 백엔드 이름을 밝히며 실패하고 규칙 분류기로 폴백합니다. 동작하던 배포가 부팅 실패로 바뀌지는 않습니다. 구조화 출력의 prompt_only 강제 범위에 anthropic과 함께 bedrock이 들어갔습니다.
  • ModelMetadata.pricing의 단위를 100만 토큰당 USD로 확정했습니다 (#1276). 문서는 1000 토큰당이라고 적혀 있었지만 실제로 배포되는 값은 전부 100만 토큰당이었고, 코드에서 서로 독립적인 근거 세 가지가 이미 100만 토큰당으로 읽고 있었습니다. 카탈로그 값은 하나도 재조정하지 않았고, 문서 주석과 사용자 문서, 카탈로그 헤더, WebUI 레이블이 이제 일치합니다.
  • smart_routing의 유한하지 않거나 범위를 벗어난 실수를 사용 시점이 아니라 설정 로드 시점에 거부합니다 (#1286, #1288). 프로파일의 cost_per_1k_input_tokens와 cost_per_1k_output_tokens, load_management.recovery.hysteresis_factor, load_management.thresholds[].error_rate, 그리고 classifier의 실수 두 개를 실제 설정 경로에서 검증하므로 시작, 설정 감시자, control-plane 설정 동기화, 관리자 설정 API가 모두 덮입니다. 아래 호환성 변경 사항을 참고하십시오.
  • 런타임 재시도의 모든 기간 값이 설정 검증기와 같은 계약을 읽도록 맞췄습니다 (#1282). parse_duration은 단위 없는 양의 정수를 초로 받으므로 retry.initial_delay: "2"는 검증을 통과하지만, RetryHandler는 접미사만 인식하는 자체 파서를 들고 있다가 조용히 100ms와 2s로 떨어졌고 Retry-After 상한은 또 다른 10s 폴백을 썼습니다. 검증을 통과한 2s/8s 정책이 100ms/2s로 실행될 수 있었습니다. 이제 런타임 경로가 모두 같은 계약을 읽고, 방어적 폴백은 필드마다 상수 하나로 통일되며, 폴백이 걸리면 필드 이름과 설정값, 대체된 값을 담은 경고를 남깁니다.

수정됨

  • Gemini 분류기 백엔드 주소에 버전 세그먼트가 겹쳐 붙던 문제를 고쳤습니다 (#1290, PR #1292). 라우터의 기본 GEMINI_API_BASE_URL은 /v1beta/openai로 끝나는데 분류기의 자체 클라이언트가 여기에 /v1/chat/completions를 덧붙였습니다.
  • Bedrock 분류기 백엔드를 실제 전송 경로로 디스패치합니다 (#1290, PR #1292). 이전에는 Bedrock 분류기 설정이 전부 동작하지 않았습니다. bedrock-sigv4 없이 빌드한 경우 백엔드가 백엔드 이름과 endpoint_type을 밝히는 운영자용 오류를 반환하고 분류기가 폴백 전에 그 내용을 그대로 노출하므로, 실패가 더 이상 익명이 아닙니다.
  • 분류기 핫 패스에서 프로바이더 오류 문자열의 길이를 제한합니다 (#1290, PR #1292). Anthropic Messages 프로토콜 계열 백엔드는 상류 본문 전체를 오류에 담아 반환하고 그 문자열이 분류 실패마다 두 번 기록되므로, 오작동하는 분류기 백엔드 하나가 분류기에 닿는 모든 요청마다 상류가 제어하는 텍스트를 무한정 로그에 쓸 수 있었습니다(발생 1회당 12,037바이트로 재현). 이제 MAX_BACKEND_ERROR_BYTES까지 문자 경계에서 잘라냅니다.
  • 샘플링 파라미터를 금지하는 Claude 모델에는 분류기 페이로드에서 temperature를 뺍니다 (#1289, PR #1291). Anthropic은 Claude Opus 4.7 이상에서 이 파라미터를 값이 아니라 존재 자체로 거부하므로, Claude 4.7 이상을 분류기 모델로 쓰면 기본값 temperature: 0.0에서도 모든 분류 요청이 HTTP 400을 받았습니다. 잘못 설정할 필요조차 없었고 실패는 조용했습니다. 라우터는 timeout_ms만큼 왕복을 낭비한 뒤 규칙 분류기로 내려갔으므로, 운영자에게는 스마트 라우팅이 동작하는 것처럼 보이면서 설정하고 비용을 낸 LLM은 한 번도 쓰이지 않았습니다.
  • /v1/responses 캐시 재생을 정확히 한 번만 계측합니다 (#1273, PR #1283). 재생이 control-plane 사용량 이벤트를 두 개 올렸고 두 번째가 served_from_local_cache: false로 표시되어, 프로바이더 토큰을 전혀 쓰지 않은 요청에 대해 허브가 해당 키의 TPM 윈도와 월 사용량을 청구했습니다. 나머지 세 인바운드 계열은 이미 올바르게 동작하고 있었습니다.
  • 자동 추론된 스마트 라우팅 프로파일 비용이 1000배 크던 문제를 고쳤습니다 (#1276). ModelTierRegistry::infer_from_metadata가 100만 토큰당 pricing을 형제 함수가 수행하는 나눗셈 없이 1000 토큰당 cost_per_1k_* 필드에 그대로 복사했습니다. 그 영향이 추론 프로파일과 설정 프로파일을 섞어 계산하던 비용 절감 추정, 모델 선택 랭킹, GET /admin/smart-routing/model-profiles와 WebUI가 보고하는 값에 미쳤습니다. 명시적으로 설정한 프로파일과 glob 패턴 프로파일은 영향을 받지 않습니다. 아래 호환성 변경 사항을 참고하십시오.
  • 관리자 WebUI 모델 카탈로그의 가격 열 레이블을 per 1K에서 per 1M으로 고쳤습니다 (#1276). 숫자는 처음부터 맞았고 레이블만 틀렸는데, 하류 소비자를 함정에 빠뜨린 것과 같은 오기였습니다.
  • 그동안 도달할 수 없던 PricingInfo·ModelLimits 범위 검증을 실제 로드 경로에 연결했습니다 (#1284). 위반은 런타임 로드 시 모델당 한 번 경고하고, continuum-router metadata download에서는 하드 에러입니다. 잘못된 다운로드가 정상 파일을 덮어쓰면 안 되기 때문입니다. context_window와 max_output의 범위는 0을 해당 없음으로 두고 [0, 10000000]으로 조정했습니다. 배포되는 카탈로그가 모델 17종에서 context_window: 0을, 43종에서 기존 상한을 넘는 max_output을 싣고 있기 때문입니다.
  • metadata download가 기존 로컬 파일과 비교하지 못할 때 그 이유를 밝힙니다 (#1284). 이전에는 로컬 파일이 없었던 것처럼 조용히 넘어갔습니다.

호환성 변경 사항

  • smart_routing 모델 프로파일 비용에 음수, NaN, 무한대가 들어 있는 config.yaml은 이제 시작 단계에서 실패하고, 핫 리로드는 이전 설정을 유지합니다 (#1286). 이전에는 비용이 정확히 -1.0이면 모델 점수가 무한대가 되어 그 모델이 같은 티어의 모든 후보 비교에서 이기고 형제 모델로 갈 트래픽을 조용히 빨아들였는데, 설정 오류도 로그 한 줄도 남지 않았습니다. 그런 비용을 담은 관리자 PUT /admin/smart-routing/model-profiles는 이제 400을 반환합니다. 같은 검증 경로를 타면서, model과 model_pattern의 200자 제한도 설정 로드에서 처음으로 실제로 걸리게 되었습니다.
  • smart_routing.load_management.recovery.hysteresis_factor와 load_management.thresholds[].error_rate, 그리고 classifier의 실수 두 개를 설정 로드 시점에 [0.0, 1.0]으로 제한합니다 (#1288). 앞의 둘은 문서에 (0.0 - 1.0)이라고 적혀 있었지만 어디서도 강제하지 않았습니다. 흔한 부호 오타인 음수 hysteresis_factor는 상승 상태 유지 판정을 무조건 참으로 만들어, 라우터가 한 번 Warning이나 Critical로 올라가면 다시 내려오지 않고 degradation이 영구히 적용된 채로 남았습니다.
  • 자동 추론된 스마트 라우팅 프로파일 비용이 올바른 값으로 1000배 줄어듭니다 (#1276). GET /admin/smart-routing/model-profiles 행과 WebUI 스마트 라우팅 표의 값이 그만큼 바뀌고(자동 추론된 GPT-4o 행이 2.5에서 0.0025로), 추론 프로파일과 설정 프로파일 사이의 후보 순서가 달라질 수 있습니다. 다만 그 방향은 둘을 비교할 수 있게 되는 쪽입니다.

문서

  • 허브 백엔드 동기화 계약을 어시스턴트와 IDE 표면 전반에 문서화했습니다 (#1264). 스키마 1.35.0의 설정 어시스턴트에 config_sync.backends.*와 enabled 항목을 넣고, IDE 규칙 파일을 재생성하고, MCP fleet_backend_sync_contract 리소스와 explain_effective의 enabled 투영을 추가하고, config.yaml.example에 비활성 백엔드 절을 넣고, 영문·국문 아키텍처 문서와 백엔드 설정 가이드에 새 절을 추가했습니다.
  • 메타데이터의 알 수 없는 키 경고, 범위 위반 경고, dall-e-3 키 정규화를 영문·국문 고급 설정 가이드에 함께 문서화했습니다 (#1284).
  • 영문·국문 문서와 배포 카탈로그 헤더에 남아 있던 1000 토큰당 가격 예시를 모두 고쳤습니다 (#1276). 같은 페이지에서 450줄 위의 표와 어긋나 있던 /v1/models 응답 예시도 포함합니다.
  • TTFT 텔레메트리 계열(#1268, #1271, #1272)과 이미지 가격 키(#1279)에 대한 이중 언어 기술 보고서를 추가했습니다.

CI

  • 릴리스 이미지를 각자의 플랫폼에서 스캔합니다 (#1260). 빌드 단계가 다이제스트로 푸시하므로 그 다이제스트는 플랫폼 하나만 담은 인덱스를 가리키는데, 러너는 amd64이고 Trivy의 기본값도 linux/amd64라 arm64 스캔이 패키지 하나도 읽기 전에 no child with platform linux/amd64 in index로 중단됐습니다. v1.19.0 릴리스가 정확히 여기서 실패했습니다. 이제 두 단계 모두 TRIVY_PLATFORM을 지정하므로 러너 아키텍처에 의존하지 않습니다.
  • 통합 테스트 캡처 하네스의 tracing interest를 고정했습니다 (#1274, PR #1285). audit_decision_action_is_allow_in_monitor_mode가 전체 스위트 실행 네 번에 한 번꼴로 실패하면서 단독 실행으로는 재현되지 않았습니다. tracing이 callsite별 interest를 프로세스 전역 테이블에 캐시하고, 등록된 디스패처가 하나 이하일 때는 그 캐시를 재구축하는 스레드의 구독자로 해석하기 때문에, 구독자가 없는 형제 테스트가 해당 callsite를 바이너리 전체에서 꺼버린 것이 원인입니다. 크레이트 내부 하네스가 이미 갖고 있던 가드를 통합 테스트 바이너리에도 설치하고, 가드가 없으면 결정적으로 실패하는 회귀 테스트를 함께 넣었습니다.

v1.19.0 - 2026-08-06

변경됨

  • 컨테이너 이미지를 두 종류가 아니라 하나만 발행하며, gcr.io/distroless/static-debian12 위에서 빌드합니다 (#1256). 이미지에는 정적 링크된 musl 바이너리와 passwd 파일, 타임존 데이터가 들어가고 uid 65532로 실행됩니다. -alpine 태그 계열은 더 이상 발행하지 않습니다. :<version>-alpine, :<major>.<minor>-alpine, :latest-alpine에 새 태그가 붙지 않으며, :<version>, :<major>.<minor>, :latest에서도 Debian 베이스가 빠집니다. -alpine 태그를 고정해 둔 배포는 접미사 없는 태그로 옮겨야 합니다. 이미지에 셸과 패키지 관리자가 없으므로 docker exec ... sh와 kubectl exec ... -- sh는 더 이상 동작하지 않습니다. 이를 대신하는 임시 컨테이너 방식은 배포 가이드에 실었고, 라우터의 config 서브커맨드와 --health-check는 이미지 엔트리포인트로 셸 없이 실행됩니다. 기존 Debian 베이스에는 재빌드로 해소할 수 없는 CRITICAL·HIGH 22건이 있었는데, 전부 라우터가 쓰지 않는 패키지의 미수정 취약점이었습니다.
  • Dockerfile.alpine과 Dockerfile.alpine.ci를 삭제했습니다. Dockerfile과 Dockerfile.ci가 distroless 이미지를 빌드하며 musl 릴리스 아카이브를 사용합니다.

수정됨

  • 컨테이너 HEALTHCHECK가 동작하도록 고쳤습니다 (#1256). perform_container_health_check가 대상 주소를 SocketAddr로 파싱했는데 이 타입은 숫자 주소만 받으므로 모든 호스트명이 거부됐고, --health-check-url 기본값에 들어 있는 localhost도 마찬가지였습니다. 그래서 #198에서 도입된 이래 발행된 모든 이미지에서 선언된 상태 검사가 실패해 왔습니다. 이제 파싱 대신 이름을 해석하며, 해석된 주소를 차례로 시도한 뒤에야 실패를 보고합니다.

CI

  • 릴리스 이미지 스캔 단계가 실제로 존재하는 Trivy 릴리스를 설치하도록 고쳤습니다 (#1255). aquasecurity/trivy-action v0.33.1의 기본 Trivy 버전은 v0.65.0인데 상류에서 그 릴리스 에셋을 삭제해, 설치 스크립트가 남아 있는 git 태그만 해석해 found version: 0.65.0을 찍은 뒤 에셋 다운로드에서 실패했습니다. 스캔 단계가 0.3초 만에 죽었고 재실행으로는 통과할 수 없었습니다. 이제 액션은 v0.36.0이고 스캔 4단계 모두 Trivy v0.73.0을 명시적으로 고정합니다.
  • self-hosted macOS CI 잡을 self-hosted-macos-15-x64 러너에서 실행합니다 (#1259).

v1.18.0 - 2026-08-05

아직 등록하지 않은 백엔드 후보를 위한 것과 이미 설정된 백엔드 하나를 위한 것, 두 개의 관리자 인증 디스커버리 엔드포인트를 추가하고, 유지 관리되는 쿠버네티스 배포 프로파일과 릴리스 이미지 공급망 게이트를 함께 제공합니다.

추가됨

  • 등록 전 백엔드 후보를 검사하는 비변경 프로브 POST /admin/backends/probe를 추가했습니다 (#1252). 백엔드 생성과 동일한 후보 필드에 operations: ["health", "models"]를 더해 받고, 유닉스 소켓을 포함한 모든 Admin 전송에서 관리자 인증을 거치며, GET /admin/capabilities가 transient_backend_probe_v1로 광고합니다. 후보는 요청 범위 안에만 존재합니다. 활성 설정, 설정 이력, 백엔드 풀, 헬스 상태, 서킷 브레이커, 집계 캐시, 환경 파일, 토큰 스토어 어디에도 기록하지 않습니다. 응답은 health.credential_status(valid, invalid, unknown, not_required)와 catalog.source(live, curated, configured, unsupported)를 분리해서 돌려주므로, 인증 없이 살아 있는 엔드포인트를 자격 증명이 유효하다는 증거로 잘못 읽을 수 없습니다. 모델 목록을 제공할 수 있는 후보는 등록된 디스커버리가 쓰는 URL 조합, 인증 헤더, 파서, 정규화, 응답 크기 상한, 백엔드별 모델 개수 상한을 그대로 재사용합니다. 요청 본문, 동시 프로브 수, 요청 빈도, 업스트림 시간, 응답 바이트, 반환 모델 개수에 모두 상한이 있고, 자격 증명이 담긴 URL은 거부하며, 검증·헬스·인증·업스트림·타임아웃·파싱·응답 크기 오류는 전부 정제해서 내보냅니다.
  • 설정된 백엔드 하나의 라이브 모델 카탈로그를 backends[].models 허용 목록 적용 전에 돌려주는 POST /admin/backends/{name}/models/discover를 추가했습니다 (#1245). 이름으로 정확히 하나의 백엔드를 해석하고 일반 집계가 쓰는 모델 페처를 그대로 재사용하므로 타임아웃, 응답 크기 상한, 백엔드별 모델 개수 상한, 정규화, Codex 계정 플랜 필터링, 중앙화된 OAuth 갱신이 모두 유지됩니다. 아무것도 기록하지 않습니다. 설정, 집계 모델 캐시, 헬스 상태, 서킷 브레이커 상태는 그대로이고 평소의 집계 모델 노출도 영향을 받지 않습니다. 네이티브 Anthropic, Bedrock, Codex가 아닌 OAuth 백엔드는 설정된 모델 이름으로 대체하는 대신 model_discovery_unsupported와 함께 501을 반환하고, 알 수 없는 이름은 backend_not_found와 함께 404를 반환하며, 업스트림 실패는 backend_authentication_failed, backend_discovery_timeout, backend_discovery_network_error, backend_discovery_parse_error, backend_discovery_http_error, backend_discovery_response_too_large로 기계가 읽을 수 있게 구분되면서 액세스 토큰, 리프레시 토큰, 토큰 스토어 경로, 인증 헤더를 실어 보내지 않습니다.
  • 유지 관리되는 쿠버네티스 배포 프로파일을 deploy/ 아래에 추가했습니다 (#13). staging 오버레이가 딸린 Kustomize base와 development, staging, canary, production 값을 갖춘 Helm 차트가 인증된 설정, 강화된 파드, TLS 인그레스, 오토스케일링, 중단 예산, 네트워크 격리를 다룹니다. monitoring/prometheus/에는 Prometheus Operator가 없는 클러스터를 위한 독립형 Prometheus 번들이 10 GiB 퍼시스턴트 볼륨 클레임과 15일 보존 설정으로 들어 있고, 오퍼레이터 CRD가 설치된 환경에서는 Helm 차트가 ServiceMonitor를 렌더링할 수 있습니다.

변경됨

  • 등록된 백엔드와 임시 후보의 라이브 디스커버리 경로를 하나로 합쳤습니다 (#1253, PR #1254). 후보 디스커버리와 등록 디스커버리가 프로바이더·인증 분기를 각자 중복해서 갖고 있었는데, 이제 검증된 백엔드 스냅샷을 기준으로 하나의 공용 함수가 양쪽을 처리하고 토큰 스토어 접근만 등록된 백엔드 전용으로 남겼습니다.

수정됨

  • 임시 OAuth 후보가 실제 확인 없이 healthy로 보고하던 것을 고쳤습니다 (#1252, PR #1254). 프로브 헬스가 등록된 백엔드의 액티브 체크 우회 경로를 물려받아 네트워크 I/O 없이 healthy를 답했는데, 등록된 OAuth 백엔드가 의존하는 라이프사이클 관리 인증 전략은 등록 전에는 존재하지 않습니다. 이제 임시 OAuth 헬스는 health.status: "unknown", credential_status: "unknown", backend_probe_health_unsupported를 반환합니다.
  • 헬스 전용 프로브에서 인증 오류를 안정적인 코드로 돌려줍니다 (#1252, PR #1254). 헬스 전용 프로브의 401 또는 403이 일반 헬스 오류로 뭉개져서 키가 틀린 경우와 엔드포인트에 닿지 못한 경우를 구분할 수 없었습니다. 이제 업스트림 본문을 노출하지 않고 backend_authentication_failed로 매핑합니다.
  • 설정 기반 후보 카탈로그에 상한을 적용했습니다 (#1252, PR #1254). 설정 기반 카탈로그가 models만 읽고 백엔드별 상한을 적용하지 않아 프로브 응답이 max_models_per_backend를 넘길 수 있었습니다. 이제 models를 먼저 쓰고 model_configs를 확장 폴백으로 쓰며, 설정 기반과 큐레이션 카탈로그 모두 백엔드별 상한으로 잘라냅니다. 요청이 지정한 설정 기반 Anthropic 모델 id는 기존 info 레벨 카탈로그 로깅에 더 이상 실리지 않습니다.
  • 지원하지 않는 전송 방식과 잘못된 확장 모델 설정을 디스패치 전에 거부합니다 (#1252, PR #1254). 지원하지 않는 URL 스킴이 검증에서 걸리지 않고 하위 페치 코드까지 내려갔고, 프로브 전용 검증 경로는 일반 백엔드 검증이 거부하는 model_configs를 받아들였습니다. 이제 둘 다 타입이 있고 정제된 후보 오류로 실패합니다.

문서

  • 두 디스커버리 엔드포인트를 영문과 국문 Admin API 레퍼런스 및 백엔드 설정 가이드에 문서화했습니다. 오류 코드, 비변경 경계, 어느 쪽을 언제 쓰는지를 함께 실었습니다.
  • 메트릭 가이드에 Helm ServiceMonitor 객체와 독립형 Prometheus 번들을 추가했습니다. 스토리지 용량 산정, 클러스터 범위 디스커버리 RBAC, NetworkPolicy 주의 사항을 함께 다룹니다.
  • 새 프로파일에 맞춰 롤링, 블루-그린, 카나리, 설정 롤아웃 절차를 배포 문서에 추가했습니다.

CI

  • 모든 Helm 프로파일과 Kustomize 오버레이를 렌더링해 kubeconform으로 매니페스트를 검증하고, 렌더링된 라우터 설정마다 continuum-router config validate를 돌리는 Deployment Asset Validation 잡을 추가했습니다 (#13). deploy/, monitoring/prometheus/, 검증 스크립트, 해당 워크플로가 바뀔 때만 실행되며 scripts/local-ci.sh가 같은 스크립트를 로컬에서 돌립니다.
  • 릴리스 컨테이너 이미지에 게이트를 걸었습니다 (#13). Trivy가 Debian amd64와 arm64 이미지를 스캔해 CRITICAL 또는 HIGH가 나오면 릴리스를 실패시키고, buildx가 SBOM과 provenance: mode=max를 생성하며, 멀티 아키텍처 매니페스트 다이제스트를 검증한 뒤 증명서를 발급합니다. 워크플로 수준의 contents: write와 packages: write는 잡별 부여로 좁혔습니다.
  • 보호된 환경 뒤에서 도는 릴리스-투-스테이징 워크플로 deploy-staging.yml을 추가했습니다.

v1.17.0 - 2026-08-03

허브가 관장하는 가드레일 정책을 처음으로 실행하고, 허브 코스트 센터 V2 월간 제한을 로컬에서 강제 적용하며, 파일 해석과 허브 엔벨로프 디코드에 상한을 두고, 키별·클라이언트별 레이트 리밋을 실제 트래픽에서 동작시키며, Files API 전송을 버퍼링 대신 스트리밍으로 바꿉니다.

보안

  • Responses의 파일·세션 권한 판정을 클라이언트가 보낸 X-User-ID 헤더가 아니라 인증된 API 키 주체에 묶었습니다 (#1244). 파일 해석, 저장 세션 생성, previous_response_id 재생, GET, DELETE가 모두 AuthContext::user_id()에서 요청자를 도출하고, X-User-ID는 업스트림으로 넘기기 전에 걸러내며, 위조 가능하던 캐시·세션 폴백 네임스페이스는 제거했습니다. Responses와 Anthropic 양쪽의 소유 파일·세션 검사는 인증된 요청자가 없으면 거부 쪽으로 닫히고, Responses 파일 해석의 AccessDenied는 원본 file_id를 전달하는 대신 403을 반환합니다. X-User-ID로 Responses 상태를 분리하던 배포는 키 단위 분리로 옮겨야 합니다. 영문·국문 가이드에 마이그레이션 절차를 실었습니다.
  • 클라이언트가 보는 오류 본문에서 백엔드 URL을 비롯한 reqwest 유래 전송 정보를 가렸습니다 (#1235). 응답 경계에서 reqwest::Error::without_url()을 사용하는 공용 client_safe_reqwest_error 헬퍼를 도입해, 그동안 원본 표시 문자열을 그대로 끼워 넣던 Responses, Messages, count_tokens, 이미지 편집, 멀티모달 임베딩, responses-only 패스스루 응답 생성 경로를 모두 덮었습니다. responses-only 패스스루 매퍼도 Responses 매퍼와 같은 정책을 따라 고정된 백엔드 연결 실패 메시지를 반환합니다. HTTP 상태, 오류 타입, 오류 코드, 서킷 브레이커 집계, 운영자 로그의 정보량은 그대로입니다.
  • 1161이 범위 밖으로 남겨 둔 신뢰할 수 없는 로그 값들을 이스케이프하고 길이를 제한했습니다 (#1179). POST /v1/files, 이미지 편집·변형 파서, 관리자 Files API 업로드의 클라이언트 제공 멀티파트 필드 이름은 포맷 문자열 보간으로 message에 닿았는데, EscapeGuard는 \n과 \r을 통과시키고 길이 제한도 없었습니다. #1161이 손대지 않은 Anthropic 하위 트리의 백엔드 제공 값들, 특히 /v1/responses 파싱 실패 본문 앞부분과 스트리밍 중 업스트림 오류 본문 전체에도 같은 처리를 적용했습니다.

  • 사고 비활성화 거부 응답 본문에 그대로 실리던 클라이언트 값들에 길이 상한을 두었습니다 (#1183). disabled_thinking_effort_error_message는 원본 effort를 두 번, 원본 모델 id를 한 번 보간했고 세 개 인그레스 모두 이 메시지를 400 본문으로 만듭니다. 두 게이트 어느 쪽도 입력을 먼저 제한하지 않습니다. effort_exceeds_high는 다듬고 소문자로 바꾼 사본으로 비교하면서 보간은 원본으로 하고, claude_family_version은 앞쪽 토큰만 본다는 점 때문입니다. 이제 상한이 공용 빌더 안에 있어 모든 인그레스가 이를 물려받습니다.
  • Responses의 file_id 해석에 요청 단위 상한을 두었습니다 (#1180, #1174 해결). #1169가 Anthropic 경로에서 고친 것과 같은 결함 종류입니다. 이 해석기는 파일별 10 MiB MAX_INJECTION_SIZE 하나만 강제했기 때문에 개별적으로는 작은 참조를 몇 개든 집계 상한 없이 해석했고, 참조마다 자기 base64 사본을 디스패치 시점까지 메모리에 붙들고 있었습니다. 이제 첫 메타데이터 조회 전에 개수 사전 검사가 참조 100개를 넘는 요청을 거부하고, 집계 바이트 예산을 콘텐츠를 읽기 전에 metadata.bytes에서 참조마다 계산하며(따라서 같은 참조를 반복하면 반복만큼 계산합니다), 해석 단계 전체에 다른 두 경로가 이미 갖고 있던 30초 타임아웃을 겁니다. 세 경로가 실제로 공유하는 상한들은 새 src/core/files/limits.rs로 모았고, 중복 선언 두 벌과 값을 맞춰 달라고 부탁하던 주석이 사라졌습니다.
  • chat-completions의 로컬 file_id 해석에 Anthropic Messages 및 Responses와 같은 원본 32 MiB 집계 바이트 예산을 적용했습니다 (#1240). 이제 chat 경로는 내용을 읽기 전에 metadata.bytes에서 각 참조를 계산하며, 같은 파일을 반복 참조해도 참조마다 계산하고, 요청 하나가 예산을 넘으면 부분 해석된 페이로드를 전달하지 않고 400 invalid_request_error를 반환합니다. 또한 chat-completions는 PDF에도 이미지와 같은 10 MiB 파일별 인라인 상한을 적용합니다. 더 큰 PDF는 files.max_file_size 아래에서 업로드할 수는 있지만 chat-completions JSON 본문 안으로 확장되지는 않습니다.
  • 지금까지 크기 상한이 없던 허브 정책 엔벨로프 디코드에 상한을 두었습니다(#1193). sync_policy는 이제 설정 동기화와 같은 상한 디코더를 거쳐 약 152.6 MiB의 유도된 상한(MAX_POLICY_SYNC_RESPONSE_BYTES) 아래에서 디코드합니다. 이 상한은 허브 자체의 배포 예산을 근거로 항목별로 기록해 유도했습니다(허브가 조직 단위로 제한하지 않는 유일한 멤버인 키 테이블에 대한 10만 항목 허용치, 4 MiB request_params 예산을 실은 티어 1,024개, 256 KiB 가드레일 캐노니컬 예산, 비용 센터 할당 5,000건과 상한이 걸린 센터 1,024개, 허브가 전혀 제한하지 않는 멤버들에 대한 명시적 허용치). 모든 상한을 동시에 채운 정상 테넌트도 디코드되도록 크기를 잡았고, 새로 추가한 상한 규모 왕복 테스트가 이를 고정합니다. 상한 초과 거부는 조용한 파싱 계열 실패가 아니라 요란하고 타입이 있는 신호입니다. 상한과 관측 크기를 담은 별도의 HubError::ResponseTooLarge 오류, 두 값을 명시하는 error! 로그(본문 내용은 절대 싣지 않습니다), 그리고 "엔벨로프가 너무 큼"을 일반 동기화 실패와 구분하는 새 control_plane_policy_sync_failure_total{reason="envelope_too_large"|"other"} 카운터가 함께 동작합니다. 거부가 지속되면 재시작한 라우터가 영구히 fail-open 상태로 남는 반면 일반 실패는 저절로 회복되기 때문입니다. 두 reason 시리즈는 등록 시점에 0으로 생성되어 아직 아무것도 거부하지 않은 라우터에서도 플릿 알림이 시리즈를 볼 수 있고, 거부 횟수와 마지막 발생 시각은 GET /admin/control-plane/status의 policy_sync.oversize_refusals / last_oversize_refusal_ms 필드로 노출됩니다. last_sync_ms가 null인데 거부 횟수가 0이 아니면 콜드 스타트 fail-open 상태라는 신호입니다. 정책 스토어는 거부를 겪어도 last-known-good 스냅샷을 유지하며 강제 적용을 절대 넓히지 않습니다. 콜드 스타트에서의 거부는 스토어를 정확히 fail-open 상태로 남겨 둡니다. 웹소켓 정책 스트림은 이제 메시지와 프레임 상한을 같은 상수로 설정합니다. 기존의 tungstenite 64 MiB 기본값은 폴링 상한보다 작아서 한쪽 경로에서는 받아들이는 정상적인 대형 엔벨로프를 다른 쪽에서 거부할 수 있었습니다. 남아 있던 무제한 허브 응답 디코드(enroll, push_usage, push_probes)에도 각각 유도된 상한과 같은 타입 거부를 적용했습니다. usage ack 상한은 기본값 500건이 아니라 이 코드베이스가 강제하는 1만 건 클램프(MAX_USAGE_BATCH_MAX_RECORDS)에서 유도했고, 읽을 수 없는 초과 크기 usage ack은 이미 처리된 배치에 대해 종결로 취급해 무한 재시도를 막았습니다. heartbeat, report_config_apply_result, push_batch_status는 응답 본문을 디코드하지 않으므로 변경이 없습니다.

추가됨

  • 허브 코스트 센터 월간 제한을 교정된 V2 수렴 계약에 따라 로컬에서 강제 적용합니다 (#1162; 허브 이슈 #688, #735, #736, 에픽 #646). 벤더링된 continuum-protocol 크레이트가 허브 rev 98bb8ac에서 코스트 센터 표면 전체를 동기화하며, 허브가 커밋한 픽스처를 바이트 단위로 복사해 새 와이어 테스트로 고정했습니다. 그래서 V1 멤버(cost_centers, cost_center_policy_digest)는 파싱 호환을 유지하되 강제 적용되지 않고 확인 응답도 하지 않으며, V2 멤버(cost_centers_v2)가 강제 적용되는 계약이 됩니다. 이 계약은 라우터별 안정 할당 명세(허브가 미리 해석한 key_id -> cost_center_id 소유권, 라우터 폴백, 프로바이더/모델 및 안정 백엔드 폴백, 센터별 상한)와 그것이 참조하는 영속 동적 예산 스냅샷(현재까지 소비된 카운터, 허브가 작성한 차원별 판정, 라우터별 included_router_usage_seq 워터마크)으로 구성됩니다. 명세는 하나의 원자적 적용 단위로서 크기 상한, 중복, 참조, 라우터 범위 다이제스트, 결정적 커서, 스냅샷 최신성, 워터마크 가능성을 검증받고, 결함이 있으면 타입 코드(invalid_cost_center_policy, digest_mismatch, stale_budget_snapshot, invalid_usage_watermark)와 함께 전체가 거부되며 last-known-good 단위가 계속 강제 적용됩니다. 하트비트 상태의 적용/거부 양쪽에 안정 커서/다이제스트와 동적 예산 커서가 정확히 확인 응답됩니다. 강제 적용은 로컬이며 허브는 요청 경로에 관여하지 않습니다. 승인 게이트는 허브가 계측할 사용량 레코드를 만들 수 있는 요청 경로에서만 작동합니다. 키와 라우터 계층은 승인 미들웨어에서 거부와 원자적 예약(요청 1건 + 결정적 토큰 허용량: 상한이 있는 바이트 기반 프롬프트 추정치와, 티어 request_params 상한 및 고정 상한으로 잘라낸 요청의 출력 최대값)이 이루어지고, 예약은 응답 본문에 실려 마지막 스트리밍 프레임까지 유지되며 다른 모든 승인의 사용량에 합산되므로 동시의 한계 직전 요청들이 모두 통과할 수 없되, 요청 하나가 자기 추정치만으로 거부되는 일은 없습니다(거부는 상한 도달 시점에만). 하위 계층(안정 백엔드, 그다음 프로바이더/모델, 허브 자신의 우선순위)은 유효 재작성 반영 대상의 모든 후보 서빙 백엔드가 각자 상한을 넘긴 코스트 센터로 귀결될 때 승인 시점에 거부됩니다. 요청이 서빙될 수 있는 신원이 전부 소진된 상태이므로 디스패치 전 결정이 실제로 서빙되고 계측되는 신원과 어긋날 수 없기 때문입니다. 후보들이 하나의 동일한 코스트 센터로 귀결될 필요는 없습니다. 그 조건은 충분하지만 필요하지는 않았고, 소진된 코스트 센터 두 곳에 백엔드가 걸쳐 있는 모델을 지목하는 것만으로 상한을 넘겨 계속 서빙할 수 있는 구멍을 남겼습니다. 모든 계층의 계측은 사용량 이음새에서 정확히 한 번 정산됩니다. 단일 사용량 루프 소비자가 전송 버퍼에 들어가는 레코드마다 영속 단조 router_usage_seq를 찍고, 최종 레코드 자체의 신원(허브가 수집 시 할당에 쓰는 것과 동일한 입력)으로 계산한 코스트 센터 아래에 계측 사용량을 원장에 기록합니다. 더 새로운 스냅샷을 수락하면 허브가 포함한 워터마크 이하의 원장 항목만 정확히 폐기하고(반영되지 않은 사용량은 절대 리셋되지 않고 반영된 사용량은 절대 이중 계산되지 않음), 새 런타임이 발급한 적 없는 시퀀스를 가리키는 허브 워터마크(자격 증명은 남고 사이드카만 잃은 경우)는 영구 거부에 갇히는 대신 시퀀스 공간의 내구성 있는 전방 점프로 복구하며, 예산 블록 없이 도착한 명세는 문서화된 예산 불가용 상태(허브 #750)로서 보유 카운터와 허브 작성 판정을 새 상한 아래에 적용하는 대신 폐기하고, 전송 버퍼에서 영구히 버려진 레코드, 분할 불가한 초과 크기 배치, 허브가 레코드 단위로 거부한 레코드는 원장 항목도 함께 은퇴하며, 시퀀스 카운터, last-known-good 단위, 적용된 워터마크, 미반영 델타는 control_plane.state_file 옆의 소유자 전용 사이드카(<state_file>.cost-center.json, 0600, 원자적 rename)에 영속화되어 재시작 시 시퀀스 공간을 처음부터 다시 시작하지 않고 이어갑니다. 판매 지출은 허브 전권입니다. 라우터는 판매 지출을 로컬에서 계산하거나 차감하거나 추정하지 않으며 sell_spend_status 판정이 유일한 입력입니다(exhausted는 거부, 더 새로운 허브의 알 수 없는 판정은 절대 거부하지 않음). cost_center_limits_v2는 계약 전체가 살아 있을 때만(사이드카 연결, 신원 결속, 시퀀싱과 강제 적용 배선 완료) 광고되고 레거시 cost_center_limits_v1 문자열은 광고하지 않으며, 문서화된 초과분은 없다고 주장하는 대신 네 개의 명시된 항(스냅샷 주기 곱하기 플릿 크기, 예약 추정 오차, 동시 승인 요청당 허용량 하나, 응답 종료와 레코드의 버퍼 진입 사이의 프로세스 내 전달)으로 상한이 정해집니다. 영문 및 국문 컨트롤 플레인 아키텍처 문서에 반영했습니다.
  • 허브가 관장하는 가드레일 정책을 추가했습니다. Continuum Hub가 플릿 전체의 콘텐츠 안전 집행을 관장하면서도 실행 권한은 각 라우터가 그대로 가집니다 (#1094, #1105, #1106, #1107, #1108, #1109, #1110, #1111, #1112, #1122 종료). 허브가 관장하는 가드레일 정책을 실제로 실행하는 첫 릴리스는 v1.17.0이며, 허브 호환성 매트릭스(lablup/continuum-hub#603)가 고정해야 할 버전이 이것입니다. 그보다 오래된 라우터는 이 필드를 무시하고 guardrail_policy_v1 기능을 광고하지 않으므로, 영원히 pending으로 남는 대신 unsupported로 판단됩니다. 거버넌스는 기존 정책 엔벨로프에 control_plane.policy.enabled 아래로 실려 오며, 닫혀 있고 타입이 정해진 정수 전용 표면입니다. mode, category_thresholds(부호 없는 마이크로유닛으로 1000000이 정확히 1.0), stages, inspect_reasoning, block_behavior로 구성되고, 해석된 모델 id를 키로 하는 라우트 재정의는 최대 64개입니다. 라우트 범위에서 다루는 것은 mode와 category_thresholds뿐입니다. 라우트 범위의 stages, inspect_reasoning, block_behavior는 로컬 라우트 스키마에 담을 자리가 없고, 전역 설정으로 접어 넣으면 라우트 하나에 대한 결정이 모든 라우트로 번지므로, 해당 라우트와 항목 이름을 담은 경고와 함께 적용하지 않습니다. 배포 배선은 허브에서 오지 않습니다. providers 목록 자체, 프로바이더의 endpoint / backend: / api_key_env, 프로바이더별 및 전역 timeout_ms와 on_error, 모든 streaming_* 항목, bypass_api_keys, audit 블록, 라우트별 enabled과 providers 부분집합, 전역 및 라우트별 allow / deny 매치 리스트는 전부 로컬이 주권을 가집니다. 정책 타입에는 자유 형식 페이로드 필드가 어디에도 없으므로, 프롬프트, 완성 텍스트, 매치된 구간, 마스킹된 값은 어느 방향으로도 실릴 수 없습니다. 합성은 항목별로 더 엄격한 쪽이 이깁니다. 어느 쪽에서 오든 enforce가 monitor를 이기고, 카테고리별로 더 낮은 임계값 하한이 이기며 로컬에 없는 카테고리는 추가되고, stages는 모든 프로바이더 행에 합집합으로 적용되고, inspect_reasoning은 논리 OR이며, 선언된 block_behavior는 엄격도 순서가 아니라 거버넌스 선택으로서 이깁니다. 합성은 두 입력 중 어느 쪽이 바뀌어도 다시 계산되고 원자적으로 적용되므로, 로컬 편집이 허브 계층을 지우는 일도, 허브 갱신이 로컬 편집을 묻는 일도 없으며, 요청은 항상 하나의 완전한 정책 세대에 대해 평가됩니다. 전달은 둘이 아니라 세 상태입니다. 엔벨로프 멤버가 없으면 마지막 정책을 유지하고, 정책이 선언되면 교체하며, 명시적 해제는 라우터를 로컬 전용 설정으로 되돌리고 해제된 정책의 정규 digest로 확인 응답을 보냅니다. "없음"으로 보고하지 않기 때문에 해제된 테넌트가 pending이나 stale에 머무르지 않고 수렴합니다. guardrails.enabled: false로 시작했거나 guardrails: 블록이 아예 없는 라우터도, 집행 가능한 정책이 도착하면 시작 시점과 동일한 팩토리 경로로 로컬 프로바이더 배선만 사용해 런타임에 가드레일 서비스를 생성합니다. 이 배포가 실행할 수 없는 집행을 요구하는 정책, 즉 게이팅할 수단이 전혀 없거나 실행 가능한 프로바이더가 담당할 수 없는 스테이지를 요구하는 정책은 타입 코드 guardrails_unavailable과 함께 전체가 거부되며 결코 active로 보고되지 않습니다. 덕분에 Fleet 화면이 실제로 일어나지 않는 집행을 보여주는 일이 없습니다. 검증에 실패한 본문은 invalid_guardrail_policy로 거부되므로, "라우터가 정책보다 오래되었다"는 상황이 공유 코드인 digest_mismatch와 섞이지 않습니다. 거부는 허브 개입 없이 해소됩니다. 와이어 계층이 정책을 적용된 상태로 유지하고 로컬 설정이 바뀔 때마다 재평가하므로, 빠진 프로바이더 행이나 deny 규칙을 추가하면 같은 정책이 다음 리로드에서 적용됩니다. guardrail_policy_v1은 살아 있는 가드레일 정책 재조정기가 붙은 뒤에야, 그리고 모든 상태 기록 경로가 공유하는 단일 기능 목록에서만 광고됩니다. 이 기능은 빌드 플래그가 아니라 실행에 대한 약속이기 때문입니다. 가드레일 트랙과 티어 트랙은 양방향으로 독립적입니다. 영문 및 국문 가드레일 가이드(거버넌스 범위, 로컬 주권, 실제 예시가 딸린 합성 표, 3상태 전달, 거부 코드, 허브 상태 도출), 컨트롤 플레인 아키텍처 문서, 설정 어시스턴트 스킬 문서(스키마 1.30.0에서 1.31.0)에 반영했습니다.
  • 요청을 실제로 처리한 백엔드의 안정적인 설정 식별자를 컨트롤 플레인 사용량 레코드와 하트비트 인벤토리에 보고합니다. 이제 백엔드를 코스트 센터로 배정할 수 있습니다 (#1153, 허브 lablup/continuum-hub#686). 새로 추가된 선택 항목 backends[].backend_id는 전적으로 운영자 소유이며 무엇으로도 유도하지 않습니다. name은 운영자가 핫 리로드 때 언제든 바꿀 수 있는 표시 레이블이고 (provider, model) 쌍은 백엔드가 아닙니다. 여러 백엔드가 같은 쌍을 제공할 수 있기 때문입니다. 따라서 유도한 값을 쓰면 같은 쌍을 공유하는 서로 다른 두 비용 주체가 조용히 합쳐지고, 이름을 바꾸는 순간 하나의 비용 주체가 조용히 갈라지며, 둘 다 사용량이 수집된 뒤에는 눈에 띄지 않습니다. 식별자가 없는 백엔드는 아무것도 보고하지 않고 사용량은 기존과 똑같이 provider/model 단위로 귀속되는데, 이는 오류가 아니라 정상 상태입니다. 값은 구분자 안전한 ASCII 집합 [A-Za-z0-9._-]에서 1~128자이며, fallback.fallback_chains가 이미 문서화한 것과 같은 문자 집합입니다. 이 값은 허브가 바이트 단위로 대조하고 정규화하지 않는 불투명한 키로 전달되므로, 공백, /, :, ,, 제어 문자, 양방향 재정의를 포함한 비 ASCII는 어느 요청이 어느 코스트 센터에 청구되는지를 바꾸도록 두지 않고 원천에서 거부합니다. 고유성은 한 라우터 안에서만 요구합니다. 허브가 (authenticated_router_id, backend_id)로 소속을 구분하므로 두 라우터가 같은 로컬 표기를 재사용해도 됩니다. 이 검사는 Validate 구현뿐 아니라 infrastructure::config::validator의 공용 수용 지점에서 실행됩니다. 실제 로드 경로는 섹션을 명시적으로 검증할 뿐 Config::validate()를 호출하지 않기 때문입니다. 후보 설정을 통째로 거부하는 것이 핫 리로드를 원자적으로 유지하는 방법이기도 해서, 실행 중인 라우터는 식별자 맵이 일부만 적용된 상태가 아니라 마지막 정상 설정에 그대로 머무릅니다. /admin/backends 핸들러 계열은 propagate_config_change를 통해 핫 리로드 watch 채널로 곧장 발행하므로 이 수용 지점에 도달하지 않습니다. 그래서 자기 경계에서 같은 검사를 수행하고, POST /admin/config/validate도 같은 규칙을 확인하여 저장 전 사전 점검이 통과라고 알린 값을 뒤이은 쓰기가 거부하는 일이 없게 했습니다. 식별자는 각 서빙 지점에서, 실제 선택이 사용한 설정 뷰를 기준으로 확정해 사용량 이벤트에 실어 보내며, 레코드를 푸시할 때 백엔드 이름으로 조회하지 않습니다. 그 사이에 핫 리로드가 백엔드 이름을 바꾸면 나중의 조회는 아무것도 찾지 못하고 이미 서빙된 사용량의 귀속이 사라지기 때문입니다. 폴백 홉은 TTFT 기준 시각을 덮어쓰는 것과 똑같이 식별자도 덮어쓰므로 폴백된 요청은 실제로 응답을 만든 백엔드에 청구되고, 스트림 중간 폴백 릴레이는 요청이 라우팅된 스냅샷을 기준으로 각 홉을 확정하며, 프로바이더 배치는 제출 시점에 확정한 식별자를 들고 있어 몇 시간 뒤 완료되어도 그 배치를 실행한 백엔드를 지목하고, /v1/responses 스트리밍은 디스패치 지점이 없으므로 컨텍스트 생성 시점에 확정하며, 로컬 캐시 적중은 백엔드로 나간 적이 없으므로 아무것도 보고하지 않습니다. 식별자가 없을 때 표시 이름으로 대신 채우는 일은 없습니다. 와이어에서 UsageRecord.backend_id는 업스트림 필드를 그대로 옮긴 것이고, BackendInfo.backend_id는 업스트림 재동기화보다 앞서 추가한 것입니다. 허브는 자체 백엔드 인벤토리를 갖고 있지 않아서, 운영자가 배정 가능한 식별자를 확인할 수 있는 경로는 하트비트뿐이기 때문입니다. 둘 다 serde 기본값이 있는 선택 필드이므로 식별자를 하나도 설정하지 않은 라우터는 이전 와이어와 바이트 단위로 동일하게 직렬화되고 PROTOCOL_VERSION은 0 그대로입니다. stable_backend_identity_v1은 조건 없이 광고합니다. 가드레일 정책과 달리 나중에 붙을 수도, 안 붙을 수도 있는 별도 실행기가 없고 설정 검증, 인벤토리, 사용량 탭이 하나의 단위로 컴파일되고 배선되기 때문입니다. 이 기능 문자열은 데이터가 아니라 구현을 서술하므로, 식별자를 하나도 설정하지 않은 라우터도 광고하고 아무것도 보고하지 않는데, 이것이 허브가 "아예 보고할 수 없는 라우터"와 구분해야 하는 상태입니다. 영문 및 국문 설정/아키텍처 가이드, config.yaml.example, 설정 어시스턴트 스킬 문서(스키마 1.32.0에서 1.33.0)에 반영했습니다.
  • 하트비트 인벤토리에 메타데이터 전용 가드레일 카운터를 보고합니다. 허브가 라우터마다 스크래핑하지 않고도 플릿 전체의 가드레일 활동을 볼 수 있습니다 (#1120). RouterInventory.guardrails에는 checks_total, blocks_total, blocks_by_category, (stage, mode, result) 기준 verdicts, (strategy, outcome) 기준 stream_buffer_cap_trips, 그리고 SupplySummary와 동일한 프로세스 시작 값에서 찍는 counters_since_ms가 실립니다. 두 카운터 계열이 함께 리셋되므로 허브는 재시작을 한 번으로 읽습니다. 카운터는 레지스트리가 아니라 Prometheus 지표를 내보내는 것과 같은 지점에 기록되는 인프로세스 트래커에서 옵니다. metrics는 선택 사항이고, 이를 빼고 빌드한 배포에서 0을 보고하면 실제로 차단 중인 가드레일이 놀고 있다고 허브에 알리는 셈이 되기 때문입니다. 모든 레이블 공간은 닫혀 있으므로 서드파티 모더레이션 프로바이더가 허브의 저장 키 공간을 무한정 늘리거나 프로바이더가 작성한 텍스트를 실어 보낼 수 없습니다. 알려진 어휘 밖의 카테고리는 other로 접히고, 인식되지 않은 stage, mode, result, strategy, outcome은 버려지는 대신 unknown 슬롯으로 접힙니다. 이 블록은 가드레일 서비스가 존재하기만 하면 첫 검사 전이라도 보고되고 라우터가 가드레일을 전혀 돌리지 않으면 통째로 생략되므로, 블록이 없으면 "가드레일이 꺼져 있음", 값이 0이면 "켜져 있고 조용함"입니다. 담기는 것은 개수와 한정된 id뿐이며 프롬프트 텍스트, 완성 텍스트, 매치된 구간, 매치된 규칙, 마스킹된 값은 들어가지 않습니다.
  • 요청당 메모리 예산이 프로세스 단위 상한을 실제로 함의하도록, 동시 처리 요청 수 상한을 선택적으로 추가했습니다 (#1173). 라우터가 집행하는 메모리 상한은 전부 요청 단위입니다. 10 MiB GLOBAL_BODY_LIMIT, 기본 512 MiB이며 메모리에 통째로 버퍼링되는 files.max_file_size, #1169와 #1174가 추가한 32 MiB 파일 해석 합계 예산이 그렇습니다. 최악의 상주 메모리는 이 값들과 동시에 떠 있는 요청 수의 곱인데, 두 번째 항을 묶는 것이 아무것도 없었습니다. 속도 제한은 그 역할을 하지 못합니다. 속도 제한이 묶는 것은 단위 시간당 도착이지 상주가 아니며, Little의 법칙에 따르면 정상 상태의 동시 요청 수는 도착 속도 곱하기 평균 지속 시간입니다. 따라서 평균 30초짜리 완성에 per_client: 10(초당 10건)이면 한 클라이언트에서만 약 300건이 정책 안에서 동시에 떠 있고, 파일 해석 상한까지 채우면 약 12.6 GiB입니다. 게다가 기본 설정에는 속도 제한 자체가 없으므로, 그 경우 상한은 클라이언트가 열 수 있는 만큼입니다. server.max_concurrent_requests의 기본값은 설정하지 않음이라 기존 배포가 업그레이드만으로 요청을 흘려보내기 시작하는 일은 없습니다. 값을 설정하면 라우터는 그만큼만 동시에 받아들이고 나머지는 503과 Retry-After, 상한을 명시한 JSON 본문으로 답합니다. 대기열에 넣지 않고 흘려보내는 이유는, 대기열이 메모리 부족 종료를 무한정 늘어나는 지연으로 바꿀 뿐 대기 중인 것들의 메모리는 그대로 붙잡고 있기 때문입니다. 구현은 tower의 ConcurrencyLimit / LoadShed가 아니라 try_acquire_owned로 획득하는 tokio::sync::Semaphore입니다. 전자는 기본이 대기열이고, 배경 작업을 가진 Buffer가 필요하며, 거절 본문이나 재시도 힌트를 제어할 수 없고, iOS에 부적합한 의존성을 일부러 배제한 embed 기능 그래프에 새 tower 기능을 끌어들였을 것입니다. 이번 구현은 의존성을 전혀 추가하지 않습니다. 퍼밋은 응답 본문에 실려 다니므로 스트리밍 완성은 마지막 프레임까지 퍼밋을 쥐고 있습니다. 즉 짧은 비스트리밍 트래픽에 맞춰 잡은 상한은 스트리밍 트래픽을 굶기므로, 가장 긴 요청을 기준으로 잡아야 합니다. 이 레이어는 애플리케이션 전체에 장착되지만 CORS와 레이트 리미터보다는 안쪽입니다. 그래서 흘려보낸 503을 브라우저가 읽을 수 있고, 어차피 리미터가 거절할 요청이 퍼밋을 차지하지 않습니다. 요청당 항목이 가장 큰 /v1/files 그룹은 별도로 중첩되어 있지만 같은 상한의 적용을 받으며, /health, /healthz, 설정된 metrics.path는 면제됩니다. 활성 상태 프로브를 차단하는 것은 정상적으로 성능을 낮추고 있는 레플리카를 과부하 한복판에서 재시작시키는 길이기 때문입니다. SupplySummary::max_concurrency와 shed_requests는 이제 허브에 "상한이 없다"고 약속하는 대신 실제 상한과 실제 차단 수를 보고합니다. 상한을 설정하지 않았다면 0이 아니라 부재로 보고합니다. 0은 라우터가 아무 요청도 받지 않는다는 뜻이 되기 때문입니다. 요청을 균일하게 세는 대신 선언된 비용만큼 청구하는 메모리 인식 승인 제어도 검토했으나 보류했습니다. 본문을 읽기 전에 요청당 비용 추정치가 필요한데, 읽기 전에 얻을 수 있는 유일한 신호인 Content-Length는 청크 인코딩에서는 존재하지 않고 애초에 비용을 예측하지도 못합니다. #1168의 공격 벡터가 파일 참조 800개를 실은 57 KiB 본문이었습니다. 용량 계산, 설정 키가 딸린 요청당 항목, 속도 제한과의 관계는 영문과 한국어 배포 및 성능 가이드에 운영자용으로 문서화했으며, 기본 크기 업로드 하나만으로도 넘어서는 Kubernetes 예시의 512Mi 메모리 상한도 함께 올렸습니다.
  • 항목이 없어 아무 정보도 제공하지 못하던 모델 다섯 개의 메타데이터를 추가해, /v1/models가 표시 이름, 가격, 능력, 한도를 제공하도록 했습니다 (#1091, #1090 해결). Claude Opus 5(claude-opus-5, claude-opus-5-latest, 네이티브 Anthropic 백엔드의 내장 지원 모델 목록에도 함께 등록), Kimi K3, Qwen 3.7-Max, Qwen 3.8-Max Preview, Solar Open 2입니다. Opus 5는 claude_family_version()이 이미 id를 (5, 0)으로 파싱하고 있어 능력 게이팅 코드가 필요 없었습니다. 확장 사고, 적응형 사고, 샘플링 파라미터 처리, max effort, 대화 중 시스템 프롬프트, fast 모드 적격성을 새 단위 테스트로 고정해, 과거 substring 게이트가 Sonnet 5를 조용히 망가뜨린 것(#854)과 같은 회귀를 리팩터링이 다시 만들 수 없게 했습니다. Qwen 3.8-Max Preview는 프리뷰 접근이 크레딧 기반이고 토큰당 공시 요율이 없어 0 요율을 유지하며, Kimi K3와 Solar Open 2는 벤더가 공시하지 않아 knowledge_cutoff를 아예 넣지 않았습니다. solar-pro-2에는 자체 요약이 이미 함의하고 있던 도구 사용 능력과 일본어 지원을 더했습니다.
  • 설정할 수는 있고 문서에도 있었지만 실제로는 한 번도 돌지 않던 files.retention_days 시작 시 정리를 구현했습니다 (#1234, #1229 해결). 0이 아닌 값은 이제 메타데이터 사이드카의 created_at이 설정한 기간 이상 지난 저장 파일을 삭제하고, retention_days: 0은 여전히 파일을 영구 보존합니다. FileService::sweep_retained_files는 메타데이터를 먼저 지우고, 콘텐츠 객체가 없는 경우를 정상으로 취급하며, 콘텐츠 삭제가 실패하면 메타데이터를 복원하고, 정리마다 로그를 남기며 file_retention_deletes_total을 보고합니다. 읽기 경합 경계, 시작 시 정리 의미론, 설정 예시는 영문·국문 가이드, 관리자 설정 스키마, 설정 어시스턴트 스킬에 문서화했습니다.
  • BackendConfigBuilder가 BackendConfig를 완전히 덮도록 채웠습니다 (#1208). backend_id, internal, role, region, endpoint_type, auth, org_id, model_configs, retry_override, health_check, anthropic_auto_cache_control, anthropic_fast_mode, external_storage 세터를 추가했고, timeout과 max_retries는 명시적 세터와 같은 retry_override 필드를 변경하며, backend_id 형식은 BackendConfigBuilder::build에서, 유일성은 ConfigBuilder::build에서 검증합니다. rest 패턴을 쓰지 않는 필드 인벤토리 가드가 있어 BackendConfig에 새 필드를 추가하면서 빌더 쪽 결정을 빠뜨리면 컴파일이 실패합니다.

변경됨

  • Claude Opus 5에서 사고 비활성화 설정과 high를 넘는 effort의 조합을 라우터 경계에서 거부합니다 (#1103). Anthropic은 Opus 5에서 output_config.effort가 high 이하일 때만 thinking: {"type": "disabled"}를 받아들이고 xhigh나 max에서는 HTTP 400을 반환하는데, 그동안은 이 조합을 그대로 전달했기 때문에 클라이언트가 왕복을 다 마친 뒤에야 알 수 있었습니다. 해결 방식은 재작성이 아니라 즉시 실패입니다. effort를 깎거나 사고 설정을 떨어뜨리는 것은 둘 다 명시적인 클라이언트 지시를 조용히 고쳐 쓰는 일이고, Opus 5에서 사고를 끈 상태 자체도 문서화된 실패 양상 두 가지(도구 호출이 구조화된 tool_use 블록 대신 평문으로 나오는 것, <thinking> 태그가 응답에 새는 것)를 갖고 있어 어느 쪽도 중립적인 대안이 아니기 때문입니다. 400 본문은 제약과 두 가지 해결책을 함께 알립니다. 게이트는 모든 인그레스에서 돕니다. 공용 OpenAI-to-Anthropic 변환, Anthropic 형태 스트리밍의 두 진입점(TCP와 유닉스 소켓), 별칭 해석과 request_params 적용 이후의 타입 있는 /anthropic/v1/messages 핸들러, 그리고 페이로드를 독립적으로 만드는 Responses 변환기입니다.
  • 아무도 읽지 않던 Backend::max_concurrent_requests() 트레이트 훅과 모든 구현·테스트 더블을 제거했습니다 (#1224). 예전의 하드코딩된 프로바이더 상수를 허브에 보고하는 것은 집행되지 않는 허구를 게시하는 일이었습니다. 백엔드별 컨트롤 플레인 인벤토리는 None으로 두되, 주석은 #1173이 추가한 실제 라우터 단위 server.max_concurrent_requests 상한을 가리키도록 고쳤습니다.
  • Files API의 업로드와 다운로드를 파일 전체를 메모리에 올리는 대신 스트리밍으로 바꾸고, 다운로드 권한 판정을 콘텐츠를 읽기 전에 내리도록 했습니다 (#1145). FileStorageBackend의 기본 연산이 store_stream과 retrieve_stream이 되고 슬라이스 기반 store/retrieve는 기본 구현 래퍼로 남았습니다. 그 결과 전송 1건의 최대 상주 메모리가 파일 크기의 약 1.5배에서 64KB 청크 버퍼로 줄었습니다. LocalFileStorage는 업로드를 프로세스별·업로드별 임시 파일로 스트리밍한 뒤 모든 검사를 통과한 다음에만 fsync하고 제자리로 rename합니다. 따라서 읽는 쪽이 절반만 쓰인 .bin을 보는 일이 없고, 거부되거나 오류가 났거나 도중에 취소된 업로드는 부분 파일도 유출된 임시 파일도 남기지 않습니다. 콘텐츠 검증은 판정 결과를 그대로 유지한 채 점진적 방식으로 바뀌었습니다. 앞쪽 13바이트가 이미지·실행 파일 매직 바이트 검사를 모두 감당하고, 새로 추가한 할당 없는 Utf8Validator가 부분 멀티바이트 시퀀스를 청크 경계 너머로 이어받으므로, 네트워크 청크 두 개로 쪼개진 문자도 단일 버퍼일 때와 똑같이 판정되고 시퀀스 도중에 끝나는 본문은 여전히 거부됩니다. GET /v1/files/{id}/content와 GET /admin/files/{id}/content는 이제 파일을 열기 전에 메타데이터만으로 권한을 판정합니다. 파일 ID를 아는 비소유자가 전체 크기의 읽기와 할당을 유발해 놓고 결국 403을 받던 경로가 닫혔고, 두 엔드포인트 모두 응답 본문을 버퍼링 대신 스트리밍합니다. 413 동작, File too large: exceeds maximum N bytes 메시지 문구를 비롯해 겉으로 드러나는 Files API 동작은 그대로입니다. 이제 임시 파일이 전송 내내 존재하므로, 갑작스러운 종료(SIGKILL, OOM kill, 노드 축출, 정전)에서는 프로세스 안의 정리 코드가 돌지 못해 임시 파일이 남을 수 있고, 제품의 다른 어떤 것도 그것을 보지 못했습니다. 고아 스캐너는 .bin과 .meta.json만 찾고, 사용자 데이터 보존 정리는 메타데이터 사이드카의 created_at을 기준으로 삼아 임시 업로드 dotfile을 보지 않기 때문입니다. 그래서 라우터를 시작하면 24시간이 지난 임시 파일을 모두 회수합니다. 오래된 임시 파일은 복구 가능성이 남은 데이터가 아니라 명백한 쓰레기이므로 cleanup_orphans_on_startup에 걸지 않았고, FileService::detect_orphans가 그 개수를 보고하므로 다음 재시작 전에도 누수를 관측할 수 있습니다. 24시간 기준은 다른 프로세스가 아직 스트리밍 중인 업로드를 청소가 지우지 못하게 하는 장치입니다. 임시 파일의 mtime은 쓰기마다 갱신되므로 진행 중인 전송은 스스로 기준 밖으로 계속 밀려납니다. commit은 부모 디렉토리도 fsync하므로, 가리키는 바이트뿐 아니라 rename 자체가 내구성을 갖습니다.

함께, 업로드 라우트의 바깥쪽 본문 상한이 문서화된 1KB~5GB 범위로 클램프한 files.max_file_size에서 파생됩니다. 더 큰 값을 조용히 512MB로 깎던 하드코딩은 사라졌습니다. 범위를 벗어난 값은 실제 설정 로드 경로에서 시작 경고로 보고합니다(하드 에러가 아닌 이유는 max_file_size: 0이 지금도 받아들여지고, 치명적 검사로 만들면 잘 돌던 라우터가 뜨지 않기 때문입니다. 라우트를 지키는 것은 클램프입니다). 이와 관련해, 전송 계층 상한이 잘라낸 본문은 이제 400 invalid_request가 아니라 413 file_too_large로 응답합니다. 두 핸들러가 multipart 실패를 전부 잘못된 입력으로 분류했는데, 상한이 512MB 고정일 때는 사실상 닿지 않던 경로가 max_file_size에서 파생되면서 닿게 되었기 때문입니다. 그동안 어디에도 연결되지 않았던 file_uploads_total, file_upload_size_bytes, file_upload_duration_seconds, file_downloads_total, file_download_duration_seconds 메트릭이 등록되어 실제로 기록됩니다. 업로드 결과는 세션의 drop에서 기록하는데, 크기 초과 거부, purpose 파트 누락, 본문 도중 클라이언트 연결 끊김까지 볼 수 있는 유일한 지점이기 때문입니다. 다운로드는 시작했다는 이유가 아니라 본문이 오류 없이 끝까지 도달했을 때만 성공으로 셉니다. files.max_file_size가 cgroup v2 메모리 한도의 4분의 1을 넘으면 시작 시 경고가 뜹니다(한도가 없거나 max로 읽히거나 cgroup이 없는 플랫폼에서는 조용히 넘어갑니다).

수정됨

  • 자체 호스팅 분류기 template 네 개 중 두 개가 인식하지 못한 가드 모델 출력을 깨끗한 허용으로 읽던 문제를 수정했습니다 (#1203, PR #1182 후속). verdict_llama_guard와 verdict_yes_no는 모델 답변의 첫 토큰만 보고 자신의 단일 양성 토큰(unsafe, yes)이 아닌 모든 값에 GuardrailVerdict::Allow를 반환했습니다. 그래서 오류 문자열, 응답 거부, 채팅 템플릿 잔여물, 32토큰 상한에 잘린 생성, 예상 밖 언어로 나온 답이 안전한 것으로 서빙됐습니다. 게다가 조용했습니다. 파서가 평범한 Ok(Allow)를 내놓으니 ProviderOutcome::failure는 None으로 남고, mode: enforce에서도 Prometheus 오류 계열과 하트비트의 errors_total / fail_open_total / fail_closed_total, 감사 기록이 모두 검사를 마친 깨끗한 요청이라고 보고했습니다. 관대한 파서 쪽이 기본 경로이기도 했습니다. template 생략, 알 수 없는 template, granite_guardian, shieldgemma가 모두 verdict_yes_no로 귀결되므로, 오타 하나로 Llama Guard 배포가 unsafe를 yes로 볼 수 없는 파서를 쓰게 되고 모든 검사가 허용으로 통과했습니다. 이제 두 파서 모두 양쪽 극을 명시적으로 인식해 unsafe와 함께 safe를, yes와 함께 no를 읽고, 진짜로 인식할 수 없는 출력만 검사 오류로 처리합니다. Qwen3Guard와 custom_classifier가 이미 하던 방식이며, 이로써 네 template의 규칙이 일치합니다. 업그레이드 전에 확인할 동작 변경: 가드 모델이 설정된 template으로 읽을 수 없는 출력을 내는 배포는 더 이상 조용히 통과하지 않습니다. 프로바이더의 유효 on_error를 따르며, 기본값 fail_open에서는 트래픽은 그대로이고 guardrail_errors_total과 하트비트의 errors_total / fail_open_total에 새 오류가 잡히며, fail_closed에서는 트래픽이 거부됩니다. api_format: completion에서는 분류 대상 텍스트가 지시문 없이 프롬프트 전체가 되므로, completion으로 서빙되는 가드 모델이 그 텍스트를 이어 쓰기만 하는 구성이 이 변화를 가장 먼저 겪게 되고, 예전처럼 조용히 허용하는 대신 이제 눈에 띄게 실패합니다.

동작 중인 배포가 받아들이던 범위를 좁히지 않기 위해 판정 토큰 정규화를 폴백이 아니라 명시적으로 정했습니다. 앞뒤 공백, ASCII 대소문자, 토큰을 감싼 문장부호나 마크다운 강조(**unsafe**, Unsafe:, Yes,), 판정 앞에 붙은 닫힌 <think>...</think> 추론 블록을 모두 허용합니다. 공백과 대소문자는 이전에도 허용됐고, 나머지 두 가지는 모델이 unsafe라고 답했는데도 조용한 허용으로 읽히던 경우라 이제 있는 그대로 양성 판정으로 읽습니다. 닫히기 전에 잘린 추론 블록은 판정을 담고 있지 않으므로 검사 실패가 되며, 이 template들이 쓰는 32토큰 상한에서는 그쪽이 더 흔한 결과입니다. 첫 토큰 뒤쪽은 판정 단어를 찾아 훑지 않습니다. 가드 모델은 설명문 안에서 양쪽 표현을 모두 다시 쓰기 때문에("this is not safe") 훑기 시작하면 설명이나 거부 응답을 판정으로 읽게 되고, 그것이 바로 이 변경이 없애려는 실패이기 때문입니다. 실제 분류에서 나온 허용 두 가지는 그대로입니다. 보고된 위해 코드가 categories에서 모두 제외된 unsafe 판정과, category_thresholds 항목에 억제된 양성 판정이 그렇습니다.

설정 검증 쪽의 두 번째 동작 변경. self_hosted_classifier(및 classifier) 프로바이더의 알 수 없는 options.template 값을 이제 Config::validate가 거부합니다. 예전에는 시작 시 warn!만 남기고 granite_guardian으로 폴백했는데, 바로 그 폴백이 llama-guard나 shield_gemma 같은 오타를 완전한 조용한 우회로 바꿔놓았고, 실패 방향이 항상 허용 쪽이라 운영자가 알아챌 만한 잘못된 차단은 결코 나오지 않았습니다. 따라서 알 수 없는 template 값이 들어간 설정은 이제 로드되지 않고 continuum-router config validate가 이를 보고합니다. 값을 고치거나 키를 지우면 됩니다. template 생략은 여전히 유효하며 granite_guardian을 뜻합니다. 프로바이더 쪽 폴백은 검증을 거치지 않은 생성 경로를 위한 방어선으로 남겨두었습니다. 허용되는 값과 판정을 읽는 규칙은 영문·국문 가드레일 가이드에 문서화했습니다.

  • 라우터의 파일시스템 구조가 Files API와 두 개의 file_id 해결 경로에서 클라이언트가 보는 오류 본문으로 새어 나가던 문제를 막았습니다 (#1191). FileError::Storage(_)가 담고 있는 문구는 라우터가 직접 만든 것으로, src/services/files/storage.rs와 src/services/files/metadata.rs의 스무 곳 남짓에서 모두 path.display()를 넣은 format!으로 조립되는데, map_file_error가 그 문자열을 그대로 500 응답의 message로 넘겼습니다. 저장된 실체 파일을 읽을 수 없게 된 파일을 내려받으면 files.storage_path 아래의 절대 경로, 2단계 샤딩 하위 디렉터리, 저장된 .bin 파일 이름, 하부 std::io::Error가 그대로 응답에 실렸습니다. 업로드가 실패하면 임시 파일 이름이 실렸는데, temp_file_name은 이를 .{id}.{pid}.{sequence}.tmp로 만들기 때문에 라우터 프로세스 id(컨테이너에서는 1이며, 그 자체로 배포 형태를 알려줍니다)와 프로세스 시작 이후 시작된 업로드 개수까지 함께 노출됐습니다. 기본값인 files.auth.method: api_key에서 이 정보의 수신자는 files 스코프를 가진 유효한 키의 모든 소유자이며, enforce_ownership으로 자기 파일만 볼 수 있는 테넌트도 포함됩니다. 즉 다른 테넌트의 바이트는 읽지 못하는 호출자가 오류 본문만으로 배포의 스토리지 구조를 읽어낼 수 있었습니다. method: none이면 포트에 닿을 수 있는 누구에게나 열립니다. 치환은 생성 지점이 아니라 각 응답 경계에서 새로 추가한 FileError::client_message를 통해 한 번만 이뤄집니다. 경로야말로 스토리지 장애를 조사하는 운영자에게 필요한 정보이고, 로그가 그 자리이기 때문입니다. Display는 손대지 않았으므로 모든 error!, warn! 지점은 전체 문구를 예전 그대로 기록하며, 테스트는 capture_logs로 두 방향을 모두 확인합니다. 상태 코드와 type, code는 그대로 유지되므로(500 / server_error / storage_error 및 io_error) 이 필드로 분기하는 클라이언트는 아무 변화를 겪지 않고, 사람이 읽는 메시지만 고정 문구인 storage error: the file operation could not be completed로 바뀝니다. 클라이언트 요청이 원인인 변형들은 호출자가 보낸 file_id, purpose, 한도를 가리키므로 문구를 그대로 둡니다. 같은 문자열을 실어 나르던 클라이언트 대면 표면은 세 개였고 셋 다 처리했습니다. 공개 /v1/files* 핸들러, 챗 컴플리션 file_id 경로(해결 경고들이 FileResolutionError::PartialResolutionFailed로 합쳐져 404 본문에 실리므로, 각 경고를 만드는 지점에서 치환하고 전체 오류는 그 자리에서 로그로 남깁니다), 그리고 Anthropic /v1/messages 경로(FileResolverError::ReadError가 File service error: ... 형태로 502에 실립니다)입니다. Responses API는 손댈 필요가 없어 그대로 뒀습니다. try_resolve_files가 한도 위반이 아닌 모든 오류를 원본 요청으로 되돌리기 때문에 클라이언트에 닿는 것은 LimitExceeded와 Timeout뿐입니다. 변형별 주장을 가정하지 않고 실제로 확인하는 과정에서 찾은 인접 결함 두 가지도 같은 변경에서 고쳤습니다. 스토리지 백엔드가 올리는 FileError::NotFound는 파일 id가 아니라 {shard}/{id}.bin을 담고 있어서, 실체 파일이 지워진 파일을 내려받으면 404를 통해 샤딩 방식이 드러났습니다. 이제 FileService가 호출자가 보낸 id로 다시 이름 붙이며, 이는 더 안전하면서 메시지로서도 더 낫습니다. 그리고 관리자 Files API의 build_error_response는 만드는 모든 본문에 "type": "invalid_request_error"를 박아 넣었고, map_file_error가 이 함수로 보내는 500들도 예외가 아니었습니다. 그래서 관리자 API는 서버 쪽 스토리지 장애를 클라이언트 잘못으로 표시했고, error.error_type()에서 타입을 가져오는 공개 핸들러와 어긋났습니다. 이제 타입이 오류를 따라가며, 관리자 500 하나와 관리자 4xx 하나를 고정하는 테스트를 두어 두 부류가 다시 조용히 합쳐지지 못하게 했습니다. 관리자 표면은 상세 정보를 관리자 경계 뒤에 남겨두는 대신 공개 표면과 같은 기준으로 가립니다. 어느 쪽이든 운영자가 찾는 답은 로그에 있고, 두 표면에 하나의 정책을 두는 것이 서로 어긋나는 것을 막는 방법이며, type 버그가 바로 그렇게 생겼기 때문입니다. src/admin_config/prompts_api/handlers.rs도 같은 조사 범위에 넣었고 더 좁은 형태의 같은 문제가 있었습니다. 상위 디렉터리가 기준 디렉터리 밖일 때 내는 PathTraversal 거부가 해석된 절대 경로를 실었는데, 몇 줄 아래의 형제 검사는 이미 호출자의 상대 경로를 보고하고 있었으므로 이제 그쪽에 맞췄습니다. PromptFileError::IoError와 InvalidPath에도 같은 클라이언트/로그 분리를 적용했습니다.
  • Azure Content Safety의 한쪽 하위 검사가 실패했을 때 다른 쪽이 내린 차단 판정이 버려지던 문제를 수정했습니다 (#1194, PR #1182 후속). AzureContentSafetyGuardrail::check_input_text는 입력 단계를 Prompt Shields(text:shieldPrompt)와 Content Safety 텍스트 분석(text:analyze)이라는 서로 독립된 두 엔드포인트로 검사하는데, #1182에서 하위 검사를 보고 가능한 GuardrailCheckResult 형태로 바꾸면서 둘을 ?로 묶었습니다. ?는 단락 평가를 하므로 Prompt Shields가 실패하면 텍스트 분석은 아예 실행되지 않았고, 검사 전체가 오류가 되었으며, 기본값인 on_error: fail_open에서 서비스는 그 오류를 Allow로 해석했습니다. 그 결과 Azure 텍스트 분석이라면 거부했을 콘텐츠가 그대로 전달되었습니다. 반대 방향도 마찬가지여서, 텍스트 분석이 실패한 경우에는 Prompt Shields가 잡아낸 탈옥 차단이 버려졌습니다. 이제 두 하위 검사는 항상 실행되며, 느린 엔드포인트가 다른 쪽에 필요한 타임아웃 예산을 먹어치우지 못하도록 순차가 아니라 동시에 호출합니다. 결과는 살아남은 판정이 이미 거부인지로 고릅니다. 살아남은 판정이 Block이면 그 판정을 반환해 그대로 적용하고, 거부가 아니면(Allow이거나 차단하지 않는 Flag) 여전히 실패를 반환해 fail 정책이 처리를 결정하고 #1182에서 추가한 카운터(errors_total, fail_open_total / fail_closed_total, guardrail_errors_total{kind="error"})도 계속 움직이게 합니다. "무언가를 발견했는지"가 아니라 "이미 거부인지"로 판단하는 이유는, 이 어댑터가 의도적으로 on_error를 알 수 없어서 두 정책 모두에서 옳은 값을 반환해야 하기 때문입니다. Block은 fail 정책이 만들어낼 수 있는 가장 엄격한 판정과 정확히 같은 수준이지만, Flag는 게이트에서 허용으로 매핑되므로 그대로 반환하면 fail_closed가 거부하도록 설정된 요청이 전달되고 엔드포인트 장애도 집계되지 않습니다. 단순히 "성공한 쪽을 반환"하지 않는 이유는 두 불변식을 동시에 지키기 위해서입니다. 아무것도 거부하지 못한 절반짜리 검사가 깨끗한 통과로 읽히는 일도 없고, 옆 엔드포인트가 죽었다는 이유로 진짜 거부 판정이 삼켜지는 일도 없습니다. 오류와 차단이 모두 요청을 거부하므로 fail_closed 동작은 그대로입니다. 같은 형태의 호출 지점은 여기뿐이라는 점도 다시 확인했습니다. check_output은 텍스트 분석만 실행하고(Prompt Shields는 입력 전용), Bedrock 프로바이더는 두 단계를 모두 하나의 ApplyGuardrail 호출로 처리합니다.
  • 백엔드 인벤토리 신원을 허브의 stable_id 와이어 키로 보고해, 백엔드 코스트 센터 계층 전체를 무력화하던 #1172의 조용한 v0 와이어 결함을 수리했습니다 (#1162 리뷰, PR #1190). #1172는 BackendInfo.backend_id로 출시했지만 허브가 독립적으로 작성한 대응 필드(허브 #732)는 stable_id를 읽습니다. 양쪽 모두 serde 기본값을 쓰고 rename도 deny_unknown_fields도 없어서, 허브는 라우터의 키를 없는 것으로 파싱했고 어떤 백엔드도 배정 가능한 코스트 센터 인벤토리가 되지 못했으며 backend_assignments는 사실상 항상 비어 있었습니다. 추론이 아니라 고정된 rev의 허브 클론에 대해 검증했습니다. 벤더링된 필드를 허브의 키로 개명하고 업스트림 문서 주석을 채택했으며, 운영자 대상 backends[].backend_id 설정 키와 별개의(올바른) UsageRecord.backend_id 필드는 그대로입니다. 허브가 커밋한 heartbeat_stable_backend_identity.json 픽스처를 바이트 단위로 복사해 양방향으로 고정했고, 신원이 다시는 로컬 설정 키로 직렬화되지 않도록 가드를 추가했습니다. 같은 재동기화에서 허브의 policy_envelope.json을 엔벨로프 수준 값 고정으로 벤더링했는데, 이것이 두 번째 파싱 충실도 결함을 즉시 잡아냈습니다. KeyEntry.project_id(허브 #685)가 재직렬화 시 조용히 사라지던 것을 이제 그대로 실어 나릅니다. 강제 적용은 #1152 전까지 손대지 않습니다.
  • 부하가 걸린 상태에서 죽은 백엔드가 요청마다 다시 시도되는 상황과, 지정하지 않은 상태 코드가 서킷 브레이커의 성공으로 기록되던 문제를 함께 고쳤습니다 (#1148). 트래픽 도중 주 백엔드를 죽여도 스트리밍 폴백이 모든 요청을 구제하기 때문에 가용성은 유지되지만, 첫 토큰 p95는 장애가 끝날 때까지 저하된 채로 머물렀습니다. 죽은 백엔드를 요청마다 다시 시도했기 때문입니다. 이 준안정 상태를 만든 원인은 서로 다른 세 가지이며, 제보자가 제시한 메커니즘(폴백이 요청을 성공시키므로 실패가 누적되지 않는다)은 그중에 없습니다. 폴백은 이미 모든 단계에서 체인을 넘기기 전에 주 백엔드의 전송 실패를 기록하고 있었습니다. 첫째, CircuitBreaker::record_failure가 failure_status_codes에 없는 상태 코드를 record_success로 바꿔 처리했고, Closed 상태에서 이는 failure_count를 0으로 되돌리고 슬라이딩 윈도에 성공을 밀어 넣었습니다. 이슈 제목이 지목한 실제 결함이 이것입니다. 429나 404를 진짜 5xx와 섞어 반환하는 백엔드는 임계치에 영영 도달하지 못했고, 429만 반환하는 백엔드는 영원히 건강해 보였습니다. 이제 이런 상태 코드는 half-open 프로브 슬롯만 반납하고 나머지는 아무것도 건드리지 않는 중립 결과로 기록되며, 새 OutcomeRecord 반환값을 통해 Prometheus 미러도 더 이상 이를 성공으로 세지 않습니다. 429는 기본 failure_status_codes에서 계속 제외합니다. 일시적 rate limit은 백엔드가 살아 있고 스로틀링 중이라는 뜻이고, 서킷을 열면 스로틀링이 장애로 바뀌며, (#740)/(#742)에서 만든 재시도 경로가 이미 업스트림 Retry-After를 존중하고 비일시적 할당량 소진에는 빠르게 실패하기 때문입니다. 둘째, 서킷 브레이커가 기본적으로 꺼져 있어서 모든 기록 지점이 무동작이었고, 제외는 전적으로 헬스 체크에 맡겨졌습니다. 기본값 기준 90초(interval 30초 x unhealthy_threshold 3)인 그 창은 제보된 저하 구간을 통째로 포함합니다. 브레이커는 계속 옵트인으로 둡니다. 기본으로 켜면 기존 배포 전체의 요청 라우팅이 바뀌기 때문입니다. 대신 fallback.fallback_chains가 설정되어 있는데 브레이커가 없으면 시작 시 경고를 남기며, 실패 양상을 설명하고 설정된 health_checks 값에서 노출 창을 계산해 알려줍니다. 폴백을 설정한 배포 템플릿 세 개에는 circuit_breaker 섹션을 추가했습니다. 셋째, 브레이커를 전혀 거치지 않던 디스패치 경로가 많았습니다. 이미지 생성과 이미지 편집, Responses 전용 패스스루 브리지, 네이티브 Gemini 멀티모달 임베딩, Anthropic Messages 인그레스 전체(네이티브, OpenAI 호환, Responses 기반, Bedrock Runtime, Unix 소켓, 웹 검색 에뮬레이션), Responses 인그레스(변환 전략 네 가지와 패스스루·compact), Anthropic count_tokens입니다. 이제 모두 새 proxy::circuit::Admission 가드를 통해 승인하고 결과를 기록합니다. 이 가드는 승인과 결과 기록을 정확히 하나씩 짝지으며 drop 시 half-open 프로브 슬롯을 반납하므로, 출구가 여럿인 OAuth 재시도 경로가 프로브 용량을 흘리지 못합니다. 중요한 점은 승인을 단독으로 적용하지 않는다는 것입니다. proxy::selection::select_admissible_backend가 filter_admissible로 후보를 거르고 선택한 뒤 승인하며, 경합에서 밀리면 다시 선택하고, 백엔드와 가드를 함께 반환합니다. 서킷을 모르는 선택 단계 위에 디스패치 시점 게이트만 얹었다면 건강한 동료가 처리할 수 있는 요청이 라우터의 503으로 바뀌었을 것입니다. HalfOpen은 중립 결과가 여전히 서킷을 닫는 쪽으로 진행시키는 유일한 상태인데, 이 상태가 던지는 질문은 백엔드가 건강한지가 아니라 트래픽 차단을 그만둘지이기 때문입니다. 이 규칙이 없으면 429만 반환하는 백엔드가 종료 시한도 없이 프로브 한도에 묶입니다. services::health_service에 있던, 연결되지 않은 두 번째 서킷 상태 기계는 데이터 경로가 아니라는 점을 문서화해 진짜 브레이커로 오인하지 않게 했습니다.
  • 하트비트의 guardrails 블록을 허브 모양으로 맞췄습니다. 가드레일이 돌고 있는 라우터가 인벤토리를 아예 전달하지 못하던 문제를 고칩니다 (#1150). 라우터의 GuardrailSummary와 허브의 것이 각각 따로 만들어져 서로 맞지 않았고, RouterInventory.guardrails가 관대한 blob이 아니라 중첩 구조체라서 serde가 구조체 전체를 실패시켰습니다. 그 결과 가드레일이 활성화되고 control_plane.enabled가 켜진 라우터는 백엔드도, 헬스도, 부하도, 공급도, policy_status도, 아무것도 보내지 못했습니다. 추정이 아니라 허브 58bb3d0에 대고 확인한 내용입니다. 이제 GuardrailSummary는 허브가 필수로 선언한 스칼라 여섯 개(transforms_total, flags_total, errors_total, fail_open_total, fail_closed_total, stream_buffer_cap_trips_total)를 싣고, 카테고리 내역은 이 라우터가 보내던 blocks_by_category 배열이 아니라 카테고리 id를 키로 하는 허브의 by_category 객체입니다. 유일하게 진짜로 깨지는 와이어 변경이지만, 예전 모양은 어떤 허브도 파싱할 수 없었기에 안전합니다. transforms_total, flags_total, stream_buffer_cap_trips_total은 따로 세지 않고 GuardrailSnapshot::into_summary에서 대응 내역으로부터 유도하므로, 지금 허브가 읽는 스칼라와 나중 허브가 읽을 내역이 어긋날 수 없습니다. 테스트가 각 합계를 대응하는 합과 대조합니다. errors_total, fail_open_total, fail_closed_total은 컨트롤 플레인 트래커에 출처가 아예 없던 값으로, 이제 guardrail_errors_total을 기록하는 것과 같은 지점(src/services/guardrail/service.rs)에서 GuardrailTracker::record_error가 기록합니다. Prometheus 레지스트리에서 되읽지 않는 것은 의도적입니다. metrics는 선택 사항이고, 이를 빼고 빌드한 배포에서 0을 보고하면 트래픽을 조용히 통과시키고 있는 가드레일이 놀고 있다고 허브에 알리는 셈이 되기 때문입니다. 두 fail 정책 카운터는 설정된 on_error가 아니라 실제로 적용된 결과로 나뉩니다. monitor 모드는 아무것도 게이팅하지 않으므로 monitor에서 실패한 검사는 정책과 무관하게 검사 없이 나간 트래픽이며 fail-open으로 셉니다. 이를 fail-closed로 보고하면 강제 적용의 구멍을 드러내는 유일한 카운터가 0이 되어 버립니다. errors_total은 총계가 아니라 하한입니다. 그 지점에 닿는 것은 프로바이더 타임아웃뿐이고, 빠르게 실패하는 프로바이더는 전송·상태 코드·파싱 실패를 자기 안에서 판정으로 바꿔버리기 때문입니다. Prometheus 쪽도 같은 공백을 갖고 있으며, 메우려면 ProviderOutcome에 명시적인 실패 표식이 필요합니다. 라우터 전용 verdicts와 stream_buffer_cap_trips 내역은 부가 선택 필드로 남습니다. 지금 허브는 deny_unknown_fields를 쓰지 않으므로 그냥 무시합니다. by_category에 키가 들어가는 유일한 경로가 GuardrailCategoryId 리터럴이므로 카테고리 id는 개수·바이트 길이·문자 집합 모두 구조적으로 한정되며, is_bounded()가 허브의 수집 규칙에 맞춰 셋을 모두 검사합니다. 허브가 커밋한 guardrail_summary.json을 바이트 단위로 이 저장소에 고정했고, 가드레일 정책 픽스처와 같은 "허브가 바뀌면 다시 복사할 것, 로컬 타입에서 재생성하지 말 것" 규칙을 함께 적었습니다. 전에는 두 저장소 모두 자기 타입만 왕복 검사했고, 양쪽 다 초록불인 채로 모양이 어긋난 것이 바로 그 때문입니다.
  • guardrails 설정 섹션을 재시작 필요가 아니라 즉시 적용 핫 리로드로 보고합니다 (#1127). ConfigSection::Guardrails가 HotReloadCapability::RequiresRestart로 선언되어 있었고 관리자 API가 이를 그대로 운영자에게 노출했지만, GuardrailService::update_config가 예전부터 라이브로 적용해 온 mode 전환, 임계값 편집, 라우트 변경, 프로바이더 행에 대해 이 선언은 틀린 값이었습니다. enabled 토글에도 더 이상 필드 단위 예외가 필요하지 않습니다. 가드레일 서비스 핸들이 나중에 채울 수 있는 형태가 된 이후로, 비활성 상태로 시작한 라우터는 리로드나 허브 정책이 가드레일을 켜는 첫 순간에 서비스를 생성하고, 런타임 비활성화는 서비스를 파괴하는 대신 라이브 서비스에 enabled: false로 표현되므로 양방향 모두 라이브입니다. 이 섹션에는 재시작이 필요한 부분이 없으며, 이제 그에 맞게 분류됩니다.
  • /anthropic/v1/messages 경계에서, 응답 범위에서만 고유한 백엔드의 도구 호출 id를 되돌릴 수 있는 형태로 요청 범위마다 고유하게 다시 씁니다 (#1143, #1142 해결). Anthropic 프로토콜에서 tool_use.id는 대화 전체 범위인데, vLLM은 Kimi-K3에 대해 {tool_name}:{index} 형태로 id를 만들고 이 인덱스는 매 응답마다 다시 시작합니다. 그래서 같은 도구를 두 턴에 걸쳐 부르면 read_file:0을 두 번 받게 됩니다. Claude Code의 ensureToolResultPairing은 이 재사용을 손상된 것으로 보고 '도구 사용이 중단됨'이라는 합성 결과로 대체하며 턴을 끝내버렸고, 라우터 쪽에서는 매 요청이 200으로만 보여 아무 문제도 드러나지 않았습니다. 이제 이미 대화 범위에서 고유하다고 알려진 형태(toolu_, call_, fc_, chatcmpl-tool- 접두사이면서 id 전체가 문자 집합상 안전한 경우)가 아닌 아웃바운드 id는 crt_<nonce:12><base64url_nopad(raw_id)>로 다시 씁니다. 실제로 충돌을 끊는 부분은 12자리 UUID v4 nonce입니다. base64url만으로는 결정적이라 매 턴 같은 id를 클라이언트에 그대로 돌려주기 때문입니다. 원본 id는 들어올 때 바이트 단위 그대로 복원하며 문자만 정리하는 식으로 손대지 않습니다. 어떤 백엔드는 자기가 만든 id 템플릿을 살짝 바꿔서 되돌려 받으면 응답을 아예 멈춘다는 보고가 있어서, 이 정확성이 그대로 동작을 좌우하기 때문입니다. 디코딩은 요청을 절대 실패시키지 않으며, 인코딩된 형태와 맞지 않으면 무엇이든 그대로 통과시킵니다. 새 src/http/handlers/anthropic/tool_id.rs 모듈이 생겼습니다. 네이티브 Anthropic 백엔드는 패스스루 SSE로 스트리밍되어 이 경로에 닿지 않고, Gemini는 이미 대화 범위에서 고유한 toolu_ id를 자체적으로 만들어 씁니다.
  • format: "toml"로 요청한 POST /admin/config/export가 무조건 실패하던 문제를 고쳤습니다 (#1159, #1138 해결). 대부분의 선택적 Config 섹션은 skip_serializing_if = "Option::is_none" 없이 #[serde(default)]만 붙어 있어서 값을 설정하지 않은 섹션도 명시적 null로 직렬화됐는데 toml 크레이트는 null 값을 표현할 방법이 없습니다. 기본 설정을 내보내면 이런 null이 19개나 나왔습니다. 이제 남아 있던 최상위 선택 필드 21개가 값을 설정하지 않으면 빠지도록 바뀌었고 기본 내보내기에서 여전히 null을 흘리던 중첩 필드들(ServerConfig.workers, ServerConfig.connection_pool_size, ResponseCacheConfig.{redis,l1,l2,tiered}, DisaggregatedServingConfig.default_external_storage, S3CacheLayerConfig.ttl_override, RequestTimeoutConfig.image_generation)도 같이 고쳤습니다. 그래서 GET /admin/config/full, POST /admin/config/export, 설정 기록 스냅숏은 이제 설정하지 않은 선택 섹션을 null로 담는 대신 아예 빼고 돌려줍니다. get_section_value는 이렇게 빠진 값을 다시 Value::Null로 되돌려주므로 GET /admin/config/{section}으로 존재하지만 설정하지 않은 섹션을 조회해도 여전히 404가 아니라 200과 null 바디를 받고 PATCH도 이 null을 기준으로 그대로 병합됩니다. WebUI의 구조 비교와 기록 비교 도우미(config.js, history.js)도 이제 키가 없는 상태와 명시적 null을 같은 상태로 취급하므로 예전 형태로 내보낸 파일을 새 응답 형태에 붙여 넣어도 사라진 null 키가 가짜 추가나 삭제로 표시되지 않습니다.
  • 로그 문자열을 고정 바이트 오프셋 대신 UTF-8 문자 경계에서 잘라내도록 고쳐 비ASCII 값 하나가 라우터 프로세스 전체를 중단시키는 일이 없도록 했습니다 (#1158, #1157 해결). proxy, core::files, http::middleware, http::handlers::anthropic, infrastructure::backends::anthropic, services::smart_routing에 걸친 12곳의 로그 절단 지점에서 바이트 인덱스 &str 슬라이스 18개가 고정 오프셋이 멀티바이트 문자 중간에 떨어질 때마다 패닉을 일으켰습니다. 릴리스 프로파일은 panic = "abort"(Cargo.toml:311)로 설정되어 있어 이 패닉은 요청 하나를 실패시키는 데 그치지 않고 프로세스 자체를 끝냈습니다. 인증된 클라이언트가 특별한 배포 없이도 도달할 수 있는 지점이 둘 있습니다. proxy::utils::sanitize_file_id_for_log는 FileId::from_string이 이미 거부한 원본 파일 id를 잘라내고 core::files::transformer에 있던 바이트까지 동일한 중복 함수는 검증을 통과한 id를 잘라냈습니다. FileId::from_string의 유니코드 인식 알파뉴메릭 검사가 비ASCII 값도 통과시키기 때문입니다. 두 곳 모두 file-일이삼사오육 같은 id에서 패닉이 났습니다. infrastructure::backends::anthropic::transform_openai_file_to_anthropic(손상된 멀티바이트 file_data 미리보기)와 http::middleware::admin_audit::mask_username(멀티바이트 Basic 인증 사용자명)도 같은 방식으로 도달할 수 있습니다. 새 src/core/text_utils.rs에 truncate_on_char_boundary와 truncate_tail_on_char_boundary를 추가했습니다. 무작정 자르는 대신 가장 가까운 유효 경계로 물러나거나 다가갑니다. 영향받는 모든 호출부가 이제 이 함수를 거치며 core::files::transformer에 있던 중복 함수는 공용 함수 쪽으로 정리해 지웠습니다. 눈으로 확인하는 대신 네 가지 알파벳으로 0부터 700바이트까지 모든 ASCII 길이에서 기존 출력과 새 출력을 전수 비교했고 기존 로그 형식과 doctest 출력은 바이트 단위까지 그대로였습니다.
  • 클라이언트가 보낸 값이 Anthropic 핸들러의 로그 필드에 닿기 전에 이스케이프하고 길이를 제한해, 인증된 호출자가 개행 문자를 심어 로그 기록을 위조하던 통로를 막았습니다 (#1161, #1151 해결). field = %value는 값을 DisplayValue로 감싸는데 tracing_subscriber의 기본 포매터는 message가 아닌 필드를 아무 감싸기 없이 {:?}로 그대로 기록하므로 Display 출력이 로그 줄에 날것으로 남았습니다. 포맷 문자열에 값을 끼워 넣는 방식도 나을 게 없었는데 message에 적용되는 EscapeGuard는 ANSI와 C1 제어 문자만 다시 쓰고 \n과 \r은 그대로 통과시켰기 때문입니다. src/http/handlers/anthropic/에 걸친 약 40곳의 클라이언트 영향 지점(도구 이름, 도구 호출 id와 파일 id, effort 문자열, 요청 모델, anthropic-version 헤더, AnthropicToolChoice::Simple 같은 열거형 값)은 이제 % 기호 없이 이름 붙은 필드로 값을 기록해 Visit::record_str을 거쳐 Debug for str로 포맷됩니다. 이 경로는 문자열을 따옴표로 감싸고 \n, \r, \t, ", \와 출력 불가능한 문자를 이스케이프해 위조 시도를 한 줄짜리 로그 기록 하나에 가둡니다. 이스케이프만으로는 위조를 막을 뿐 값이 부풀어 오르는 것까지는 막지 못하므로, 이 값들은 새로 추가한 core::text_utils::cap_client_value_for_log도 함께 거칩니다. 이 함수는 바이트 인덱스 슬라이스 대신 기존의 경계 안전 함수 truncate_on_char_boundary로 CLIENT_LOG_VALUE_MAX_BYTES(256바이트)까지만 값을 잘라냅니다. backend.name, url, status, 타입이 정해진 열거형, 숫자처럼 라우터가 스스로 정하는 값과, 고정된 허용 목록으로 이미 제한된 mime_type 필드 두 곳은 % 기호를 그대로 남겨두었습니다. 클라이언트가 절대 고를 수 없는 바이트까지 이스케이프하면 실제 회귀를 가릴 뿐이기 때문입니다.
  • 마스킹된 설정을 내보냈다가 그대로 다시 가져오면 자격 증명이 자기 마스크 값으로 덮어써지면서도 성공으로 보고되던 문제를 고쳤습니다 (#1160, #1139 해결). 기본값인 include_sensitive: false로 설정을 내보낸 뒤 그대로 다시 가져오는, WebUI의 Configuration 페이지가 보여주는 바로 그 흐름에서 마스킹된 자격 증명마다 자리표시자 값이 그대로 기록되고도 success: true가 돌아왔습니다. 문제는 한참 뒤 모든 백엔드가 401을 반환하면서 드러났고 복구하려면 시크릿을 하나하나 다시 입력해야 했습니다. 예전 자리표시자 형태(긴 값에는 sk...(21 chars), 4자 이하 값에는 그대로 ***MASKED***, 환경 변수 참조에는 ${***VAR***})는 비밀 값 자체에서 만들어졌기 때문에 마스킹된 값과 같은 모양의 진짜 자격 증명을 구분할 수 없었습니다. 이제 Admin API가 반환하는 모든 마스킹 값은 고정된 센티널 ***CONTINUUM-MASKED:<힌트>***로 감싸이며(4자 이하 값은 예전처럼 ***MASKED***만 나오는 대신 (N chars)를 표시합니다), 이 센티널만 '지금 적용된 값을 그대로 둔다'는 뜻으로 읽습니다. POST /admin/config/import, config 후보를 담은 POST /admin/config/apply, PUT/PATCH /admin/config/{section}, 저장 전 예행 검증인 POST /admin/config/validate가 모두 같은 방식으로 자리표시자를 복원하므로 예행 검증이 약속한 결과가 실제 저장 결과와 같습니다. 실제로 값을 기록하는 쪽은 설정 변경 락을 잡은 뒤 값을 채워 넣으므로 동시에 들어온 다른 변경이 복원 대상 시크릿을 옮겨 놓을 수 없습니다. 가져오기 응답은 새 필드 preserved_secret_paths에 그대로 둔 경로를 모두 담고, 예행 검증을 포함한 모든 기록 경로는 같은 목록을 MASKED_SECRET_PRESERVED 경고로 알려줍니다. 배열 항목은 위치가 아니라 id나 name으로 대응하므로 backends 순서를 바꿔도 제대로 복원되지만, guardrails.bypass_api_keys나 rate_limiting.bypass_keys처럼 id도 name도 없는 목록은 위치가 유일한 단서라서 항목을 추가하거나 빼면 추측 대신 거부합니다. 같은 공급자가 발급한 키는 접두사와 길이가 같을 수 있어서 한 칸 밀린 위치가 엉뚱한 키로 대응해 버리기 때문입니다. 대응할 값을 찾지 못한 자리표시자는 옆에 있던 비밀 값을 빌려 쓰는 대신 MASKED_SECRET_UNRESOLVED와 함께 실패한 경로를 알리며 기록 전체를 거부합니다. 센티널이 생기기 전 세 가지 자리표시자 형태도 여전히 인식하지만 오직 거부하기 위해서입니다. LEGACY_MASK_PLACEHOLDER가 뜨면 이 수정 이전 라우터가 내보낸 문서라는 뜻이므로 조용히 믿지 않고 거부하며, 벗어나려면 업그레이드한 라우터에서 다시 내보내거나 지적된 경로에 실제 비밀 값을 직접 입력하면 됩니다. 그대로 둔 경로 목록과 거부된 경로 목록은 각각 500개로 제한되고 초과분은 MASKED_SECRET_REPORT_TRUNCATED 표시로 요약되므로 자리표시자로 가득한 문서가 응답을 부풀리지 못합니다. 백엔드 PUT/PATCH와 WebUI의 Configuration·History 페이지도 새 센티널을 인식합니다. 영문·국문 Admin API 문서와 WebUI 문서에 반영했습니다.
  • API 키별·클라이언트(IP)별 레이트 리밋 차원이 실제 트래픽에서 집행되도록 고쳤습니다. 그동안 두 차원은 프로덕션에서 한 번도 동작한 적이 없습니다 (#1164, #1147 해결). 배선 결함 세 개가 겹쳐 있었습니다. 리미터가 요청 확장에서 맨 SocketAddr를 읽었지만 axum이 넣는 타입은 ConnectInfo<SocketAddr>라서 client_ip가 항상 None이었고, 그 결과 클라이언트별 차원과 IP 화이트리스트, 신뢰 프록시 X-Forwarded-For 경로가 모두 죽어 있었습니다. 리미터가 AuthContext를 채우는 API 키 인증보다 바깥, 즉 애플리케이션 전체에 장착되어 있어서 키별 차원이 붙을 식별 정보가 없었습니다. 그리고 rate_limiting.limits.per_api_key는 아예 읽히지 않았습니다. 결국 global 차원만 동작했으므로 한 테넌트가 폭주해도 차단되지 않았고, 정상 테넌트의 첫 토큰 지연이 약 22ms에서 5600ms로 무너졌습니다. 이제 리미터는 하나의 저장소를 공유하는 두 장착 지점에 차원별로 나뉘어 있습니다. 전역 차원은 인증과 CORS보다 바깥에 있는 애플리케이션 전체 장착 지점 하나가 집행하므로, 인증이 나중에 401로 거절하는 API 요청(키 추측이 계량됩니다), 실패한 admin 인증, CORS 프리플라이트, 404 폴백까지 모든 요청이 limits.global에서 정확히 한 번 차감됩니다. 클라이언트별 차원은 API 라우트에 인증보다 바깥으로, 나머지 식별 차원(키별, 모델별)은 AuthContext가 존재하는 인증보다 안쪽으로 장착됩니다(장착 위치는 이후 #1170에서 다시 정리했습니다). 두 장착 지점이 겹치지 않는 차원을 집행하므로 어떤 버킷에서도 요청 하나가 두 번 차감되지 않습니다. 거절 시에는 거절한 차원의 값이 기준이며, 전역 차원과 식별 차원이 동시에 거절할 상황이면 먼저 검사되는 전역 거절이 클라이언트에 전달됩니다. 기존 설정에 영향을 주는 동작 변경이 두 가지 있습니다. rate_limiting.limits.per_api_key는 이제 자체 api_keys[].rate_limit이 없는 모든 인식된 키에 적용되는 기본값으로 집행됩니다. 따라서 이미 이 블록을 설정해 둔 환경에서는 지금까지 제한이 없던 키들이 차단되기 시작합니다. 명시적 rate_limit은 여전히 이 기본값을 대체하고, rate_limit: 0은 해당 키를 제외합니다. 그리고 429 본문이 이제 하나의 올바른 JSON 문서입니다(error.message, error.type, error.code, error.details.limit_type). 이전에는 JSON 오류 문서가 이스케이프 없이 다른 엔벨로프 안에 다시 박혀 있어 파싱이 불가능했습니다. 거절 응답에는 retry-after와 x-ratelimit-*도 함께 실리며, Retry-After는 #1146에 따라 최소 1초로 고정됩니다. 식별 정보가 없어 건너뛴 차원(유닉스 소켓 리스너의 클라이언트별 차원, permissive 인증 모드의 익명 호출자에 대한 키별 차원)은 새 rate_limit_skipped_total{dimension} 카운터와 디버그 로그에 기록됩니다.
  • 레이트 리미터가 응답 헤더로 호출자 식별 정보와 공유 상태를 흘리지 않게 하고, 미인증 폭주를 그것을 위해 존재하는 차원이 실제로 묶게 하며, 버킷 맵이 가득 찼을 때 트래픽을 거절하지 않게 고쳤습니다 (#1170). 집행 자체는 그대로입니다. 설정된 모든 차원이 이전과 똑같이 차감하고 똑같이 거절하며, 거절 귀속과 Retry-After, 429 본문도 건드리지 않았습니다. 달라진 것은 라우터가 무엇을 보고하는지, 그리고 두 장착 지점이 어디에 있는지입니다. 이제 x-ratelimit-*는 호출자 본인의 할당량만 설명합니다. 이전 계약은 남은 토큰이 가장 적은 차원을 보고했는데, 그러면 그 값이 호출자가 누구인지에 따라 달라집니다. limits.per_api_key 버스트 7과 limits.global 버스트 100을 설정한 상태에서 유효한 키는 7/6을, 인식되지 않은 키는 100/99를 읽었고, 이것이 permissive 인증 모드에서 라우터가 내보내는 유일한 키 인식 신호였습니다. 같은 헤더는 미인증 호출자에게 공유 전역 버킷을 탐침 하나당 토큰 하나 비용으로 실시간 조회하는 수단도 함께 넘겼습니다. 401, 폴백 404, /version 같은 미인증 200이 모두 해당되며, 그 결과 전역 고갈 공격이 감으로 하는 폭주에서 시점을 정확히 맞춘 공격으로 바뀝니다. 또한 bypass_keys 보유자는 면제 센티널 때문에 헤더를 아예 받지 않고 다른 토큰은 받았는데 두 응답 모두 401이었으므로, 리미터 혼자서 우회 목록 등재 여부를 확인해 주고 있었습니다. 이제 규칙은 인증된 식별 정보에 묶인 할당량만 보고한다는 것이고, 현재로서는 per_api_key입니다. 호출자는 그 버킷의 키가 되는 API 키를 가지고 있음을 이미 증명했으므로, 그 값은 본인이 이미 가진 정보 외에는 알려 주지 않습니다. 나머지는 허용된 응답에서 모두 감춥니다. global, per_model, per_backend는 플릿 전체가, per_client는 같은 출발 주소 뒤의 모두가 함께 소비하므로 어느 공유 잔량도 호출자의 할당량이 아닙니다. 게다가 per_client는 인증보다 먼저 평가되어 라우터가 누구에게 보고하는지조차 알 수 없는 시점이고, 이를 보고했다면 발견 3의 오라클이 그대로 되살아납니다. 겉보기에 똑같은 401에서 bypass_keys 보유자는 면제 센티널 때문에 헤더를 받지 못하고 다른 토큰은 실제 값을 받게 되기 때문입니다. 그 차원이 평가하지 않은 응답에는 x-ratelimit-*가 예외 없이 실리지 않으므로, 401, 404, 미인증 200, 화이트리스트, 우회 키 경로에서 구분 신호가 한꺼번에 사라집니다. 거절 응답은 공유 차원이라도 거절한 차원의 값을 그대로 보고합니다. 클라이언트에게 재시도 힌트가 필요하고, "버킷이 비었다"는 사실은 429 자체가 이미 말해 주기 때문입니다. 업그레이드 전에 확인할 결과가 두 가지 있습니다. 이제 허용된 응답에 할당량 헤더가 실리는 것은 per_api_key가 요청을 평가한 경우뿐이므로, 클라이언트가 이 헤더를 읽는다면 그 차원을 설정하십시오. 그리고 permissive 모드의 잔여 항목은 고치지 않고 받아들였습니다. 인식된 키의 응답에는 키별 헤더가 실리고 인식되지 않은 토큰의 응답에는 실리지 않으므로, 헤더의 존재 여부로 둘이 여전히 구분됩니다. 인식되지 않은 호출자에게 헤더를 숨기면 신호가 부재 쪽으로 옮겨 갈 뿐이므로, permissive 모드가 인증 경계가 아니라 개발 및 신뢰 네트워크용 편의 장치임을 분명히 밝히기로 했습니다. 이 모드는 이미 유효한 키와 무효한 키를 똑같이 처리합니다. 키 유효성이 민감한 환경이라면 api_keys.mode: blocking을 설정하십시오. per_client가 인증 안쪽에서 바깥으로 옮겨 갔습니다 (API 라우트 기준). 이 차원은 피어 주소를 키로 쓰고 그 주소는 인증 실행 전에 연결이 제공하므로, AuthContext가 실제로 필요한 차원들과 함께 묶어 두면 정작 이 차원이 존재하는 이유인 패턴을 막지 못합니다. blocking 모드에서 주소 하나가 보낸 잘못된 키 요청 열두 개는 모두 401을 받았고 하나도 차단되지 않았으며, 그 폭주는 공유 전역 버킷만으로 계량되면서 버킷이 마르는 동안 다른 테넌트를 전부 함께 차단했습니다. 같은 라우트 그룹에 인증보다 바깥으로 장착하면 그 폭주를 자기 버킷에서 묶으면서도 WebUI, admin, 정적 트래픽으로 적용 범위가 넓어지지 않습니다. 이제 API 라우트의 요청 경로는 전역 -> 클라이언트별 -> 인증 -> 키별 -> 핸들러 순이며, 세 장착 지점은 여전히 서로 겹치지 않는 차원을 집행하므로 이중 차감은 없습니다. 메트릭 면제가 고정 경로가 아니라 설정에서 유도됩니다. 문자열 /metrics를 그대로 비교하는 방식은 양쪽 다 반대였습니다. 메트릭을 껐을 때 존재하지도 않는 라우트를 면제해 계량되지 않는 404 배출구를 남겼고, 운영자가 사용자 지정 metrics.path로 옮겨 둔 스크레이프는 제한해서 정작 장애 시점에 필요한 모니터링을 차단했습니다. 이제 면제는 실제 metrics.path를 따르고 metrics.enabled가 참인 동안에만 적용됩니다. 스크레이프를 차단 대상에서 빼는 것은 의도한 선택입니다. 부하 상황에서 모니터링을 굶기는 리미터가 계량되지 않는 스크레이프 엔드포인트보다 나쁘기 때문이며, 그래서 남는 위험은 받아들이고 명시합니다. 스크레이프 비용은 메트릭 카디널리티에 비례해 커지고 리미터는 그 폭주를 막아 주지 않으므로 metrics.auth와 네트워크 정책이 중요합니다. /health와 /healthz는 조건 없이 면제로 남습니다. 포화된 라우터도 자기 활성 상태 검사에 계속 답해야 과부하 도중 재시작당하지 않기 때문입니다. 버킷 맵이 가득 찼을 때 이제 닫히는 쪽이 아니라 열리는 쪽으로 실패합니다. per_client, per_api_key, per_backend는 각각 600초 TTL과 함께 항목 100,000개에서 상한에 걸리는데, 상한에 도달하면 Capacity 거절을 돌려주고 있었습니다. 클라이언트와 백엔드 검사에는 이미 존재하는 항목인지 확인하는 조건이 없어서, 맵이 가득 차면 이미 자기 버킷을 가진 호출자까지 거절했습니다. 거절해도 메모리를 아끼지 못합니다. 거절 경로 역시 버킷을 만들지 않기 때문입니다. 반면 서로 다른 출발 주소 100,000개는 IPv6 /64 하나로도 쉽게 만들 수 있으므로, 닫히는 쪽으로 실패하면 공격자가 한정된 맵을 그 차원의 모든 호출자에 대한 10분짜리 장애로 바꿀 수 있었습니다. 이제 가득 찬 맵은 해당 요청에서 그 차원만 건너뛰고 나머지 차원은 그대로 집행하며, 건너뛴 사실을 rate_limit_skipped_total{dimension}과 차원별로 분당 한 줄로 제한된 경고 로그에 남깁니다. 인라인 만료 정리도 맵당 초당 한 번으로 제한해, 고유 키 폭주가 요청마다 10만 항목 스캔을 강제하지 못하게 했습니다. 정상 상태의 정리는 60초 주기 배경 작업이 계속 담당합니다. 다만 클라이언트별 버킷은 여전히 주소 전체를 키로 쓰므로, 하위 64비트를 바꾸는 IPv6 출처는 이 차원을 빠져나가면서 맵까지 포화시킵니다. 그런 트래픽은 앞단에서 막으십시오. 어느 장착 지점이 만든 429든 브라우저에서 읽을 수 있습니다. 전역 장착 지점은 프리플라이트를 계량하기 위해 CORS 레이어 바깥에 있고, 그래서 그 거절에는 Access-Control-Allow-Origin이 아예 실리지 않아 브라우저가 클라이언트에게 가장 중요한 거절을 정체 불명의 네트워크 오류로 만들어 버렸습니다. 장착 지점 자체는 옮기지 않았고, 대신 즉시 반환하는 거절 응답에 cors.allow_origins에 등재된 오리진에 대한 allow-origin 반사, Vary: Origin, 설정된 경우 자격 증명 플래그를 싣습니다. 여기에 더해 retry-after와 x-ratelimit-* 헤더를 CORS 레이어의 Access-Control-Expose-Headers에 항상 추가했습니다. 이 목록이 없으면 교차 출처 스크립트가 거절의 상태 코드는 볼 수 있어도 정작 행동에 필요한 재시도 힌트는 읽지 못했고, 이는 바깥 장착 지점만이 아니라 모든 장착 지점에 해당하는 문제였습니다. 영문과 한국어 레이트 리밋 가이드에 문서화했습니다.
  • 요청 단위로 Anthropic file_id 해석에 상한을 두어, 업로드된 파일 하나를 등에 업은 작은 요청 하나가 수 기가바이트의 상주 메모리로 부풀지 못하게 했습니다 (#1169, #1168 해결). src/http/handlers/anthropic/file_resolver.rs는 메시지, 콘텐츠 블록, tool_result 안에 중첩된 블록의 세 단계를 상한 없는 try_join_all 세 개로 펼쳤고, 10 MiB MAX_INJECTION_SIZE를 요청이 아니라 파일 단위로만 검사했습니다. 그 결과 10 MiB 파일 하나를 800번 참조하는 약 57 KiB짜리 요청 본문이 약 10.7 GiB의 base64를 동시에 상주시켰습니다. 같은 file_id를 중복 제거해도 이 문제는 해결되지 않습니다. resolve_image_source와 resolve_document_source가 참조마다 BASE64_STANDARD.encode를 호출하므로, 바이트를 한 번 읽었든 800번 읽었든 참조마다 인코딩된 사본이 따로 상주합니다. 그래서 이번에 추가한 합계 예산은 고유 파일을 합산하지 않고 중복을 포함한 모든 참조를 계산합니다. 이제 서로 다른 항을 각각 막는 세 가지 상한이 적용됩니다. MAX_TOTAL_RESOLVED_BYTES(요청당 원본 32 MiB, base64 확장 후 약 43 MiB)는 콘텐츠를 읽기 전에 load_file에서 metadata.bytes로 청구되며, 리졸버가 소유한 AtomicUsize에 fetch_add가 돌려준 값을 검사하는 방식이라 동시에 진행 중인 다른 해석과 경합하는 확인 후 증가 방식이 아니고, 이 상한은 근사치가 아니라 정확합니다. read-modify-write는 원자 변수의 단일 수정 순서에서 바로 앞에 기록된 값을 항상 읽으므로 관측되는 누적값은 그 순서상의 접두 합이 되고, 검사를 통과하는 참조는 정확히 그 순서의 접두 구간이므로 어떤 인터리빙에서도 통과한 참조들의 크기 합이 예산을 넘지 못합니다. MAX_FILE_REFERENCES_PER_REQUEST(100)는 첫 메타데이터 조회보다 앞선 단일 사전 순회에서 세 단계를 모두 세어 적용합니다. chat-completions의 20(src/proxy/files.rs)보다 의도적으로 다섯 배 높게 잡았는데, Messages API는 매 턴마다 대화 전체를 다시 보내므로 긴 멀티모달 세션에서는 단일 chat-completions 호출에서는 나올 수 없는 개수의 참조가 정상적으로 쌓이기 때문이며, 개수 상한은 메모리 상한이 아니라 값싼 사전 필터입니다. MAX_CONCURRENT_FILE_LOADS(8)는 리졸버가 소유한 tokio::sync::Semaphore로 강제합니다. 세 펼침 지점이 중첩되므로 단계별 buffer_unordered(8)는 512로 곱해지기 때문입니다. 퍼밋은 스토리지 읽기 구간에서만 유지되고 그 위의 재귀 해석 전체에는 걸리지 않으므로, tool_result가 자기 조상이 쥔 퍼밋을 기다리는 교착은 발생하지 않습니다. 해석 단계에는 chat-completions 경로에 이미 있던 30초 타임아웃도 추가했으며, proxy::files가 crate::proxy 내부 전용이라 상수를 import하지 않고 같은 값으로 복제했습니다. 새 상한 두 가지는 400 invalid_request_error로, 타임아웃은 504로 전달되며, 스트리밍과 비스트리밍 핸들러가 각자 들고 있던 중복 match 블록 대신 새로운 FileResolutionResult::into_request_or_response를 공유하므로 한쪽 경로에서만 처리되고 다른 쪽에서 조용히 누락되는 일이 생길 수 없습니다. 기존 파일 단위 FileTooLarge는 그대로 502를 반환하며 이번 변경에서 건드리지 않았습니다. 영문과 한국어 API 가이드에 문서화했습니다.
  • 닫는 추론 태그 앞에 놓인 분류기 판정을 살려 둡니다 (#1241, PR #1214 후속). strip_reasoning_prefix는 추론 블록이 판정 앞에 온다는 규칙에 따라 마지막 </think> 뒤를 남기는데, 태그가 답 뒤에 오는 출력(unsafe\nS1</think>)은 앞부분이 빈 문자열로 잘렸습니다. #1203이 읽을 수 없는 출력을 실패한 검사로 만든 뒤로, 실제 거부가 실패한 검사가 되어 기본값인 on_error: fail_open 아래에서 그대로 서빙됐습니다. #1203이 막으려던 바로 그 결과가 없어진 게 아니라 자리를 옮긴 셈이었습니다.
  • 빠르게 실패하는 가드레일 프로바이더를 깨끗한 허용이 아니라 실패로 보고합니다 (#1182). 전송 오류, 비정상 HTTP 상태, 파싱 불가 본문은 각 어댑터 안에서 실패 정책 판정으로 해소됐고, outcome.timed_out에서만 발화하던 텔레메트리 이음매는 이를 전혀 보지 못했습니다. 그래서 fail_open 아래에서는 모더레이션 장애가 깨끗한 트래픽 위의 정상 가드레일과 바이트 단위로 같은 하트비트를 만들었고, fail_closed 아래에서는 범주 없는 안전 차단이 쏟아졌습니다. 이제 Guardrail 트레이트가 Result<GuardrailVerdict, GuardrailCheckError>를 반환하므로 새 어댑터가 물리적으로 실패를 삼킬 수 없고, 실패 정책은 버퍼링 경로와 스트리밍 경로가 공유하는 단일 서비스 이음매에서 적용되며, 생성 시점에 고정되는 대신 검사마다 살아 있는 설정 스냅샷에서 해소됩니다. 트레이트 변경은 트리 밖 어댑터에 대해 호환성을 깨는 변경입니다. PII 프로바이더만 문서화된 저하 경로를 위해 로컬 on_error를 유지하는데, fail-open에서 외부 인식기 실패는 내장 탐지로 검사를 마치는 정밀도 저하이지 실패한 검사가 아니기 때문입니다.
  • 파싱되고, 시작 시 정규식 검증을 거치고, 관리자 API로 설정할 수 있고, 집행 기능으로 문서화까지 되어 있으면서 정작 요청 경로의 어떤 코드도 읽지 않던 guardrails.allow / guardrails.deny 매치 목록을 실제로 집행합니다 (#1104). 새 matchlist 모듈이 전역·라우트별 목록을 설정 스냅샷마다 한 번 컴파일하므로 요청 경로에서는 정규식을 컴파일하지 않고, 설정 스냅샷은 설정과 컴파일된 규칙을 한 락 아래 담는 PolicySnapshot이 되어 한 검사가 서로 다른 세대의 설정과 규칙을 읽을 수 없습니다. 우선순위는 deny, allow, 프로바이더 순입니다. deny 매치는 프로바이더가 돌기 전에 차단하고, allow 매치는 프로바이더 호출 없이 곧바로 허용하며, 라우트 목록은 전역 목록을 대체하는 것이 아니라 확장하므로 라우트 재정의가 전역 deny 규칙을 약화시킬 수 없습니다. exact 항목은 대소문자를 구분하지 않는 부분 문자열 일치입니다. 대소문자만 바꾼 변형이 보안 통제를 우회해서는 안 되기 때문입니다. 빈 리터럴과 빈 패턴은 모든 요청에 매치되는 대신 경고와 함께 버려집니다. 매치는 예약된 의사 프로바이더 라벨 match_list_deny / match_list_allow로 기록되어 집행 전에 모니터 모드에서 측정할 수 있고, 차단 사유는 매치된 텍스트나 규칙을 절대 그대로 싣지 않습니다. enforce 모드는 이제 프로바이더 하나 또는 deny 규칙 하나를 요구합니다. 예전에는 실제로 차단하는 deny 전용 정책을 거부했습니다.
  • GPT-5.2에서 멈춰 있어 그 이후 등급을 전부 이름 휴리스틱에 맡기던 내장 OpenAI 모델 카탈로그를 교정하고 채웠습니다 (#1177). 발견된 gpt-5.3-codex는 400K/128K와 \(1.75/\)14 대신 256K 컨텍스트, 4096 토큰 최대 출력, 그리고 꾸며낸 100만 토큰당 \(10/\)30으로 광고됐습니다. 내장 테이블에 GPT-5.5, GPT-5.4, GPT-5.3 계열과 gpt-5.2-codex를, model-metadata.yaml에 gpt-5.3-codex-spark와 이전 codex 등급 세 개를 추가했습니다. gpt-5.1은 두 카탈로그에서 서로 다른 방식으로 틀려 있었고, 이제 2024-09 컷오프에 400K/128K, \(1.25/\)10이며 애초에 없던 audio 능력도 뗐습니다. gpt-5.1-codex와 gpt-5.1-codex-mini도 같은 방식으로 교정했습니다. 모든 codex 등급은 Responses API 전용으로 문서화되어 있어 네 개 모두 responses_only를 달았고, 라우터는 이들에 대한 Chat Completions 요청을 /v1/responses로 연결합니다. 폐기 처리는 OpenAI 폐기 문서와 Azure Foundry 퇴역 일정을 교차 확인해 결정했습니다.
  • solar-pro-3의 가격, 능력, 별칭을 교정했습니다 (#1097, #1095 해결). 이 항목은 0 요율을 광고하고 있었는데, 이 파일에서 0은 호스팅 API 비용이 없는 오픈 웨이트 모델의 관례이므로 /v1/models가 유료 호스팅 모델을 무료로 보고했고 하류의 비용 추정은 Solar Pro 3 지출을 0으로 축소했습니다. Upstage는 100만 입력 토큰당 $0.15, 출력 토큰당 $0.60을 청구합니다. 항목의 요약이 추론 정확도 향상을 이야기하는데도 reasoning 능력이 빠져 있었습니다. Upstage가 실제로 공시하는 solar-pro3와 solar-pro3-260323은 어디에도 해석되지 않았습니다. 4자리도 6자리도 8자리 날짜 접미사 정규화 대상이 아니기 때문이며, 이제 둘 다 명시적으로 등재했습니다. 같은 이유로 solar-pro-2도 solar-pro2와 solar-pro2-251215 별칭을 얻었습니다 (#1099, #1098 해결).
  • 설정 쓰기가 떨어뜨리던 런타임 상태를 다시 만들어, 관리자 API 변경이 백엔드 조회와 모델 메타데이터가 깨진 설정을 게시하지 않도록 했습니다 (#1100은 #1089 해결, #1136은 #1116 해결). put_config_section과 patch_config_section은 실행 중인 설정을 JSON으로 왕복시키는데, 이 과정에서 #[serde(skip)] 런타임 상태(backends_index, model_metadata_cache, response_defaults_cache)가 조용히 타입 기본값으로 돌아가고 아무도 복구하지 않았습니다. 그래서 섹션 PUT/PATCH가 성공하면 다음 전체 설정 로드나 파일 기반 핫 리로드 전까지 백엔드 조회와 메타데이터 해석이 깨진 채였습니다. 이제 두 핸들러가 reload_config_from_file과 같은 순서로 인덱스를 다시 만들고 메타데이터를 다시 읽으며, 후속 변경이 전체 설정 apply·import·rollback 게시 경로까지 같은 처리를 확장하고 create·update·delete·weight·models 변경의 인덱스 재구축을 한곳으로 모았습니다.
  • 모델 메타데이터 조립이 실패하면 경고 후 빈 캐시로 리비전을 게시하는 대신 핫 리로드를 실패시킵니다 (#1102). publish_config_revision은 Config 전체를 대입하므로 model_metadata_cache와 response_defaults_cache가 통째로 교체됐고, 라우터는 가격, 컨텍스트 창, 능력, /v1/models 응답 기본값이 조용히 백엔드 보고값으로 되돌아간 채 계속 서빙했습니다. 재시작이 없으니 설명할 단서도 없었습니다. 경로 검증, 크기 초과 검사, 파싱 실패, 환경 변수 오버라이드, 시크릿 처리, 설정 검증은 모두 Err를 반환해 이전 리비전을 그대로 두는데, 메타데이터 조립만 경고하고 계속 진행하던 유일한 단계였고 이제 같은 규칙을 따릅니다. 정말 치명적인 조건만 이 경로에 도달합니다. 읽을 수 없거나 파싱할 수 없는 기본 파일, 메타데이터 스키마에 맞지 않는 병합 문서, 그리고 MAX_METADATA_LAYERS(64)와 MAX_TOTAL_METADATA_BYTES(8 MiB) 상한입니다. 기본 파일 부재와 읽기·파싱·검증에 실패한 드롭인은 여전히 경고 후 건너뜁니다. 이 문제가 새로 도달 가능해진 이유는 상한 위반입니다. model-metadata.d/ 디렉터리에 쓰기 권한이 있으면 기존 파일을 건드리지 않고 65번째 파일을 추가할 수 있기 때문입니다.
  • /admin/backends의 모든 핫 리로드 후보를 라이브 watch 채널에 게시하기 전에 공용 설정 검증기로 검증합니다 (#1206). 이로써 백엔드 전용 관리자 API가 파일·설정 수용 경로와 같은 판단을 하게 됩니다. 검증 실패는 400으로, watch 채널 전송 실패는 기존 서버 오류로 매핑되며, 형식과 유일성이 이제 나머지 BackendConfig와 같은 전체 설정 게이트를 지나므로 backend_id 전용 관리자 API 검증 헬퍼는 제거했습니다. create, update, delete, weight, models에 대한 회귀 테스트가 거부된 후보는 게시되지도, 설정 이력에 기록되지도 않음을 증명합니다.
  • 두 팩토리 경로 모두에서 시작 시 빈 백엔드 풀을 허용해 기존의 백엔드 0개 배포 계약을 지키면서, 비어 있지 않은 설정의 백엔드가 전부 실패하는 경우의 즉시 실패 동작은 그대로 유지합니다 (#1134, #1133 해결).
  • half-open 서킷의 승인을 디스패치 이음매로 미뤄, 로컬 사전 처리·캐시·가드레일 작업을 위해 백엔드를 고르는 것만으로 프로브 슬롯이 소비되지 않게 했습니다 (#1236, #1148 후속). 서킷 필터를 거친 선택만이 만들어 내는 지연된 SelectedBackend 토큰이 업스트림 I/O 직전에 Admission으로 소비되고, 늦은 승인 경합은 남은 적격 후보에서 다시 선택합니다. Anthropic count_tokens, Anthropic Messages, Responses, compact Responses, responses-only Chat-to-Responses 브리지가 모두 승인 전에 선택된 백엔드 메타데이터를 확인하는 방식으로 바뀌었습니다. half-open의 진행도 실제 성공 근거와 분리해, 중립 결과나 타임아웃만 담긴 프로브 구간은 서킷을 새 쿨다운과 함께 Open으로 돌리고, 중립과 실제 성공이 섞인 구간은 여전히 닫힐 수 있습니다.
  • CORS 노출 응답 헤더를 첫 번째로 파싱된 이름만 남기는 방식으로 중복 제거해, 운영자가 설정한 내장 헤더의 중복이 두 번 나타나지 않게 했습니다 (#1220). 기존 Vec::dedup은 인접성에 의존해서, 내장 짝이 결합된 목록에서 인접하지 않은 retry-after 설정을 놓쳤습니다.
  • Prometheus 메트릭이 꺼져 있어도 키별 인메모리 통계 귀속이 유지되도록 했습니다 (#1209, #1196 해결). API 키 귀속과 라벨 정리를 항상 컴파일되는 코어 모듈로 옮겼고, 로컬과 호스팅 CI 매트릭스 모두 메트릭 비활성 회귀 검증을 추가했습니다.
  • POST /admin/guardrails/test와 가드레일 WebUI 테스트 콘솔에서 dry-run 프로바이더 실패를 드러내, fail-open 대체값을 진짜 허용과 구분할 수 있게 했습니다 (#1221). 정리된 실패 메타데이터가 GuardrailService::dry_run을 거쳐 전달되고, 콘솔은 fail-open·fail-closed 결과 칩과 실패 상세를 렌더링합니다. 영문·국문 WebUI 가이드에 문서화했습니다.
  • PII 외부 인식기의 저하를 로컬 텔레메트리로 드러냅니다 (#1223). 새 guardrail_degraded_total{provider,kind}는 fail-open에서 선택적 외부 인식기에 닿지 못해 PII 프로바이더가 내장 탐지로 되돌아갈 때 kind="external_unavailable"을 기록합니다. 이는 정밀도가 낮아진 검사이지 실패한 검사가 아니므로 guardrail_errors_total은 의도적으로 세지 않습니다. 내장 전용 처리와 fail-closed 오류 동작은 그대로입니다.
  • 1172가 깨뜨린 BackendConfig 구조체 리터럴 네 개를 복구하고, 그것을 가리고 있던 CI 구멍을 막았습니다 (#1200). #[serde(default)]는 역직렬화만 덮고 Rust 구조체 리터럴에는 아무 일도 하지 않으므로, 필수 backend_id 필드를 추가할 때 완전한 리터럴을 전부 같이 고쳐야 했습니다. get_health_check_config rustdoc 예제 안의 두 개는 CI가 doctest를 컴파일하지 않아서, Bedrock 통합 테스트의 두 개는 bedrock-sigv4를 빌드하는 잡도 릴리스 바이너리도 없어서 살아남았습니다. .github/workflows/ci.yml과 scripts/local-ci.sh가 각각 cargo test --doc과 전체 기능 cargo check --all-targets --keep-going 두 단계를 서로 대칭으로 얻었습니다. 두 번째를 좁은 --features bedrock-sigv4 대신 고른 이유는, 고정되지 않은 기능을 한 번에 전부 덮고 나중에 추가되는 기능도 매트릭스 수정 없이 계속 덮기 때문입니다. 매트릭스 수정 누락이 바로 이 버그를 만든 실패 양상입니다.

  • 메트릭 비활성 호환 표면에 no-op FileMetrics::record_retention_delete를 추가해 (#1238), 항상 컴파일되는 Files 서비스를 포함하는 모든 no-default 기능 그래프의 컴파일 정합성을 복구했습니다. 이 구멍은 #1234와 #1233이 main에서 합쳐지고 나서야 드러났습니다.
  • 가드레일 하트비트의 verdicts와 errors_total이 무엇을 세는지 분명히 했습니다 (#1227). 프로바이더가 여럿인 단계와 fail-closed 대체 차단에서 올바른 단위로 읽을 수 있도록, continuum-protocol rustdoc과 영문·국문 가드레일 가이드가 이제 실패한 검사를 명시적으로 설명하고, fail-open 해석을 검사되지 않은 허용 트래픽의 상한으로 한정하며, fail-closed에서 verdicts와 blocks_total이 갈리는 지점을 문서화합니다.

의존성

  • 스트리밍 Files API 경로를 위해 tokio-util의 io 기능을 켜고 (#1189), 가드레일 런타임 서비스 구체화 경로가 무조건 의존하게 되어 arc-swap을 선택적 의존성에서 필수 의존성으로 바꿨습니다 (#1126). 이번 릴리스에서 의존성 그래프에 추가되거나 제거된 크레이트는 없습니다.

v1.16.0 - 2026-07-28

스트리밍 출력 guardrail 게이트를 모든 스트리밍 경로에서 실행하고, 차단만 하고 마스킹은 못 하던 문제를 고치고, 4 MiB 버퍼 상한을 넘어서도 검사를 이어가게 하고, monitor 모드가 실제로 관찰할 것을 갖게 했습니다. metadata download 명령과 계층형 모델 메타데이터 드롭인 디렉터리도 추가했습니다.

추가됨

  • 벤더 기준 파일과 운영자 커스터마이즈가 같은 파일 하나를 두고 다투지 않도록, 계층형 모델 메타데이터 드롭인 디렉터리를 추가했습니다(#1069). model_metadata_file은 슬롯이 하나뿐이라 배포된 사본을 영영 갱신하지 않거나 업스트림 변경을 매번 손으로 병합하거나 둘 중 하나를 고를 수밖에 없었고, 그 파일을 통째로 갈아치우는 metadata download(#1067)를 로컬 수정과 함께 쓰기도 위험했습니다. 이제 메타데이터는 우선순위가 낮은 것부터 기본 model_metadata_file, /etc/continuum-router/model-metadata.d/, <사용자 설정 디렉터리>/continuum-router/model-metadata.d/, ./model-metadata.d/, 그리고 새 설정 키 model_metadata_dirs에 적은 모든 디렉터리를 적은 순서대로 쌓아 조립합니다. 이는 find_config_file의 먼저 찾은 것이 이기는 순서를 뒤집은 것이고 의도한 결과입니다. 계층으로 표현하면 더 구체적인 위치가 나중에 적용되어 이깁니다. model_metadata_dirs는 관례 위치를 대체하지 않고 보강하며 항목을 그대로 쓰기 때문에, 쿠버네티스 ConfigMap을 /etc/router/metadata/에 마운트했다면 그 경로를 그대로 적으면 됩니다. 기본값이 비어 있으므로 기존 설정은 예전과 똑같이 역직렬화되고 똑같이 동작합니다. 한 디렉터리 안에서는 확장자가 .yaml·.yml인 일반 파일만 파일 이름 사전순으로 읽어 10-/50-/90- 관례가 동작하게 하고, 하위 디렉터리로 재귀하지 않으며, 점으로 시작하는 파일과 *~·*.swp는 건너뛰고, 심볼릭 링크는 따라가되 최종 대상이 일반 파일이어야 하며, 디렉터리가 없으면 오류가 아니라 빈 것으로 봅니다. 병합은 역직렬화 이전 단계인 파싱된 serde_yaml::Value에서 이뤄지는데 이는 우연이 아니라 핵심입니다. 매핑은 키 단위로 재귀 병합하므로 오버라이드 파일이 바꾸는 필드만 담으면 되고, 시퀀스와 스칼라는 통째로 교체하므로 물려받은 aliases나 capabilities 항목을 지울 수 있으며, models 항목은 id를 키로 삼아 이미 있는 id는 제자리에서 병합하고 새 id는 뒤에 덧붙입니다. responses_only가 제대로 동작하는 이유도 같습니다. #[serde(default)]가 붙은 평범한 bool은 역직렬화 후에 값이 없었는지 false였는지 구분할 수 없어서, 타입 단위 병합이라면 그 키를 아예 언급하지 않은 오버라이드 파일이 물려받은 true를 조용히 지워버렸을 것입니다. 같은 이유로 ModelMetadata에 필드가 새로 생겨도 병합 코드를 추가하지 않아도 됩니다. 병합된 문서는 한 번만 역직렬화·검증하며, 백엔드 model_configs가 여전히 그보다 우선하고 병합 결과는 여전히 내장 OpenAI 레지스트리보다 우선합니다. 읽기·파싱·검증에 실패한 드롭인은 눈에 띄는 경고와 함께 건너뛰고 나머지 탐색 경로는 그대로 적용되는데, 이는 시작 시점과 리로드 시점에 이미 쓰이던 경고 후 계속 진행 방침과 같으며 잘못된 운영자 파일 하나가 실행 중인 라우터를 내려앉히지 못하게 합니다. 기본 model_metadata_file이 잘못된 경우는 종전과 똑같이 치명적입니다. 책임은 한 번만 묻습니다. 병합 결과가 스키마를 만족하지 못하게 된 순간에 적용 중이던 레이어만 원인으로 지목하므로, 기본 파일이 이미 잘못된 경우 그 위에 얹힌 멀쩡한 드롭인이 아니라 기본 파일이 원인으로 보고됩니다. --resolved도 이제 response_defaults가 유효하지 않다는 이유로 1로 종료하지 않습니다. 그 경우 경고만 남기고 기본값으로 돌아가는 라우터 자신의 동작을 그대로 따라서, 실제로 라우터가 서비스하는 문서를 출력하지 못하고 거부하던 문제를 없앴습니다. 두 보고 형식 모두 설정 파일을 찾긴 했지만 불러오지 못했거나 애초에 찾지 못한 경우를 # WARNING: 머리글 줄과 config_warning JSON 필드로 드러내므로, 그 설정의 model_metadata_file과 model_metadata_dirs가 빠진 탐색 경로를 아무 말 없이 보여주던 예전과 다릅니다. 파일은 최대 64개, 합계 최대 8 MiB까지 조립하고 한도를 넘으면 잘라내지 않고 오류로 처리합니다. 오버라이드 집합을 일부만 조용히 적용하는 쪽이 조립을 거부하는 쪽보다 나쁘기 때문입니다. 건너뛴 파일은 클라이언트 쪽에서 보이지 않으므로, 새 명령 continuum-router metadata show [--resolved] [--json]이 탐색 경로, 적용 순서대로의 레이어 목록(나중 레이어가 가져간 모델 id와 그 이전 파일 포함), 건너뛴 파일과 사유를 출력합니다. 기본 모드는 원본 병합 결과를 그대로 출력해 오타가 보이게 하고, --resolved는 response_defaults 검증·정제를 거친 실제 적용 타입 문서를 출력하며, 사람이 읽는 머리말은 YAML 주석으로 쓰므로 출력을 그대로 저장해도 읽을 수 있는 파일이 됩니다. 적용된 파일 목록과 모델 id 오버라이드는 시작 시 로그로도 남습니다. 레이어를 읽을 때는 기본 파일을 포함해 경로를 유닉스에서 O_NONBLOCK으로 한 번만 열고, 그 동일한 디스크립터를 fstat으로 검사해 일반 파일인지 확인한 다음 남은 바이트 한도만큼만 take로 읽습니다. 그래서 타입 검사와 한도 계산과 실제로 읽는 바이트가 모두 같은 대상을 가리키며, 같은 경로를 세 번 따로 조회하던 예전 방식은 사라졌습니다. 나열된 드롭인 디렉터리 안에 fifo나 디바이스 노드가 끼어들거나 기본 파일 자리에 fifo가 바꿔 들어가도 시간 제한 없이 open이 멈춰버리는 일이 이제는 없고, fstat 이후에 파일이 커지거나 실제 길이를 속여도 크기 한도를 피해갈 수 없습니다. models 목록 병합은 레이어마다 누적된 항목을 id로 한 번만 인덱싱하므로, 오버레이 항목마다 전체 목록을 다시 훑던 이차 시간 대신 모델 수에 선형으로 걸립니다. ConfigSection::ModelMetadataDirs도 FromStr에 연결해, 섹션 목록과 admin JSON 스키마에는 광고되면서 섹션별 admin GET/PUT 핸들러에서는 알 수 없는 섹션으로 거부당하던 틈을 막았습니다. metadata download는 여전히 기본 파일만 쓰고 드롭인 디렉터리는 읽지도 쓰지도 않으므로 반복 실행해도 안전하며, --model-metadata 플래그의 의미도 그대로입니다. 이 플래그는 기본 파일을 지정할 뿐 드롭인을 끄지 않습니다. model_metadata_file과 model_metadata_dirs는 모두 requires_restart로 남습니다. libc가 O_NONBLOCK용으로 cfg(unix) 의존성에 새로 들어갔지만 이미 lock 파일에 있던 crate라서 실제로 새로 추가되는 crate는 없습니다. man 페이지, 국문·영문 configuration 가이드, 예제 설정, configuration-assistant 안내(스키마 1.27.0에서 1.28.0)에 문서화했습니다.
  • 저장소에서 정식 model-metadata.yaml을 받아 라우터가 실제로 읽는 경로에 설치하는 continuum-router metadata download(표시 별칭 metadata update)를 추가했습니다(#1067). 릴리스 아카이브·Debian 패키지·컨테이너에는 이 파일이 들어 있지 않고, 메타데이터만 바꾸는 커밋으로 새 프런티어 모델이 계속 들어오는 동안 기존 사본은 조용히 낡아갑니다. 저장소를 통째로 복제하지 않고서는 이를 받아올 방법이 없었습니다. 대상 경로는 문서화된 순서(--output, 전역 --model-metadata, 불러온 설정의 model_metadata_file로 로더와 동일한 틸드 확장 적용, 발견한 설정 파일 옆의 model-metadata.yaml, ~/.config/continuum-router/model-metadata.yaml)로 정해지며 성공 경로뿐 아니라 실패 경로에서도 출력됩니다. --model-metadata가 설정 필드보다 위에 있는 이유는 라우터 본체에서 하는 일이 정확히 그것이기 때문입니다. 그래서 --model-metadata /etc/cr/meta.yaml metadata download는 해당 인스턴스가 실제로 읽을 경로에 파일을 설치합니다. 내려받은 내용은 디스크를 건드리기 전에 파싱해 로더의 response_defaults 검증을 거치는데, 경고만 남기고 기본값으로 되돌리는 로더와 달리 이 명령은 검증 실패를 치명적으로 취급합니다. 형식이 깨졌거나 스키마에 맞지 않으면 1로 종료하고 동작 중인 배포는 바이트 단위로 그대로 둡니다. 교체는 대상 디렉터리 안의 임시 파일을 거쳐 원자적으로 이뤄집니다. 임시 파일은 실행마다 다른 이름으로 배타 생성하므로, 다른 사용자가 쓸 수 있는 디렉터리에 예측 가능한 이름으로 미리 심어둔 심볼릭 링크가 쓰기를 엉뚱한 곳으로 돌릴 수 없고 동시에 실행된 두 작업이 같은 버퍼 파일에 섞여 들어가지도 않습니다. 기존 파일의 권한 모드는 유지하며(새 파일은 토큰 저장소의 0600이 아니라 0644), --no-backup을 주지 않으면 이전 파일을 <file>.bak으로 남깁니다. 내용은 SHA-256으로 비교하므로 바뀐 것이 없으면 --force 없이는 쓰지 않고 건너뜁니다. --check는 아무것도 쓰지 않고 업데이트가 있으면 2로 종료하므로, cron과 CI가 낡은 사본을 오류로 취급하지 않고 분기할 수 있습니다. HTTPS 소스만 받아들이고, HTTPS를 벗어나는 리다이렉트는 reqwest 기본 정책처럼 따라가지 않고 거부하며, 참고용에 불과한 Content-Length를 믿는 대신 스트리밍 본문을 8 MiB로 제한합니다. 저장소가 비공개인 동안에는 GitHub 토큰이 필요합니다. 인증 없는 raw 요청은 모든 ref에 대해 404를 돌려주기 때문입니다. 토큰은 CONTINUUM_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN 순으로 읽어 비어 있지 않은 첫 값을 쓰고 앞뒤 공백은 잘라냅니다. --token 플래그는 일부러 두지 않았습니다. argv에 담긴 비밀은 셸 히스토리와 ps 출력으로 새어 나갑니다. 자격 증명은 요청 호스트가 정확히 raw.githubusercontent.com 또는 api.github.com일 때만 붙으며, 접미사 비교가 아니라 대소문자 구분 없는 완전 일치로 판단하므로 --url로 지정한 미러나 raw.githubusercontent.com.evil.test 같은 유사 호스트에는 절대 전달되지 않습니다. 토큰은 로그·출력·--json 보고서 어디에도 남지 않고, 보고서에는 값 대신 어느 변수에서 읽었는지만 담깁니다. --url에 담긴 basic 인증 정보도 같은 방식으로 소스를 출력하는 모든 지점에서 제거하므로, 비공개 미러의 비밀번호가 사람이 읽는 보고서나 --json의 source_url 필드, 오류 메시지에 남지 않습니다. 404는 이제 두 원인을 뭉뚱그리지 않습니다. 자격 증명이 없으면 세 변수 이름과 함께 저장소가 비공개일 수 있다고 알리고, 자격 증명이 있으면 ref나 경로가 없거나 토큰에 접근 권한이 없을 수 있다고 알립니다. --ref로 브랜치나 태그에 고정하고, --url로 미러나 폐쇄망 설치용 소스를 지정하며, --json은 같은 보고서를 객체로 냅니다. 보고서에는 소스 URL, 해석된 ref, 대상 경로, 이전과 이후 모델 수, 추가·제거된 모델 id, 백업 경로, 두 해시가 담기고, 파일을 갱신해도 실행 중인 라우터가 핫 리로드하지 않는다는 안내가 함께 나옵니다. 새로 추가한 crate 의존성은 없습니다. man 페이지, 국문·영문 configuration 가이드, configuration-assistant CLI 빠른 참조에 문서화했습니다.

변경됨

  • 릴리스 컨테이너 이미지를 ghcr.io와 함께 cr.backend.ai/product/continuum-router에도 올립니다(#1068). Debian·Alpine 멀티아치 태그를 한 번의 빌드 단계에서 두 레지스트리에 모두 푸시하고 마지막 imagetools create는 Harbor에 있는 매니페스트를 소스로 삼습니다. 그래서 ghcr 쪽 상태와 무관하게 Harbor가 확실히 가지고 있는 매니페스트에서만 태그가 만들어집니다. 다운스트림 배포는 ghcr 가용성이나 사용자별 PAT에 기대지 않는 소스를 얻게 됩니다. ghcr 쪽 동작은 그대로입니다.

수정됨

  • 스트리밍 출력 게이트를 모든 스트리밍 경로에서, 각 경로의 wire 형식 그대로 실행합니다(#1087, #1076 해결). StreamingOutputGate를 실제로 만드는 지점은 OpenAI chat 스트리밍 핸들러 하나뿐이었고, 그래서 나머지 스트리밍 표면에서는 출력 guardrail이 조용히 동작하지 않았습니다. 도달 가능한 다섯 갈래 전부의 /anthropic/v1/messages, SSE 파이프라인을 지나는 Gemini, 미드스트림 폴백, /v1/responses의 네 가지 스트리밍 전략, thinking 패턴 변환, Bedrock runtime·converse, Unix 소켓 스트리밍 생성자 전부, responses_only chat 브리지가 그랬습니다. 이제는 각 경로마다 클라이언트가 실제로 받는 것을 검사합니다. 새 Anthropic wire 드라이버는 차단 시 형식에 맞는 Anthropic 종료 시퀀스를 만들고(열린 블록 닫기, 거부 텍스트 블록, message_delta, message_stop, block_behavior: error에서는 error 이벤트 하나), SSE 파이프라인에는 관측 탭과 캐싱 탭 사이에 출력 게이트 단계가 들어가면서 캐싱 탭에 공유 거부권이 생겨 차단·마스킹된 스트림은 저장되지 않습니다. MidStreamFallbackContext는 폴백을 몇 번 거치든 클라이언트 스트림당 게이트 하나를 들고 다니므로, 백엔드가 바뀌는 지점을 가로질러 보유한 텍스트도 하나의 완성으로 검사합니다. 이때 정책 차단은 폴백 재시도를 부르지 않고 스트림을 완료 처리합니다. Responses 패스스루 갈래는 response.output_text.done과 response.completed 미러 이벤트를 종료 시점 검사 뒤로 미루고 게이트가 보는 클라이언트 노출 텍스트로 다시 씁니다. 그래서 델타에서 가린 내용이 미러 이벤트로 새지 않습니다. 게이트를 만들 때 전역 guardrails.mode를 그대로 박아 쓰던 것도 고쳤습니다. 공용 build_streaming_gate가 경로별 mode와 경로별 enabled, bypass_api_keys 허용 목록을 함께 해석하므로, 전역이 monitor인데 경로에서 enforce로 덮어쓴 경우 이제 실제로 차단하는 게이트를 받습니다. 예전에는 기록만 남고 차단은 되지 않았습니다. 비활성 경로나 우회 키에는 게이트를 아예 만들지 않으므로, 모든 검사를 Allow로 단축하면서 스트림 전체를 지연시키던 게이트도 사라졌습니다. stream_with_auto_backend_selection 하나만 범위 밖으로 남겼고(라이브러리 전용이라 어떤 HTTP 라우트도 등록하지 않습니다), 그 판단을 rustdoc에 적고 임베더가 guardrail을 켠 채 게이트 없는 갈래에 도달하면 프로세스당 한 번 경고합니다. 새로 게이팅된 표면에서도 monitor 모드는 스트림당 출력 provider 호출 한 번을 쓰며, 이 비용은 guardrail 가이드에 적었습니다.
  • 스트리밍 출력 경로에서 Transform 판정을 실제로 적용해, 기본 트래픽 형태에서 출력 PII 마스킹이 조용히 무력화되던 문제를 고쳤습니다(#1075, #1074 해결). finalize와 run_window_check가 모든 판정을 is_block()으로 뭉개는 바람에, mask 액션이 내놓는 Transform이 허용 갈래로 떨어져 원본 평문 청크가 그대로 나가고 마스킹된 텍스트는 버려졌습니다. 같은 요청도 비스트리밍이면 제대로 마스킹됐습니다. 이제 보유 중인 청크를 가려진 텍스트로 다시 써서 내보냅니다. 소스 형태는 청크 단위로 판별하므로 게이트 하나가 OpenAI choices[].delta.content와 네이티브 Anthropic content_block_delta 페이로드를 모두 처리하고 프레이밍 이벤트(role 시작, 도구 호출 델타, finish_reason, usage, message_start, ping, message_stop, [DONE], 파싱되지 않는 것 전부)는 바이트 단위로 그대로, 순서도 그대로 지나갑니다. buffer_full에서는 완성 전체를 들고 있으므로 완전한 가림이 됩니다. chunked에서는 마스킹된 윈도우에서 이미 내보낸 컨텍스트와의 최장 공통 접두사를 떼고 내보낸 뒤, 누적 버퍼의 검사 완료 구간에도 그 결과를 덮어씁니다. 이 덮어쓰기가 없으면 다음 윈도우의 컨텍스트에 원본이 다시 들어가 provider가 두 번 가리고, 클라이언트는 자리표시자가 중복된 텍스트를 보게 됩니다. 내장 PII provider는 이제 trait 기본 구현 대신 check_streaming_chunk를 직접 구현합니다. 기본 구현은 모든 청크에 Allow를 돌려주므로 streaming_mode: chunked에서는 provider가 사실상 놀고 있었습니다. streaming_stream_first: true와 enforce + streaming_mode: passthrough는 검사 전에 내보내므로 마스킹 판정이 앉을 자리가 없습니다. 이때는 이미 보낸 텍스트를 그대로 두고 스트림당 한 번 경고하며 가린 사본을 다시 보내지는 않습니다. OpenAI n > 1에서는 가려진 텍스트가 choice 0으로 모입니다. 문서에 적어둔 대로 다중 choice 스트림에서는 형태가 망가지지만 유출되지는 않습니다.
  • 4 MiB 게이트 버퍼 상한을 넘어서도 스트리밍 출력 검사를 계속합니다(#1082, #1078 해결). 상한에 닿으면 게이트가 열린 채로 실패했습니다. passthrough로 바꾸고, 검사를 한 번도 돌리지 않은 채 들고 있던 것을 전부 흘려보내고, 나머지 스트림도 검사 없이 내보냈습니다. 긴 완성 하나가 그 응답 전체에서 출력 차단과 PII 마스킹을 꺼버렸고, 흔적은 warn! 한 줄이 전부였습니다. 이제 상한에서는 passthrough가 아니라 chunked로 낮춥니다. 들고 있던 전부에 대해 전체 텍스트 검사를 돌리고 판정을 온전히 적용하며(차단이면 스트림을 끊고, Transform이면 보유 청크를 가린 뒤 내보내고, 깨끗한 판정에서만 그대로 내보냅니다), 나머지 구간에서는 stream_first를 강제로 끕니다. buffer_full은 출력을 내보내기 전에 검사한다는 약속 위에서 설정하는 값이기 때문입니다. 검사가 끝난 이력은 뒤쪽 streaming_context_size 윈도우만 남기고 압축하므로, 메모리는 유계로 유지되면서 검사도 계속됩니다. chunked 스트림은 낮추는 대신 압축만 하므로 나머지 동작은 그대로입니다. 새 guardrail_stream_buffer_cap_trips_total{strategy,outcome} 카운터와 경고는 스트림당 한 번만 발생합니다. 나머지 두 사본의 크기를 재려고만 들고 있던 세 번째 전체 사본도 없앴습니다.
  • monitor 모드가 스트리밍 출력을 실제로 관찰합니다(#1086, #1079 해결). monitor는 스트리밍 응답에서 출력 단계 평가를 전혀 하지 않았습니다. StreamingOutputGate::new가 monitor에서 모든 streaming_mode를 Passthrough로 접고(monitor는 보유하거나 끊으면 안 되니 이 자체는 옳습니다), 그 갈래는 기록 없이 전달만 하고 finalize의 passthrough 갈래는 누적 버퍼가 비어 있지 않을 때만 도는 구조였습니다. monitor는 어떤 정책을 강제해도 되는지 판단하는 수단이고 챗 UI와 에이전트에서는 스트리밍이 기본이므로, 한 주 내내 monitor로 돌려 출력 단계 판정이 하나도 없는 것을 보고 트래픽이 깨끗하다고 결론 내릴 수 있었습니다. 이제 monitor일 때는 passthrough 갈래도 누적합니다. 청크를 들고 있는 것이 아니라 추출한 텍스트만 남기므로 보유·지연·절단·재작성이 없고, 종료 시점 검사가 rustdoc과 guardrail 가이드가 이미 약속하던 판정을 냅니다. enforce + streaming_mode: passthrough는 검사도 provider 호출도 없는 경로로 그대로 남습니다. monitor는 상한에서 chunked로 낮출 수 없습니다. 윈도우를 내보내기 전에 검사한다는 것이 곧 스트림을 붙잡는다는 뜻이기 때문입니다. 그래서 4 MiB에서 관찰을 멈추고, 잘린 사실은 guardrail_stream_buffer_cap_trips_total{strategy="monitor",outcome="truncated"}로 셉니다. is_active()는 더 이상 건너뛰기 신호로 안전하지 않으며 rustdoc에도 그렇게 적었습니다. monitor 게이트는 false를 돌려주면서도 모든 청크가 필요합니다. 이전 릴리스가 기록한 monitor 결과에는 아무 정보가 없습니다.
  • 배열 형태의 스트리밍 delta.content를 검사하고 추론 텍스트를 새 옵트인 뒤에서 출력 guardrail 범위로 들입니다(#1083, #1080 해결). 스트리밍 추출기는 비스트리밍 짝이 읽던 배열 형태 delta.content를 건너뛰었습니다. 백엔드를 조사해 보니 지금 그 형태를 내보내는 지원 백엔드는 없으므로, 실제 유출을 막았다기보다 검사의 비대칭을 심층 방어 차원에서 닫은 것입니다. 다만 앞으로 content 파트를 스트리밍하는 백엔드나 프록시가 생기면 출력 단계를 우회하지 않고 검사·마스킹됩니다. 추론과 확장 사고 텍스트는 결정이 아니라 사고로 출력 단계에서 보이지 않았고, 이제 guardrails.inspect_reasoning(bool, 기본 false)로 검사할 수 있습니다. OpenAI 형태 경로의 스트리밍·비스트리밍 reasoning_content와 네이티브 Anthropic 경로의 thinking_delta·thinking 블록이 대상입니다. 기본값을 끈 이유는 추론이 provider가 보는 텍스트 양을 대략 두 배로 늘려 지연이 늘고 네트워크 기반 provider에서는 비용이 늘기 때문이고 비스트리밍 경로까지 함께 덮는 이유는 스트리밍에만 켜면 이 수정이 없애려는 바로 그 비대칭이 되살아나기 때문입니다. 범위는 명시적인 TextScope로 두 경로의 모든 추출기·재작성기·transform 적용 지점에 전달되며 두 범위 각각에서 모든 청크 형태에 대해 추출 가능성과 재작성 가능성이 일치하는지 표 기반 테스트가 검사하므로 양쪽이 다시 어긋날 수 없습니다. configuration-assistant 스키마는 1.28.0에서 1.29.0으로 올렸습니다.
  • guardrail debug 로그에서 판정 페이로드를 가립니다(#1081, #1077 해결). GuardrailService::evaluate가 집계된 판정을 ?aggregated로 남기면서 GuardrailVerdict를 Debug 포맷으로 출력했고, Transform::new_content와 차단의 Block::reason이 통째로 찍혔습니다. 텍스트를 일부러 남기지 않도록 설계한 audit 모듈을 우회하는 셈이었습니다. 이제 두 debug! 지점 모두 redacted() 뷰를 남기며, 여기에는 판정의 형태(variant 이름, 카테고리, 점수)만 있고 페이로드는 없습니다. 내장 PII provider는 변환된 텍스트가 이미 가려진 상태이므로 이 변경은 로그 위생과 심층 방어에 해당하고 실제로 닫는 노출은 두 필드에 원본을 담는 커스텀·자체 호스팅 provider 쪽입니다.
  • test_legacy_routing_section_is_ignored_on_load를 ENV_TEST_MUTEX로 감쌌습니다(#1085, #1084 해결). 이 모듈에서 잠금 없이 환경 민감 config를 읽는 테스트는 이것 하나였고, CONTINUUM_BACKEND_URLS를 설정하는 열두 개 남짓한 형제 테스트와 경합해 CI에서 간헐적으로 실패했습니다. guardrail 작업이 병렬 테스트 스케줄을 흔들면서 드러났습니다. 테스트 전용이고 #1027에서 이 테스트가 들어온 때부터 잠복해 있던 문제입니다.

의존성

  • minor·patch 그룹 6건을 갱신했습니다(#1073). tokio 1.53.0에서 1.53.1, tokio-util 0.7.18에서 0.7.19, clap 4.6.3에서 4.6.4, tokio-stream 0.1.18에서 0.1.19, aws-config 1.9.0에서 1.10.1, libc 0.2.186에서 0.2.189입니다.
  • CI 워크플로의 actions/setup-python을 6에서 7로 올렸습니다(#1072).

v1.15.5 - 2026-07-24

모델이 낸 출력 항목을 그대로 되돌려 보내는 클라이언트에서 /v1/responses의 무상태 멀티턴 도구 호출이 다시 동작하도록 복구하고, 시작 시 리스너 바인딩이 시간 제한 없는 백엔드 프로브에 발목 잡히지 않도록 했습니다.

추가됨

  • 백엔드를 프로브하기 전에 리스너를 바인딩할 수 있도록 health_checks.block_startup(bool, 기본값 true)을 추가했습니다(#1066, #1065 해결). 기본값에서는 시작 절차가 이전과 바이트 단위로 동일합니다. 연결 예열과 첫 헬스체크 라운드가 인라인으로 실행되며 리스너 바인딩을 막습니다. false로 두면 리스너가 먼저 바인딩되고 두 단계는 완료를 로그로 남기는 백그라운드 tokio::spawn 태스크에서 실행되므로, /health와 /v1/models가 즉시 응답하는 동안 백엔드별 상태는 admin·모델 엔드포인트를 통해 수렴합니다. 오케스트레이터가 준비 상태 제한 시간(Backend.AI GO는 30초) 안에서 /health를 폴링하는데 도달 불가능하거나 응답이 멈춘 백엔드 하나가 바인딩 전 대기 구간을 그 한계 너머로 밀어낼 때 문제가 되던 상황입니다. /health는 백엔드 상태를 보고한 적이 없는 라우터 생존 확인용 프로브이므로 백그라운드 예열 중에도 의미가 유지됩니다. 국문·영문 health-and-caching·configuration·deployment 가이드, 예제 config, configuration-assistant 안내(스키마 1.26.0에서 1.27.0)에 문서화했습니다.

수정됨

  • 되돌려 보낸 Responses 입력 항목이 변환 가능한 상태로 유지되어, 변환 백엔드에서 무상태(store: false) 멀티턴 도구 호출이 다시 동작합니다(#1064, #1063 수정). v1.15.0 회귀(#1051)가 평범한 id와 status를 베타 필드 phase·agent·caller와 함께 네이티브 전용 메타데이터로 취급했기 때문에, 모든 OpenAI 호환 클라이언트가 (모델이 발급한 항목 id까지 포함해) 그대로 되돌려 보내는 message·function_call·function_call_output 항목이 불투명한 passthrough 값이 되었고, convert_to_anthropic과 convert_to_gemini가 요청 전체를 Request field 'native Responses input item' ... would lose it로 거부했습니다. 이제 정확히 보존하는 판정은 실제로 손실이 발생하는 베타 필드에서만 발동하며, 길이가 제한된 표준 id 문자열과 인식되는 status 값만 버려도 되는 재전송 메타데이터로 간주합니다. 지나치게 크거나 형식이 잘못됐거나 알 수 없는 메타데이터는 바이트 단위로 보존되고 네이티브 전용으로 묶이며 요청 크기 검증에 계산되므로, 변환은 여전히 fail-closed로 동작합니다.
  • 되돌려 보낸 reasoning 항목이 바이트 단위로 충실하게 직렬화됩니다(#1064). reasoning 입력 항목의 encrypted_content·content·status는 값이 없으면 직렬화를 건너뛰므로, 전달되는 항목에 OpenAI가 Unknown parameter: 'input[N].status'로 거부하는 명시적 null이 붙지 않습니다.
  • 시작 시 헬스체크 경로에서 설정된 health_checks.timeout을 요청별 타임아웃으로 적용합니다(#1066). HealthChecker::with_shared_client는 라우터의 공유 reqwest 클라이언트를 재사용하는데 그 전역 타임아웃은 600초 스트리밍 총합이므로, 연결은 수락하고 응답이 멈춘 백엔드가 헬스체크 라운드를 최대 10분까지 붙잡을 수 있었습니다. 이제 공유 클라이언트와 전용 클라이언트 두 생성 경로 모두에서 모든 검사에 시간 제한이 걸립니다. Duration은 설정 락에서 읽은 뒤 요청 전에 가드를 해제하므로 await 구간에 락을 잡고 있지 않습니다.
  • 일반 연결 예열 HEAD 요청에 10초 상한을 적용해(#1066) 공유 클라이언트의 600초 천장을 물려받지 않도록 했습니다. Anthropic 분기가 이미 쓰던 상한과 같은 값입니다.

v1.15.4 - 2026-07-23

표준 blocking 모드 인증에서 Anthropic 고유의 x-api-key 헤더를 수용하고, hub 정책 해시·guardrail 우회 허용 목록·키별 통계가 모두 동일한 제시 자격증명을 기준으로 동작하도록 했습니다.

수정됨

  • 표준 blocking 모드에서 x-api-key를 수용합니다(#1062, #1061 해결). blocking 모드에 api_keys가 설정된 상태에서 동적 인증 미들웨어의 표준(비 AppProxy) 경로는 Authorization: Bearer만 읽었기 때문에, 유효한 키를 x-api-key로 제시하는 Anthropic 네이티브 클라이언트(Anthropic SDK, ANTHROPIC_API_KEY만 설정한 Claude Code)가 401을 받았습니다. 공유 헬퍼 extract_presented_api_key(Bearer 우선, x-api-key 폴백, 공백 제거, 원본 자격증명은 로깅하지 않음)가 blocking·permissive 두 모드 모두에서 미들웨어에 자격증명을 공급하고, AppProxy ROUTER 키 추출은 이 헬퍼에 위임하며, Anthropic messages·count-tokens·models 핸들러는 x-api-key를 다시 요구하는 대신 미들웨어가 붙인 인증 컨텍스트에서 인증된 사용자를 얻습니다. 핸들러의 조기 반환이 제거되면서, 인증된 요청이라도 anthropic-version 헤더 형식이 잘못됐으면 조용히 통과하는 대신 400을 받습니다.
  • 모든 자격증명 소비자가 인증 미들웨어가 검증한 제시 자격증명을 기준으로 동작합니다(#1062). control-plane hub 키 해시는 공백이 덧붙은 Bearer 자격증명을 인증 경로와 동일하게 트리밍하므로, 공백을 덧붙인 Authorization 헤더로 인증은 통과하면서 hub의 rate·예산·티어 집행과 사용량 귀속을 회피하는 일이 불가능해졌습니다. guardrail 우회 허용 목록(비스트리밍·스트리밍·Responses 전용 경로)과 키별 통계 귀속도 같은 제시 자격증명을 읽으므로, x-api-key만 쓰는 클라이언트가 우회와 통계를 올바르게 적용받고, 요청이 다른 Bearer 키로 인증됐을 때 우회 키와 우연히 일치하는 미인증 x-api-key가 우회를 발동시킬 수 없습니다.

v1.15.3 - 2026-07-22

자체 호스팅 guardrail 분류기에서 Qwen3Guard 판정을 실제로 강제하고, AppProxy 기능 그래프를 Admin API에서 분리하며, Chat-to-Responses 브리지에서 도구 호출 인자가 비는 문제를 고치고, rmcp 2.x 마이그레이션을 포함해 의존성 다섯 개를 갱신했습니다.

추가됨

  • 자체 호스팅 분류기에 네이티브 Qwen3Guard 템플릿을 추가해, 호환되지 않는 파서 탓에 조용히 허용되던 구조화 평문 판정을 실제로 강제하도록 했습니다(#1060, #1059 해결). 이 템플릿은 Safe·Unsafe·Controversial 결과에 대해 줄 단위로 제한된 Safety:·Categories: 출력을 파싱하고, Qwen의 공식 분류 체계를 대소문자 구분 없이 매핑된 임계값으로 대응시키며(인젝션 양성은 jailbreak로 분류), 차단하지 않는 flag를 기본값으로 하는 controversial_action: flag | block | allow를 제공합니다. 잘못된 출력은 적용 중인 fail-open/fail-closed 정책을 따릅니다. 분류기 옵션·Qwen 파싱·테스트는 각각 500줄 미만의 모듈로 분리했고, 설정은 국문·영문 guardrail 가이드와 예제 config, configuration-assistant 안내에 문서화했습니다.

변경됨

  • AppProxy 기능 그래프가 Admin API를 전이적으로 활성화하지 않고 독립적으로 컴파일되도록 했습니다(#1058, #1053 해결). 프로세스 전역 config 변경 잠금은 infrastructure::config로, CIDR 기반 IP 허용 목록 평가는 기능 독립적인 HTTP 미들웨어 헬퍼로 옮기면서 둘 다 호환 재익스포트를 남겼고, appproxy-common은 더 이상 admin을 활성화하지 않습니다. 그래서 --no-default-features AppProxy 빌드는 Admin 라우트 트리를 컴파일하지도 마운트하지도 않으며, 공식 릴리스 빌드는 여전히 기본 full 기능 집합으로 Admin을 받으므로 동작이 바뀌지 않습니다. 독립 appproxy-common·appproxy-router·control-plane,appproxy-router 검사를 CI에 추가했습니다.

수정됨

  • Responses 스트림이 완전한 JSON을 response.output_item.added나 done 이벤트에서만 제공할 때 함수 호출 인자를 보존해, Chat Completions 도구 호출이 빈 arguments 문자열로 클라이언트에 도달하지 않도록 했습니다(#1057, #1056 해결). 변환기는 출력 인덱스별로 output_item.added에서 관측한 인자를 버퍼링하고, 각 도구 호출에 이미 전달한 델타를 추적하며, 종료 이벤트를 조정할 때 접두사가 호환되는 가장 긴 누락 접미사만 내보냅니다.

의존성

  • rmcp를 1.8에서 2.2로 올리고 MCP 리소스 목록을 주석 없는 Resource·ResourceTemplate 빌더로 마이그레이션했습니다. RawResource·RawResourceTemplate·AnnotateAble·no_annotation()이 2.x API에서 제거됐기 때문입니다. tower-http는 0.7(cors 레이어), object_store는 0.14(s3-cache), aws-smithy-eventstream은 0.61(bedrock-sigv4), tokio-tungstenite는 0.30(control-plane 정책 스트림)으로 올렸습니다.

v1.15.2 - 2026-07-21

Windows 릴리스 바이너리를 MSVC C 런타임을 정적 링크한 상태로 배포해 깨끗한 Windows 설치본에서도 실행되도록 하고, 의존성 트리를 갱신했습니다.

수정됨

  • x86_64-pc-windows-msvc 릴리스 빌드에서 MSVC C 런타임을 정적 링크하여, 배포되는 continuum-router.exe가 더 이상 Visual C++ 재배포 패키지를 필요로 하지 않도록 했습니다(#1055). v0.23.1 이후 모든 Windows 릴리스는 CRT를 동적 링크했기 때문에, VC++ 2015-2022 재배포 패키지가 없는 Windows 머신에서는 VCRUNTIME140.dll 누락 오류로 실행되지 않았습니다. 이제 릴리스 워크플로가 Windows 타깃을 -C target-feature=+crt-static로 빌드하며, 이 잡은 명시적 --target으로 빌드하므로 해당 플래그는 최종 바이너리에만 적용되고 호스트 빌드 스크립트나 proc-macro에는 적용되지 않습니다. macOS와 Linux 빌드는 변경되지 않았습니다.

의존성

  • 의존성 12개를 한 그룹으로 마이너·패치 업데이트했습니다(#1052). tokio 1.52.3→1.53.0, redis 1.3.0→1.4.1, uuid 1.23.5→1.24.0, serde 1.0.228→1.0.229, serde_json 1.0.150→1.0.151, thiserror 2.0.18→2.0.19, async-trait 0.1.89→0.1.91, clap 4.6.1→4.6.2, futures-util 0.3.32→0.3.33, fastrand 2.4.1→2.5.0, regex 1.13.0→1.13.1, toml 1.1.2→1.1.3을 포함합니다.

v1.15.1 - 2026-07-20

OpenAI Chat Completions·Responses API 전반에서 GPT-5 계열을 정합했습니다. 이번 릴리스는 GPT-5.6 모델과 GPT-5 Pro를 등록하고, reasoning_effort를 모델별 정확한 매트릭스에 맞춰 조용한 하향 없이 검증하며, Chat-to-Responses 브리지를 지날 때 호환 가능한 요청 필드를 버리지 않고 보존합니다.

추가됨

  • GPT-5.6 Sol, Terra, Luna 모델과 계열 별칭(alias)을 등록하고 OpenAI 모델 정규화·reasoning-effort 처리에 연결했습니다(#1051). 모델 메타데이터가 새 세대를 다루고, 계열 별칭은 구체 모델로 해석됩니다.
  • GPT-5 Pro를 Responses 전용 모델로 추가하여 /v1/chat/completions가 /v1/responses로 투명하게 브리지되도록 했습니다(#1051). GPT-5 Pro는 업스트림에서 Responses 전용이지만, 라우터는 기존 Chat-to-Responses 브리지를 통해 Chat Completions 호환을 유지합니다.
  • Responses API 스키마를 reasoning context·mode, 텍스트 verbosity, 프롬프트 캐싱, 프로그래매틱 도구 호출, 멀티 에이전트 아이템, 네이티브 replay 메타데이터, safety identifier, context management, 도구 호출 한계까지 확장했습니다(#1051).

변경됨

  • 스트리밍·비스트리밍 Chat Completions 요청 모두에서 OpenAI reasoning_effort를 GPT-5 세대·모델의 정확한 매트릭스에 맞춰 검증합니다(#1051). 원래 gpt-5 계열은 minimal을 받고, GPT-5.1은 none/low에서 시작하며, GPT-5.2부터 GPT-5.5는 xhigh를 추가하고, GPT-5.6은 max를 추가하며, gpt-5-pro는 high만 받습니다. 라우터는 auto, xhigh, max를 다른 effort로 재작성하지 않으며, OpenAI 모델은 더 이상 기존 Gemini 정규화 간섭을 거치지 않습니다.
  • Chat-to-Responses 브리지를 지날 때 flat verbosity, 메타데이터, safety identifier, 프롬프트 캐시 제어를 포함한 호환 Chat 필드를 보존하고, 유효하지 않은 값은 버리는 대신 거부합니다(#1051). 네이티브 OpenAI persisted-response 상태와 beta 헤더는 그대로 유지되고, 로컬 세션 상태는 변환 전략에서만 해석되며 네이티브 previous_response_id 값은 변경 없이 전달됩니다. 프롬프트 캐시 TTL 검증은 문서화된 30m 값을 받습니다.

수정됨

  • 네이티브 전용 Responses 제어가 비네이티브 변환 경로에서 조용히 버려질 상황이면 unsupported_request_parameter로 fail-closed 처리합니다(#1051). GPT-5.6 전용 제어를 담은 네이티브 Responses 요청은 응답 캐싱을 우회하고, 조용히 성능이 떨어지는 대신 변환 전용 백엔드에서 거부됩니다.

호환성 변경 사항

  • OpenAI Chat Completions는 이제 지원되지 않는 모델·effort 조합을 조용히 재작성하는 대신 400 invalid_request_error로 거부합니다(#1051). 이전에 라우터가 범위를 벗어난 reasoning_effort를 조정해 주는 데 의존하던 클라이언트는 명시적 오류를 받으며, 대상 모델이 지원하는 값을 보내야 합니다.

v1.15.0 - 2026-07-20

설정 진실성 점검(configuration truth pass). v1.14.0 트리를 수동 코드 감사한 결과, 스키마가 받아들이고 매뉴얼이 문서화했지만 어떤 프로덕션 코드 경로도 존중하지 않는 설정 knob과 엔드포인트가 발견되었습니다. 이번 릴리스는 각각을 런타임에 연결해 문서화된 동작이 실제로 적용되도록 하고, 매뉴얼이 이미 설명하던 환경 오버라이드와 보간(interpolation)을 추가하며, 문서 정합 감사와 머지 후 하드닝 패스로 마무리합니다.

추가됨

  • 하드코딩된 secret/URL/경로 집합뿐 아니라 모든 문자열 값 config 필드에서 ${VAR} 환경 참조를 보간합니다(#1039, closes #1032). 로더가 파싱된 YAML/TOML 트리를 순회하며 typed 역직렬화 전에 ${VAR}, ${VAR:-default}, $$ 리터럴 달러 이스케이프를 해석하고, 변수 이름 검증과 과도한 값·치환에 대한 경계를 둡니다. .env 파일은 파싱 전에 로드되어 그 값이 보간에 반영되고, 기본값 없는 미설정 필수 참조는 시작과 핫리로드를 경로 인식 오류로 중단시키며, config validate는 미설정 참조를 경고로 보고하고, config show --resolved와 config diff는 보간하며(diff는 secret 이름 필드를 여전히 마스킹), config show와 MCP 표면은 참조를 리터럴로 유지하고 환경 값을 읽지 않습니다.
  • 런타임에 연결된 검증형 model_aggregation config 섹션을 추가했습니다(#1042, closes #1022). 이 섹션은 집계 서비스, 캐시 TTL, fetcher·엔트리 한계, 중복 제거 전략, force-refresh 게이트, 백그라운드 새로고침 작업에 매핑되어 allow_force_refresh와 background_refresh를 기본값에 고정하지 않고 운영자가 제어할 수 있게 합니다. 캐시와 서비스가 시작 시점에 구성되므로 리로드 클래스는 restart입니다. 스키마 카운트가 35로 늘어납니다.
  • 설정 로더가 CONTINUUM_SELECTION_STRATEGY 환경 오버라이드를 읽도록 했습니다(#1037, closes #1023). 문서화됐지만 읽히지 않던 값으로, 이제 로더가 --selection-strategy와 동일한 표기 규칙으로 여섯 전략을 모두 파싱하고, 인식되지 않는 값은 조용히 무시하는 대신 명확한 오류로 설정 로드를 실패시키며, 우선순위는 --selection-strategy > CONTINUUM_SELECTION_STRATEGY > 파일 값 > RoundRobin 기본값입니다.
  • 설정 로더가 네 개의 CONTINUUM_FILES_AUTH_* Files API 인증 환경 오버라이드를 읽도록 했습니다(#1046, closes #1033). CONTINUUM_FILES_AUTH_METHOD, CONTINUUM_FILES_AUTH_SCOPE, CONTINUUM_FILES_ENFORCE_OWNERSHIP, CONTINUUM_FILES_ADMIN_ACCESS_ALL는 문서화됐지만 무동작이었습니다. 이제 files.auth에 적용되고, 파일에 files: 섹션이 없으면 기본 FilesConfig를 구성하며, 두 불리언은 리터럴 소문자 true/false만 받아들이고 1, 0, yes, no, 대소문자 혼용은 명확한 오류로 거부합니다.
  • 라우터가 이미 광고하던 X-Fallback-Attempts 응답 헤더를 실제로 내보냅니다(#1035, closes #1024). 폴백 경로가 응답을 구성하기 전에 시도 횟수를 버렸습니다. 이제 헤더는 primary를 포함한 총 모델 시도 횟수를 보고하고, 폴백 백엔드가 응답을 제공했을 때만 나타나며(최소값 2), 모델별 notify_on_fallback 설정으로 게이팅됩니다. 스트리밍 응답은 본문 전에 헤더를 flush하므로 스트림 중간 복구는 헤더 대신 streaming_fallback_* 메트릭으로 관측 가능합니다.

변경됨

  • 영문·국문 매뉴얼, CLI --help, 생성된 설정 안내, Debian 패키징 텍스트, Docker 예제, 매뉴얼 페이지를 구현된 라우터 표면에 맞춰 정합했습니다(#1034). 사용자 대상 문서에서 역사적·희망적·존재하지 않는 기능 주장을 제거하고 실제 엔드포인트, 기능 게이트, 인증, 폴백, 서킷 브레이커, 환경 확장, model-aggregation, 핫리로드 경계를 문서화했으며, Admin·MCP 핫리로드 메타데이터와 회귀 테스트를 실제 배선에 맞췄습니다. 이 감사가 이번 릴리스의 나머지가 닫는 후속 발견들을 드러냈습니다.
  • Debian Build-Depends Rust 하한을 lablup PPA에 맞춰 1.75에서 1.96으로 올리고 패키징 문서를 정합했습니다(#1021, closes #1020). 잘못된 edition-2024 MSRV 주장(크레이트는 edition 2021), 틀린 Noble 코드명 매핑, 그리고 stock Ubuntu 시리즈는 Rust 1.96을 제공하지 않으니 lablup PPA를 활성화해야 한다는 배포판 안내를 바로잡았습니다.

수정됨

  • 서킷 브레이커 상태 머신을 일반 프록시 트래픽으로 구동합니다(#1044, closes #1028). 브레이커는 구현·설정·핫리로드·Admin 노출까지 되어 있었으나 어떤 LLM 프록시 요청도 이를 구동하거나 참조하지 않아 활성화해도 라우팅 경로를 보호하지 못했습니다. 이제 선택 단계에서 열린 서킷을 제외하고, 디스패치 전 게이트가 승인/거부하며, 종료 결과를 Chat, Completions, Responses, Embeddings, Images, Rerank와 모든 폴백 홉 및 표준 스트리밍에 걸쳐 기록합니다. 업스트림 5xx와 설정된 failure_status_codes는 서킷을 열고, 타임아웃은 timeout_as_failure를 존중하며, 전송 오류는 무조건 실패이고, 클라이언트 측 4xx는 기록하지 않으며, 승인 거부는 재시도 가능한 오류가 되어 선택과 폴백이 표준 503을 반환하기 전에 다른 백엔드로 진행합니다.
  • retry.*와 timeouts.* 핫리로드 개정을 요청 실행에 적용합니다(#1045, closes #1029). 둘 다 핫리로드 가능으로 분류됐지만 요청 경로가 시작 스냅샷만 읽어, 리로드가 설정을 바꾸고 성공을 보고하면서도 실효 동작은 시작 값에 머물렀습니다. 이제 각 요청 표면이 진입 시점에 라이브 정책과 타임아웃 예산을 스냅샷해 진행 중 요청마다 일관된 정책 하나를 유지합니다. reqwest 클라이언트의 연결·전체 타임아웃은 클라이언트 구성 시점에 고정되므로 restart 전용으로 남습니다.
  • 라이브 selection_strategy와 prefix_routing.load_factor_epsilon 변경을 실행 중인 BackendPool에 적용합니다(#1038, closes #1025). watcher가 감지한 전략 변경이 적용됐다고 광고됐지만 HotReloadService가 라이브 풀에 밀어넣지 않아 라우팅이 시작 전략을 유지했습니다. 이제 둘 다 lock-free 셀로서 백엔드 멤버십, 백엔드별 통계, in-flight 회계, 해시 링 안전성을 보존하며 원자적으로 교체되고, Admin·MCP·문서 표면이 이를 즉시(immediate)로 보고합니다.
  • 받아들여지지만 무동작이던 prefix_routing.virtual_nodes와 anthropic_cache_control_injection knob을 런타임에 연결합니다(#1041, closes #1026). 이제 virtual_nodes가 consistent-hash 링 크기를 정하고(변경 시 캐시 재구성), anthropic_cache_control_injection이 fleet 전역 마스터 스위치로 자동 Anthropic cache-control 주입을 게이팅하며, 둘 다 핫리로드 가능하고 /admin/prefix-routing/stats가 활성 풀 값을 보고합니다.
  • 공유 API 키 저장소를 Admin 인증에 주입하여 admin.auth.method: api_key가 동작하도록 수정 (#1036, closes #1030). Admin 라우트가 AdminAuthState::without_api_key_store(...)로 마운트되어 모든 api_key 요청이 빈 저장소 분기에 걸려 500 Internal Server Error를 반환했습니다. 이제 Admin API 키 인증은 일반 /v1 인증 및 Admin 키 관리와 동일한 저장소로 검증하고(Authorization: Bearer와 X-API-Key 모두 허용), required_scope를 강제하며, 선택적 allowed_ips를 존중합니다. 키를 비활성화, 회전, 만료, 삭제하면 재시작 없이 즉시 인증에서 차단됩니다.
  • POST /admin/config/apply를 정직하게 수정 (#1047, closes #1031). 이 엔드포인트는 자리표시자였습니다. 변경 없는 현재 설정을 새 기록 버전으로 기록하고, config_sender로 발행하지 않았으며, 아무것도 트리거하지 않고도 hot_reload_triggered: true를 반환할 수 있었습니다. 이제 선택적 전체 config 후보를 받습니다. 후보가 없으면 명시적 no-op(기록 버전 없음, hot_reload_triggered: false)이고, 후보가 있으면 검증한 뒤 실행 중인 설정과 비교하여 차이가 있을 때만 수정 잠금 아래에서 원자적으로 발행합니다. hot_reload_triggered는 변경된 설정을 성공적으로 발행한 뒤에만 true이고, updated_sections/requires_restart는 실제 diff를 반영하며, hot_reload: false는 적용 없이 diff를 미리보기하고, 후보 없는 반복 호출은 빈 기록 버전을 만들지 않습니다.
  • --features embed에서 src/errors.rs가 빌드되도록 metrics::security 스텁을 추가했습니다(#1048). 항상 컴파일되는 오류 경로가 metrics 기능 뒤에 있는 crate::metrics::security::truncate_at_char_boundary를 호출하는데, embed 스텁 모듈에 실제 UTF-8 경계 안전 절단 로직을 담은 서브모듈을 추가했습니다. embed 기능은 CI 매트릭스에 없어서 (#1017이 유발한) 이 파손은 off-feature 빌드 매트릭스를 로컬에서 돌릴 때에만 잡혔습니다.
  • capacity 정수 전용 하트비트 단언을 capacity 필드로 한정했습니다(#1040). 릴리스 feature-set CI 잡이 1.14.0 이후 결정적으로 실패했는데, 직렬화된 하트비트 전체에서 .0을 검사하다 router_version "1.14.0"의 정당한 dot-zero에 걸렸기 때문입니다. 이제 세 개의 observed_*_capacity 값이 JSON 정수로 직렬화되는지만 확인합니다.
  • 위 설정·라우팅 변경들에 대한 머지 후 교정을 통합했습니다(#1050). 서킷 브레이커 승인·half-open 회계·핫리로드를 일관되게 만들고, 재시도 정책을 스냅샷하고 해시 링 세대를 하드닝하며, 완전한 Admin config 카탈로그를 진실한 리로드 메타데이터와 함께 노출하고, 모든 스트리밍 전송을 다루면서 설정 문서를 동기화했습니다.

제거됨

  • 무동작이던 레거시 routing config 섹션을 제거했습니다(#1043, closes #1027). 스키마가 이를 받아들이고 문서가 고급 라우팅 오버라이드로 설명했지만 어떤 프로덕션 코드도 읽지 않아, routing을 설정해도 검증은 통과하되 그 값 중 어느 것도 트래픽에 영향을 주지 않았습니다. 라이브 선택은 최상위 selection_strategy, 백엔드별 weight/models, smart_routing을 씁니다. 최상위 routing: 키를 여전히 가진 설정은 계속 로드되고(알 수 없는 키는 무시), 로더와 config validate는 이제 조용히 무동작하는 대신 라이브 설정을 가리키는 마이그레이션 경고를 내보냅니다. 스키마 카운트는 34로 돌아가고 schema_version은 1.26.0이 됩니다.

v1.14.0 - 2026-07-19

추가됨

  • 임베디드 WebUI를 완전한 관리 콘솔로 개편했습니다(에픽 #931). 단일 파일 SPA에 Alpine.js, uPlot, 사전 빌드한 Tailwind 스타일시트를 벤더링하고 CDN 의존성을 모두 제거해 엄격한 default-src 'self' CSP 아래 에어갭 환경에서도 동작하며, 페이지별 모듈 레지스트리로 재구성했습니다(#948). 자격증명 검증과 세션 UX를 갖춘 로그인 흐름이 콘솔을 게이팅하고(#949), GET /admin/capabilities 엔드포인트가 페이지 단위 기능 게이팅을 구동해 비활성 서브시스템 패널이 깨지는 대신 자연스럽게 비활성화됩니다(#950). 새 관리 페이지는 가드레일(#951), 캐시 운영(#952), 스마트 라우팅(#953), 사용량 분석(#954), 프롬프트(#955), 파일(#956), 카탈로그·별칭·폴백 체인을 담은 모델·라우팅(#957), 온디맨드 헬스 프로브를 갖춘 백엔드 상세 편집 폼(#958), diff 미리보기와 핫리로드 표시를 갖춘 스키마 기반 config 폼(#960), 컨트롤 플레인·ACP·AppProxy 통합 상태(#961)를 다룹니다. diff에 표시된 삭제 항목을 소리 없이 누락시키는 config PATCH 저장을 차단하고(#962), API 키에 허용 목록·주석·사용량 드릴다운을 더했으며(#963), 대시보드에 추세·업타임·서킷 상세를 추가하고(#964), 커맨드 팔레트·키보드 단축키·반응형 레이아웃·접근성 개선을 반영했습니다(#965, #966, #1004).
  • 모든 LLM API에 걸쳐 typed 운영자 요청 파라미터 정책을 추가했습니다(에픽 #990). 프로토콜 독립 정책 엔진(#996)이 일곱 canonical 파라미터(temperature, top_p, max_tokens, presence_penalty, frequency_penalty, top_k, min_p)에 대해 운영자가 정의한 기본값·오버라이드·최소/최대 클램프를 typed 값과 검증된 범위로 적용하며, config로 노출하고(#1006) Chat Completions·Completions 프록시 경로(#1007), 네이티브 Anthropic Messages 경로(#1008), Responses API(#1009)에서 강제합니다. 변형은 값이 없는 결정적 메타데이터를 내보내고 잘못된 정책과 잘못된 요청 필드를 구분합니다. 감사 후 런타임 경로와 로컬 경계 보존 보장을 하드닝해 Hub 티어 제한이 로컬에 설정된 경계를 더 좁힐 수는 있어도 넓힐 수는 없도록 했습니다(#1013, #1014, #1015).
  • Continuum Hub 티어 요청 파라미터 제한을 Router 데이터 플레인에서 강제합니다(#1002, Hub #299 / PR #314 및 #301 / PR #319). 버전 0을 유지하는 추가형 와이어 계약은 일곱 canonical 파라미터의 정수 마이크로단위 경계, 로컬 별칭 해석 후 정확한 모델 범위, canonical 공개 티어 digest, typed capability/active/rejection 증거를 전달합니다. 전체 엔벨로프는 하나의 원자적 last-known-good 교체 전에 검증·변환되며 잘못되거나 오래됐거나 digest가 맞지 않는 후보는 부분 적용되지 않습니다. 인증된 Hub 키는 Completions, Chat Completions, Responses, Anthropic Messages에서 캐시 identity·치환·arbitrage·백엔드 선택·재시도·폴백 전에 Hub와 배포 로컬 제한의 가장 좁은 교집합을 한 번 고정합니다. 권위 있는 clear, 혼합 버전 Hub, feature-off/default 빌드, Hub 장애의 기존 동작을 보존하며 로그·메트릭·하트비트 상태는 값 없이 유지됩니다. vendored protocol은 Hub PR #319 merge 59387265e755bf02a9dbbb238da369e36d566d49에 고정됩니다.
  • 아웃바운드 전용 컨트롤 플레인 에이전트를 통해 Continuum Hub 플릿 요청 파라미터 설정을 적용합니다(#1001, Hub #302 / PR #317). 별도 옵트인인 control_plane.config_sync.enabled는 값이 제거되고 상한이 정해진 structured request_params 기능을 광고하고, 리비전에 묶인 공개 scalar content를 가져와 Hub 호환 canonical digest와 전체 effective config를 검증하며, 명시적인 로컬 pin 아래에 Hub leaf를 계층화한 다음 하나의 원자적 hot-reload snapshot을 게시합니다. Dry run, rollback revision, 중복 전달, ack 유실, 재연결, 재시작은 비공개 last-known-good 상태/outbox를 통해 멱등적으로 처리됩니다. 일시적인 Hub 실패는 terminal rejection이 되지 않고, 진행 중 요청은 이미 잡은 config를 유지하며, snapshot·로그·apply result에는 값이 들어가지 않습니다. vendored protocol은 버전 0에서 추가형 호환성을 유지하고 Hub PR #317 head 771591f에 고정됩니다.
  • Continuum Hub 인벤토리에 백엔드별 관측 RPM, 입력 TPM, 출력 TPM 추정치를 추가했습니다(#972). 컨트롤 플레인 에이전트는 확인된 일시적 업스트림 429가 있을 때만 60초 롤링 처리량 관측치를 용량으로 승격하고, 최소 두 건의 성공 완료를 포함한 온전한 윈도를 요구하며, 각 토큰 차원은 모든 완료 건의 토큰 계측이 갖춰져야 내보냅니다. 추정치는 15분 뒤 만료되는 최고 관측값으로 유지됩니다. 콜드 스타트, 유휴 상태, 가벼운 부하, 불완전한 토큰 계측, 할당량 소진, 오래된 백엔드는 0을 보고하는 대신 필드를 비워 두며, 관측 용량은 허브에 저장된 운영자 선언 용량을 읽거나 합치지 않습니다. 같은 선택적 추정치는 컨트롤 플레인 빌드의 /admin/stats/backends에서도 확인할 수 있습니다.
  • 라우터 관리 Gemini 컨텍스트 캐싱을 추가했습니다(#929, #928 종료). gemini_context_cache.enabled가 켜지면 type: gemini 백엔드로 보내는 크고 안정적인 시스템 프롬프트 접두사를 Google cachedContents 리소스로 캐싱하고 요청 간 재사용해, OpenAI 호환 클라이언트에는 투명하게 캐시 토큰 할인을 보장하고 큰 고정 시스템 프롬프트를 반복 전송하는 워크로드의 prefill 지연을 줄입니다. 기존 Anthropic cache_control 주입을 compat·네이티브 Gemini 양쪽 표면에서 그대로 따르며, (백엔드, 모델, 접두사 digest)로 키잉하고 single-flight 생성, API가 거부한 접두사의 네거티브 캐싱, LRU 축출, 히트 시 TTL 연장을 갖춥니다. 기본값은 꺼짐입니다. 명시적 캐싱은 토큰-시간당 저장 비용이 청구되므로 비활성 시에는 맵 할당·백그라운드 작업·요청당 지연이 전혀 없는 완전한 no-op입니다. continuum_gemini_context_cache_* 메트릭 계열이 요청·생성·실패·엔트리·축출·캐시 토큰을 보고합니다.
  • 비용 상한이 걸린 Hub 동기화 합성 provider/model 프로브를 실행합니다(#969, #1019). control-plane 피처가 컴파일되고 control_plane.enabled, control_plane.policy.enabled, 그리고 대상이 있는 활성 Hub ProbePolicy가 모두 성립할 때, 에이전트는 나열된 (provider, model) 대상마다 정책 주기에 맞춰 내부 Backend::execute_probe_chat_completion 지점을 통해 비스트리밍 프로브 호출 한 건을 실행합니다(정상 변환과 실효 인증, 구조화된 결과, 치환·폴백·캐시·재시도 없음, UsageEvent 없음). 이는 control_plane.state_file 옆의 소유자 전용 사이드카에 지속되는 라우터별 월간 예산 상한으로 제한됩니다(0은 무제한, 양수 상한은 디스패치 전에 예약하고 가격·상태가 없으면 fail-closed). 결과는 안정적인 report_id/probe_id 재전송과 구형 허브를 위한 비치명적 rate-limited 404 경로로 POST /api/agent/v1/probes에 게시됩니다. config 필드는 추가되지 않으며 게이트 하나라도 꺼지면 프로브 동작·비용·와이어 트래픽이 전혀 없습니다.
  • Continuum Hub 인벤토리에 백엔드별 서빙 모델(#920), 백엔드별 인증·할당량 거부 카운터(#925), 토큰 공급·포화 텔레메트리(#968)를 보고하고, 인식되지 않은 키에 대해 fail-open 대신 fail-closed하는 옵트인 policy.unmatched_key 거부 strict 모드를 추가했습니다(#923). 모두 control-plane 피처 뒤에 있으며 PROTOCOL_VERSION은 그대로입니다.
  • 컴파일 타임 Cargo 피처 플래그와 런타임 활성 서브시스템을 하나의 읽기 전용 응답으로 보고하는 GET /admin/capabilities 엔드포인트를 추가했으며, 기존 관리자 인증·감사 미들웨어가 게이팅합니다(#950).

변경됨

  • 의존성 12개를 한 그룹으로 마이너·패치 업데이트했습니다(#976). regex 1.12.4→1.13.0, aws-config 1.8.18→1.9.0, http-body 1.0.1→1.1.0, rust-embed 8.11.0→8.12.0과 uuid, bytes, lru, socket2를 포함합니다.

수정됨

  • 라우터 관측성 에픽을 위해 실시간 Prometheus 메트릭 소스를 연결하고 레거시 컬렉터를 정리했습니다(#973; #982, #986, #993). 등록만 되고 기록되지 않던 메트릭 계열에 프로덕션 기록 지점을 붙이고, 레거시 lazy_static 카운터를 최신 메트릭 소스에서 동기화하며, 영문·국문 메트릭 레퍼런스가 내보내는 메트릭과 레이블 이름을 문서화합니다.
  • 컨트롤 플레인 인벤토리를 통해 관측된 백엔드별 용량을 보고하되(#972, #983), 병렬 카운터가 아니라 단일 인플라이트 게이지를 읽습니다.
  • 내부 백엔드를 모든 사용자 대상 API 표면에서 숨겨 backend:로 참조되는 내부 모델이 모델 목록이나 라우팅 후보 집합에 새어 나가지 않도록 했습니다(#985).
  • 에픽 #990 응답 캐시 identity를 하드닝해 서로 다른 실효 요청 간에 캐시 키가 충돌하지 않도록 했습니다(#1018).
  • Unix 소켓 스트리밍 경로에서 업스트림 오류 본문을 노출합니다(#1017, #1016 종료). plain·thinking·Anthropic Unix 소켓 스트림의 비-2xx 백엔드 응답이 이제 남은 요청 타임아웃으로 제한된 8 KiB 상한과 2초 예산 안에서 오류 본문을 드레인해, 백엔드 설명을 버리는 대신 TCP 스트리밍 경로와 동일한 오류 본문 동등성을 확보합니다.
  • 스트리밍 응답에서 Unix 소켓 사용량을 계측해 해당 전송에서 컨트롤 플레인 사용량 레코드가 누락되지 않도록 했습니다(#927).
  • 컨트롤 플레인 관측성과 strict 인증을 하드닝하고(#926), 헬스 동기화 중 이름이 바뀐 백엔드를 정리하며(#921), fail-open 사용량 귀속에 unattributed 센티널을 찍고(#922), 사용량 provider를 오래된 스냅숏이 아니라 이벤트별 실시간 config에서 해석하도록 했습니다(#919).
  • 병합된 구현 감사 후 에픽 #973의 관측성 상태를 하드닝했습니다. 동시 요청의 완료 시각이 순서와 다르게 기록되더라도 오래된 처리량이 남거나 미래 관측값이 노출되지 않도록 용량 샘플을 타임스탬프 순서로 유지합니다. 헬스 체크가 비활성화된 경우에도 백엔드 제거와 같은 이름의 엔드포인트 교체가 이름 기반 용량 및 자격증명 상태를 항상 정리하며, 등록되어 있던 집계 모델 사용량·토큰·최종 타임아웃 카운터에 실제 프로덕션 기록 지점을 연결했습니다. 영문·국문 메트릭 레퍼런스도 실제로 내보내는 메트릭과 레이블 이름에 맞췄습니다.
  • 실제 HTTP 트래픽이 설정된 selection_strategy를 통해 디스패치되도록 수정했습니다(#975). 이전에는 어떤 HTTP 요청 경로도 풀 셀렉터를 호출하지 않았습니다. 채팅 컴플리션, 스트리밍 채팅, Responses API(비스트리밍·스트리밍·compact), Anthropic Messages와 count_tokens, 이미지 생성이 모두 모델 조회 순서상 첫 번째 정상 후보를 골랐고 그 결과 RoundRobin, WeightedRoundRobin, Random, LeastLatency, ConsistentHash, PrefixAwareHash가 실제 트래픽에는 전혀 작동하지 않았습니다. 이제 이 경로들은 하나의 선택 지점을 공유합니다. 각 경로는 자체 사전 필터와 오류 의미론(모델 조회, 내부 백엔드 필터, 키별 허용 목록, 재시도 상태 제외, 그리고 model-not-found / 403 / all-unhealthy 구분)을 그대로 유지하고 공유 지점이 살아남은 후보를 헬스 필터링하므로 비정상 백엔드는 결코 선택되지 않으며, 최종 선택은 새 후보 제한 풀 진입점 BackendPool::select_from_candidates를 거칩니다. 이 진입점은 select_backend_with_context와 전략 코어를 공유하고 컨시스턴트 해시 링 캐시를 참여 백엔드 이름으로 키잉하므로 모델별 후보 부분집합이 각각 올바른 링으로 해싱됩니다. 접두사 인지 라우팅도 끝까지 연결되었습니다. prefix_routing.enabled가 켜지면 요청 본문(OpenAI messages[]와 Anthropic 최상위 system 형태)에서 접두사 키를 추출해 #971의 실제 인플라이트 게이지 위에서 PrefixAwareHash CHWBL 배치를 구동하며, KV 캐시 인덱스가 함께 구성된 경우 시작 시 KvOverlapScorer가 등록됩니다. 트래픽 분배 동작 변경: 기본값이 아닌 selection_strategy를 설정한 배포에는 이제 그 전략이 실제로 적용되고, 기본 배포는 '항상 첫 번째 후보 백엔드'에서 모델별 정상 후보들에 대한 실제 라운드로빈으로 바뀝니다.
  • 백엔드별 인플라이트 추적을 실제 요청 경로에 연결하여 CHWBL 부하 상한이 더 이상 무력하지 않도록 수정했습니다(#971). BackendStats.in_flight_requests에는 프로덕션 기록 지점이 없어 PrefixAwareHash 셀렉터의 bounded-load 절반이 항상 0인 값으로 상한을 계산했고, 한 번도 바인딩될 수 없었습니다. 이제 백엔드 풀이 항상 컴파일되는 RAII 인플라이트 트래커를 소유하고 모든 디스패치 지점(채팅 프록시, 스트리밍 채팅, Responses API, Anthropic Messages와 count_tokens, 이미지 생성, 이미지 편집)에서 기록하며, 스트리밍 가드는 응답 본문에 실려 마지막 프레임 또는 클라이언트 연결 종료까지 유지됩니다. 상한 공식은 배치하려는 요청까지 포함하는 ceil((total_in_flight + 1) * (1 + epsilon) / backend_count)로 바뀌었고, prefix_routing.load_factor_epsilon(기본값 0.25)이 마침내 풀까지 전달되어 문서에 적힌 설정값이 실제로 적용됩니다. 이 게이지가 단일 기준이 되어 관리자 prefix-routing/stats 엔드포인트와 컨트롤 플레인 인벤토리의 백엔드별 active_requests가 같은 값을 읽으며, #968이 만들었던 병렬 컨트롤 플레인 게이지를 대체하고 0 대신 실제 부하를 보고합니다.
  • 적용 범위 참고: 이번 변경은 CHWBL 셀렉터가 읽는 게이지를 고친 것이지, 어떤 코드 경로가 그 셀렉터를 호출하는지를 바꾼 것은 아닙니다. 라우터의 HTTP 요청 경로는 여전히 해당 모델을 서빙하는 첫 번째 정상 백엔드로 디스패치하므로, selection_strategy(CHWBL 포함)는 아직 실제 트래픽 분배에 관여하지 않으며 이 변경으로 라우팅이 달라지는 요청은 없습니다. 풀 셀렉터를 요청 퍼널에 연결하는 작업은 #975로 반영되었습니다(위 항목 참고).
  • Unix 소켓 통합 테스트가 멈추지 않도록 하고 CI에서 복구했습니다(#984, #992, #995).

v1.13.1 - 2026-07-10

추가됨

  • Converse API를 통한 Amazon Bedrock 멀티 프로바이더 지원(#615). type: bedrock 백엔드의 새 endpoint_type: converse는 POST /model/{modelId}/converse[-stream]으로 요청을 보내 Bedrock의 모든 채팅 모델(Claude, Nova, Llama, Mistral, Cohere, AI21 Jamba, DeepSeek)을 하나의 통합 와이어 형식으로 사용합니다. OpenAI 형식 요청을 Converse 콘텐츠 블록으로 변환하고(시스템 프롬프트 추출, toolConfig/toolUse/toolResult, inferenceConfig 샘플링 파라미터), #614의 공유 SigV4 서명 경로로 서명하며, 이진 ConverseStream 이벤트 스트림을 도구 호출/추론 델타를 포함한 OpenAI SSE로 다시 디코딩합니다. 하드코딩된 모델별 기능 매트릭스(채팅, 스트리밍, 도구, 비전, 문서, 가드레일, 프롬프트 캐싱, 추론)가 지원되지 않는 기능 요청을 서명 이전에 기능 이름을 담은 4xx로 거절하고, 벤더 전용 옵션은 extra_body / additionalModelRequestFields로 그대로 전달되며, 원격 이미지 URL은 SSRF 검증과 크기 제한을 거쳐 base64로 인라인되고, Converse 네이티브 본문은 감지되어 이중 변환 없이 전달됩니다. 기존 bedrock-sigv4 Cargo 피처 뒤에 위치합니다.
  • 백엔드 수준 internal 가시성 플래그(#908, #911). 백엔드에 internal: true를 지정하면 그 백엔드가 서비스하는 인프라 전용 모델(가드레일 분류기, 내부 요약 모델, 임베딩 모델)이 사용자 대상 표면에서 빠집니다. 내부 백엔드 이름은 OpenAI /v1/models, /v1/models/extended, 단일 모델, Anthropic /anthropic/v1/models 목록에서 제거되고, 키별 허용 목록보다 먼저 라우팅 후보군에서도 빠지므로 내부 전용 모델을 요청하면 오해를 부르는 Forbidden 대신 model-not-found(404)가 반환됩니다. 라우터 내부의 backend: 참조와 관리자 표면은 여전히 내부 백엔드를 해석합니다. 이 필드는 기본값이 false이고 직렬화 시 생략되므로 기존 설정은 그대로입니다.
  • 컨트롤플레인 키 회전 겹침 구간에서 물러나는 허브 키 해시 수용(#906). KeyEntry에 추가형 필드 previous_key_hash와 previous_retires_at_ms가 생겨, 라우터가 겹침 구간 동안 현재 해시와 이전 해시를 모두 받아들입니다. 이전 해시는 로컬 타임스탬프 시점이나 허브가 다음 동기화에서 해시를 생략하는 시점 중 먼저 오는 쪽에서 물러나며, 현재 해시가 이전 해시를 가리는 일은 없고 폐기(revocation)는 여전히 두 해시 모두를 거부합니다. control-plane 피처 뒤에 위치하며, 예전 봉투(envelope)도 그대로 파싱되고 PROTOCOL_VERSION은 0으로 유지됩니다.
  • 컨트롤플레인 usage 레코드에 스트리밍 TTFT(첫 토큰까지 시간) 기록, 부하 상황에서도 하트비트 인벤토리 신선도 유지(#912). UsageRecord에 추가형 ttft_ms가 생겼습니다. latency_ms와 동일한 요청 시작 시점부터 첫 스트리밍 바이트까지를 chat, thinking, Anthropic, Gemini SSE 루프에서 측정해 latency_ms로 클램프하며, 비스트리밍·로컬 캐시 재생·배치 응답에서는 지어내지 않고 생략합니다. 하트비트 루프는 각 비트의 구성·전송 시간을 간격에서 빼므로 부하로 비트가 느려져도 허브의 부하 샘플 간격을 넘어 주기가 밀리지 않고, 모든 비트는 여전히 전체 라우터 인벤토리를 담습니다. control-plane 피처 뒤에 위치하며 PROTOCOL_VERSION은 0으로 유지됩니다.

변경됨

  • 공식 릴리스 바이너리에 control-plane과 appproxy-router 피처가 컴파일되어 함께 배포됩니다(#905). Release 워크플로가 6개 타깃 모두를 --features control-plane,appproxy-router로 빌드하고 Debian 패키지와 Docker 이미지가 그 바이너리를 재포장하므로 모든 배포 채널이 이 피처를 담습니다. 두 피처 모두 크레이트 default/full 세트에는 들어가지 않으므로 소스에서 cargo build로 빌드하면 바이트 단위로 동일하고, 패키지 바이너리의 경우 옵트인은 런타임 설정(control_plane.enabled, 기본값 false, 그리고 기본적으로 없는 appproxy_router 섹션)으로 옮겨가므로 기존 배포의 동작은 바뀌지 않습니다. 레거시 appproxy/appproxy-legacy 워커는 소스 빌드 옵트인으로 남습니다.

수정됨

  • 기본 피처 cargo clippy -- -D warnings CI 단계를 모든 브랜치에서 붉게 실패시키던 clippy 린트 드리프트를 해소했습니다(#907). Anthropic Gemini 변환과 Bedrock reasoning 변환에서 clippy가 제안한 기계적 재작성 두 건이며 동작 변화는 없습니다.

v1.13.0 - 2026-07-09

추가됨

  • Continuum Hub 컨트롤 플레인의 정책 집행과 최적화를 추가하여, opt-in 에이전트를 아웃바운드 텔레메트리(v1.12.0)에서 완전한 정책 계층으로 승격했습니다. 모든 기능은 control-plane 피처 뒤에 있고 정책이 로드되지 않으면 fail-open하므로, 기본 빌드는 의존성도 런타임 비용도 늘지 않고 바이트 단위로 동일합니다.
  • 허브와 동기화된 키 테이블, 티어 레이트 리밋(rpm과 캐시 인식 입력/출력 TPM), 월간 예산, 모델 허용 목록을 post-auth 미들웨어로 집행하고 x-continuum-ratelimit-* 헤더를 내보냅니다. 추가적인 KeyUsageSnapshot 프로토콜 타입과 poll/WebSocket 정책 동기화를 ArcSwap 스토어로 받습니다(#877).
  • 정확 일치 응답 캐시를 티어 cache_enabled와 조직 최적화 정책으로 게이트하고, x-continuum-cache·x-continuum-batch 요청별 오버라이드 헤더를 티어 범위 안에서 강제하며(off가 항상 우선, on은 티어를 넘지 않음), x-continuum-cache hit/miss/bypass 결과 헤더를 내보내고, 로컬 캐시 히트를 토큰 예산을 차감하지 않는 메타데이터 전용 이벤트로 계측합니다(#878).
  • usage 레코드에 CacheHitType(exact/prefix/semantic과 개방형 unknown)을 이미 계측 중인 cache_hit·cached_input_tokens·batched·provider_batch_id 필드와 함께 기록합니다. serde 기본값을 갖는 추가 필드이며 PROTOCOL_VERSION은 그대로입니다(#879).
  • 배치 가능한 트래픽을 OpenAI 방식과 Anthropic Message Batches 엔드포인트로, 동기 rpm/tpm 한도를 절대 소비하지 않는 별도의 배치 레이트 풀(batch_rpm과 인플라이트 batch_queue_depth 게이지)에서 디스패치하고, 각 provider 작업의 수명주기를 at-least-once·404 허용 BatchStatusUpdate 메시지로 허브에 보고하며, per-key 소유권 게이트와 나이 기반 회수 백스톱을 갖춘 완전한 키 범위 /v1/batches 표면(create·retrieve·list·cancel·results)을 노출합니다. control_plane.batch를 켜지 않으면 비활성입니다(#880, #895).
  • OpenAI chat과 Anthropic Messages 비스트리밍에서 정확 일치 캐시와 분리된 키스페이스로 프롬프트 접두어 응답 캐시를 실행합니다. 프롬프트를 결정하는 필드는 유지하고 샘플링에 묶인 꼬리는 버리며, 자연 종료된 응답만 저장하고 저장 항목이 새 요청의 max_tokens에 맞을 때만 서빙하도록 보호합니다(#895).
  • 가격 차익(x-continuum-arbitrage, 티어 arbitrage_enabled, tri-state equivalence_classes)으로 가장 저렴한 동등 provider를, 헬스·서킷 브레이커·지연·티어·요청별 헤더 게이트를 모두 통과한 뒤에만 선택하고, 이후 선택 패스가 정책 결정을 뒤집지 못하도록 디스패치를 선택된 백엔드로 제약합니다(#896, closes #891).
  • 관리 대상 요청의 컨텍스트를 라우터 측에서 압축합니다(x-continuum-compression, TierLimits.compression_enabled, CompressionPolicy). responses-only 라우팅, 스트리밍, 캐시 조회/저장, 디스패치 전에 채팅 이력을 결정적으로 줄이되 선두의 system/developer와 최근 턴은 보존하며, 내용을 허브에 노출하지 않고 compressed·uncompressed_input_tokens를 보고합니다(#898, closes #892).
  • 배치 가능한 /v1/batches 제출을 다음으로 일치하는 UTC 라우팅 윈도(PolicyEnvelope.routing_windows tri-state, 요일 마스크, 티어 범위, 끝 배타적·자정 넘김 경계)가 열릴 때까지 지연시키되 배치 승인은 디스패치 시점에 확인하여 요청이 대기하는 동안 풀 슬롯을 잡아두지 않으며, 라우터 인벤토리 heartbeat에 정수 전용 LoadSummary 요청/오류율/p50/p99 카운터와 자문용 OptimizationPolicy.load 임계값을 추가합니다(#900, closes #893).
  • 컨트롤 플레인 경로에서 허브 정책에 따라 요청 모델을 치환하며, 치환과 차익 메타데이터는 상호 배타로 유지합니다(#894).
  • 허브가 전달한 조직 범위 provider 자격증명(PolicyEnvelope.provider_credentials, 허브 M3 vault)을 로컬 설정 api_key 대신 사용하여, 재시작이나 인플라이트 요청 중단 없이 회전을 적용합니다. 비밀값은 메모리에만 있고 모든 Debug 출력에서 마스킹되며, 모든 provider 디스패치 전송 지점(proxy, streaming, images, Responses, batch, 네이티브 Anthropic, count_tokens, embeddings)에서 값만 해석되므로 auth 헤더 존재 여부와 OAuth/SigV4/Bedrock 게이트는 바뀌지 않습니다(#901).
  • 허브 정책 집행과 usage 귀속을 네이티브 Anthropic Messages 경로(스트리밍·비스트리밍, native·OpenAI-bridge·Responses-bridge·Bedrock-runtime 하위 경로)와 스트리밍 /v1/responses로 확장하여, Anthropic 네이티브 x-api-key 자격증명을 받아들이고 캐시 인식 토큰 사용량을 anonymous가 아닌 허브 key_id로 과금합니다(#882, #889).

수정됨

  • 계층형 KV 캐시 이벤트 라우팅을 하드닝했습니다. 설정된 KV 이벤트 소스를 시작 시 인덱스에 연결하고, 이벤트가 스토리지 티어만 바꿀 때 기존 어피니티 점수를 보존하며, 오프로드/리로드/퍼지 이벤트 전체를 메트릭과 admin 상태로 노출하고, 분리형 fast-decode 실행을 KV 인덱스에서 선택된 decode 백엔드로 제약하며, 오케스트레이션 중 per-client 백엔드 허용 목록을 준수하고, 2단계 워커 프로토콜을 사용할 수 없을 때 거짓 성공 prefill/decode 헤더 대신 service-unavailable 오류를 반환합니다(#903).
  • 에픽 864 가드레일 핫 리로드 동작을 설정 validator와 가드레일 서비스 전반에서 하드닝했습니다(#864).
  • 프리픽스 캐시 히트 계측 호출 지점에 모델 치환 인자를 연결하여, #894와 #895가 서로 다른 베이스에서 깨끗하게 병합된 뒤 --features control-plane로 트리가 컴파일되도록 했습니다(#897).

문서

  • AppProxy 워커 모드 레퍼런스(영문·국문)를 새 ROUTER 프론트엔드 중심으로 재설계했습니다. 워커당 단일 바인딩 주소, 클러스터 수준 모델 추상화, 2단계 계층 라우팅, 매핑의 event+pull 전파와 해시 전용 키 관리, BEP-1053 상호 참조를 담았습니다(#748).
  • 두 릴리스만큼 뒤처져 있던 docs/en·docs/ko 변경 내역 미러에 v1.11.0과 v1.12.0을 소급 반영했습니다.
  • 완화된 drift 가드에 맞춰 config-assistant 동기화 노트를 바로잡고 CHANGELOG en/ko 미러 요구 사항을 명시했습니다.

의존성

  • minor-and-patch 그룹을 2건 갱신했습니다(#883): rand 0.10.1 → 0.10.2, arc-swap 1.9.1 → 1.9.2.

CI

  • 스킬 문서 버전 drift 가드를 정확한 CARGO_PKG_VERSION 일치에서 호환 범위 검사로 완화하여, 범위 안의 통상적 버전 상승은 손수 편집이 필요 없고 범위 상한을 넘는 메이저 상승은 여전히 빌드를 실패시켜 재검토하도록 했습니다.

v1.12.0 - 2026-07-05

추가됨

  • config.yaml을 작성하고 검증하는 설정 어시스턴트를 추가했습니다(에픽 #530).
  • validate·generate·diff·show --resolved 동사를 갖춘 config CLI 하위 명령과 configuration-assistant 스킬, 설정 템플릿(#829).
  • claude mcp add continuum-router-config -- continuum-router mcp-serve가 바로 동작하는 mcp-serve stdio MCP 서버 모드입니다. mcp 피처 뒤에 있고 default/full에 포함됩니다(#830).
  • IDE 규칙 생성기(scripts/generate-ide-rules.sh)와 문서(#831), 그리고 @lablup/continuum-router-mcp npm 래퍼 패키지(#832).
  • 스키마 변경이 어시스턴트 문서나 템플릿 갱신을 빠뜨리면 빌드를 실패시키는 검증·CI drift 가드(#833)와 후속 하드닝(#834).
  • opt-in control-plane 피처 뒤에 Continuum Hub 컨트롤 플레인 에이전트를 추가했습니다(#875, closes #874). 라우터를 허브에 등록하고 liveness와 라우터 인벤토리를 heartbeat하며, 메타데이터만 담은 usage 레코드를 배치로 밀어 올리는 아웃바운드 전용 작업입니다. default/full에 없으므로 기본 빌드는 의존성도 런타임 비용도 늘지 않습니다. 유일한 새 의존성은 피처를 켰을 때만 컴파일되는 vendored serde 전용 continuum-protocol 크레이트입니다.
  • Backend.AI AppProxy ROUTER 워커 모드를 추가했습니다(에픽). ROUTER wire 타입·이벤트·워커 설정(#815), 노드별 applied-ack를 갖춘 ROUTER 이벤트 오버레이(#816), circuit과 매핑을 백엔드로 조정하는 로직(#817), 라우터 설정을 당겨오는 coordinator 클라이언트(#818), allowed_models 기반 모델 단위 API 키 게이트(#819), ROUTER 워커 수명주기와 설정 연결·피처 플래그(#820)를 포함합니다.
  • Claude Sonnet 5를 지원합니다(#857).
  • streamGenerateContent를 통한 네이티브 스트리밍 Gemini Responses 변환을 추가했습니다(#856).
  • 가드레일 provider 두 개를 추가했습니다. backend: 접두어로 서비스 중인 백엔드를 가드레일 모델로 참조하는 provider(#871)와, 프롬프트로 정의한 JSON 판정을 반환하는 custom_classifier provider(#870)입니다.
  • 내부 미러를 위해 web-search provider의 base URL을 설정할 수 있게 했습니다(#836).
  • 인프로세스 iOS 임베딩을 위한 cli 없는 라이브러리 빌드를 embed 피처 뒤에 추가했습니다(#850). continuum_router::serve_embedded(config, addr)를 노출합니다.

변경됨

  • 두 백엔드 연결 풀을 단일 트레이트 기반 풀로 통합하고(#851, refs #812) 레거시 Backend 구조체의 이름을 PooledBackend로 바꿨습니다(#843, refs #511).
  • 이중 스트리밍 경로를 하나로 모았습니다. SSE 파이프라인 관심사를 조합 가능한 stream 미들웨어로 추출했고(#839), Gemini 스트리밍을 Backend 트레이트로 라우팅했으며(#840), 중복된 Anthropic 스트리밍 변환을 합치고(#841), OpenAI reasoning 정규화를 스트리밍 경로에 적용했습니다(#842).
  • 가드레일용 공유 LLM-백엔드 전송 코어를 추출하고(#869), alloc 없는 family 조회를 갖춘 네이티브 Anthropic system 게이트를 추가했습니다(#861).
  • 현재 AppProxy 워커를 legacy(appproxy v3)로 옮기고 공유 common 코어를 추출했습니다(#813).

수정됨

  • 컨트롤 플레인 usage 푸시에서 usage attribution 메타데이터를 보존했습니다(#876).
  • /v1/responses 스트리밍 요청을 file_id 해석 전에 검증하여, 잘못된 요청이 해석 도중 실패하는 대신 올바른 오류로 거부되게 했습니다(#873). 스트리밍 경로에서도 키별 allow-list를 존중합니다(#863).
  • Anthropic capability 게이팅을 위해 Bedrock 모델 접두어를 정규화했습니다(#859).
  • 쿼리 문자열이 붙은 Brave web_search base_url을 거부합니다(#852).
  • 네이티브 Gemini Responses 오디오·이미지 변환을 강화하고(#853, #780 후속) input_audio에 네이티브 Gemini Responses 변환을 연결했습니다(#835).
  • 에픽 505 스트리밍 프롬프트 패리티(#846)와 에픽 530 설정 어시스턴트(#834), AppProxy ROUTER 요청 게이팅(#823)을 강화했습니다.
  • 일별 series 정리·읽기 테스트를 벽시계에 고정하여 더 이상 흔들리지 않게 했습니다(#814).

문서

  • SSE 스트리밍 파이프라인 오버헤드를 재현 가능한 벤치마크로 문서화하고(#848) 두 스트리밍 경로 사이의 차이를 감사했습니다(#837).
  • 백엔드 변환 일관성 통합 테스트를 추가했습니다(#838).

의존성

  • minor-and-patch 그룹을 1개 디렉터리에서 3건(#845), 그리고 2건(#802) 업데이트했습니다.
  • actions/cache를 5에서 6으로(#844), actions/checkout을 6에서 7로(#801) 올렸습니다.

v1.11.0 - 2026-06-21

추가됨

  • 콘텐츠 안전 가드레일 서브시스템을 추가했습니다(에픽 #644). 기본값이 off인 Option<GuardrailsConfig>로 설정을 게이트하므로 guardrails 키가 없으면 기능이 꺼진 채로 남고 기존 설정은 영향을 받지 않습니다.
  • 기반: 입력·출력·스트리밍 검사 단계를 갖춘 object-safe async Guardrail 트레이트, 가장 위험한 결과가 이기는 심각도 순서의 GuardrailVerdict(Allow/Block/Transform/Flag), 빌림 기반 GuardrailContext, 그리고 전역 monitor/enforce 모드와 env로 자격 증명을 받는 provider·경로별 override·API 키별 우회·가드레일별 timeout_ms와 on_error fail-open/fail-closed·차단 동작·스트리밍 모드·exact/regex allow/deny 목록을 담은 GuardrailsConfig 스키마를 정의했습니다. 검증기는 provider 없는 enforce, [0,1]을 벗어난 임계값, 컴파일되지 않는 regex, 0인 timeout 같은 잘못된 조합을 거부합니다(#779, closes #644). 이 검증기는 실제 설정 로드 경로에 연결되어 잘못된 guardrails 블록을 로드 시점에 거부합니다(#781).
  • 서비스: 가드레일별 timeout과 fail-open/fail-closed 처리, hot-reload, 차단 응답 빌더를 갖춘 GuardrailService 파이프라인을 추가했습니다(#782).
  • factory 뒤의 provider: OpenAI Moderation(#783), self-host 분류기(#785), PII 탐지·삭제(#786), AWS Bedrock과 Azure 클라우드 가드레일(#787).
  • 게이팅: chat·Anthropic·responses 디스패치 경로에 입력 게이팅을 연결하고(#788), 비스트리밍 응답 경로에 출력 게이팅을(#789), buffer/chunked/passthru 모드로 스트리밍 출력 게이팅을(#790) 붙였으며, 디스패치 핸들러에 경로별 정책을 연결해 요청 모델 id를 키로 guardrails.routes[<model>] override(모드, enable/disable, provider 부분집합, 임계값)가 끝까지 적용되게 했습니다(#796).
  • 제어·관측: 가드레일 정책 런타임 제어(#784), Prometheus 지표와 결정별 감사 로깅(#792), 입력·출력 게이팅 종단 통합 스위트(#791), 아키텍처와 지표를 다루는 가드레일 가이드와 사이트 내비게이션(#793)을 추가했습니다.
  • admin stats API에 모델별·일별 usage-series 차원을 추가했습니다(#799, closes #798). GET /admin/stats/api-keys/{id}/models와 GET /admin/stats/users/{user_id}/models는 모델별 토큰·요청 분해를 반환하고, .../{id}/series와 .../{user_id}/series는 UTC 완료 날짜를 키로 total_requests·prompt_tokens·completion_tokens·total_tokens의 오름차순 일별 시계열을 반환합니다(from/to는 Unix 밀리초 또는 RFC 3339를 받고, interval 기본값은 day이며 다른 값은 400을 반환합니다). id·모델·날짜에 나타날 수 없는 U+001F 구분자로 합성 키를 만든 카디널리티 제한 DashMap 네 개가 이 차원을 뒷받침하고, series_retention_days 옵션(기본 30, serde 기본값)이 컷오프보다 오래된 일별 버킷을 정리하며, 네 맵은 #[serde(default)]로 스냅샷에 추가되어 이 변경 이전에 기록된 스냅샷도 그대로 로드됩니다.

수정됨

  • 일별 series 정리 수명주기를 강화했습니다(#800, refs #799). 이제 restore가 런타임 삽입 경로에서 비예약으로 취급하는 anonymous 일별·모델 버킷을 포함해 구분자를 가진 모든 합성 키를 세므로, 재시작 후 만료 버킷을 정리할 때 예약된 적 없는 슬롯을 감소시켜 AtomicUsize를 언더플로하고 새 실버킷을 오버플로 행으로 접어 넣던 문제가 사라졌습니다. 스냅샷 작업이 정리를 돌리지 않는 stats 영속화 off 상태에서는 전용 시간별 series 정리기가 생성되므로, 기본 설정은 만료된 일별 버킷을 카디널리티 상한까지 쌓지 않고 물리적으로 버립니다.
  • /v1/responses 트래픽의 admin stats를 기록했습니다(#794). 변환 실패의 stats 성공·실패 경계를 바로잡고 등록된 API 키별·사용자별 admin stats 엔드포인트의 회귀 커버리지를 강화했습니다.
  • 네이티브 Gemini·Anthropic 변환에서 OpenAI input_audio 콘텐츠 블록을 처리했습니다(#778). Gemini Responses 변환기는 이제 오디오를 형식에서 유도한 mime_type(wav는 audio/wav, mp3과 그 외는 audio/mp3)의 inline_data 파트로 매핑하고 data: URL은 기존 inline-data 경로로 보냅니다. Anthropic Messages API에는 오디오 콘텐츠 타입이 없으므로, 네이티브 변환은 블록을 조용히 버리거나 그대로 전달하는 대신 비스트리밍·스트리밍 진입점 모두에서 ValidationError를 HTTP 400으로 표면화합니다.

의존성

  • minor-and-patch 그룹을 5건 업데이트했습니다(#769): uuid 1.23.2에서 1.23.3로, regex 1.12.3에서 1.12.4로, redis 1.2.2에서 1.2.3로, aws-smithy-eventstream 0.60.20에서 0.60.21로, aws-smithy-types 1.4.9에서 1.5.0로.

v1.10.2 - 2026-06-15

추가됨

  • Kimi K2.7-Code와 GLM-5.2 모델 메타데이터를 추가했습니다 (#775). Kimi K2.7-Code는 MoonViT 비전 인코더와 256K 컨텍스트를 갖춘 1조 파라미터 MoE(활성 32B)입니다. thinking 모드로만 동작하고 추론을 네이티브 reasoning_content 필드로 반환하므로 <think> 마커 설정을 두지 않습니다. GLM-5.2는 GLM-5 계열의 코딩 플래그십으로 1M 컨텍스트 윈도우와 131K 최대 출력, 두 단계의 thinking-effort(High, Max)를 지원합니다. GLM 계열과 마찬가지로 표준 <think>/</think> 마커 설정을 둡니다. GLM-5.2 단독 API 가격은 출시 시점에 공개되지 않아 입력·출력 요금을 GLM-5/GLM-5.1 티어에서 추정했습니다.

수정됨

  • vLLM/OpenAI 호환 reasoning 필드를 /v1/chat/completions에서 정규 reasoning_content로 정규화했습니다 (#776, closes #774). 최신 vLLM이 추론 출력 필드를 reasoning_content에서 reasoning으로 바꾸었습니다(스트리밍 delta.reasoning, 비스트리밍 message.reasoning). 라우터가 이를 그대로 중계하면서 reasoning_content를 읽는 클라이언트는 self-host vLLM 추론 모델의 추론 텍스트를 조용히 잃었습니다. 정규화는 모든 OpenAI 호환 중계 경로에서 동작합니다. 스트리밍 기본·thinking 변환기, unix-socket 중계, mid-stream fallback 중계, 비스트리밍 프록시 본문이 대상입니다. 같은 객체에 reasoning_content가 없을 때만 적용하므로 정규 이름을 이미 쓰는 업스트림은 덮어쓰지 않습니다. Gemini·Anthropic 핸들러와 Responses API에는 적용되지 않습니다.

문서

  • reasoning-effort 아키텍처 문서(영문·국문)에 라우터가 업스트림 vLLM reasoning 필드를 reasoning_content로 정규화한다는 설명을 추가했습니다.

v1.10.1 - 2026-06-15

추가됨

  • API 키별·사용자별 사용량 통계 REST API를 추가했습니다. 관리자 인증 라우터 아래에 기존 /admin/stats/models와 같은 형태로 GET /admin/stats/api-keys, GET /admin/stats/api-keys/{id}, GET /admin/stats/users, GET /admin/stats/users/{user_id} 네 개 엔드포인트를 둡니다 (#772, closes #770). StatsCollector에 per_api_key와 per_user 차원이 추가됩니다. Prometheus llm_tokens_total 카운터를 갱신하는 바로 그 스폰 태스크에서 함께 기록하므로 이 인메모리 차원은 더 이상 metrics 피처에 의존하지 않으며 실패한 요청이나 토큰이 0인 요청까지 집계됩니다. api_key_id는 요청 핫 패스 밖에서 한 번만 해석하는 파생된 비가역 식별자입니다. 인증되지 않은 요청은 "anonymous" 버킷으로 묶이고 차원당 1000개 상한을 넘는 식별자는 "unknown" 오버플로 버킷으로 묶여 전체 합계에는 그대로 반영됩니다. GET /admin/stats/.../{id}는 기록된 사용량이 없는 식별자에 404를 반환합니다. 선택적 window 쿼리 파라미터는 응답에 그대로 돌려주지만 필터링하지는 않습니다. 집계가 /admin/stats/models처럼 전체 기간 누적 카운터이기 때문입니다. 스냅샷·영속 형식은 두 차원을 모두 #[serde(default)]로 추가하므로 이 변경 이전에 기록된 스냅샷도 형식 버전을 올리지 않고 그대로 로드됩니다.

변경됨

  • 루트 디렉터리의 .sh 스크립트를 .gitignore로 무시해 로컬 헬퍼 스크립트가 실수로 커밋되지 않도록 했습니다.

문서

  • 관리자 API 키 관리 엔드포인트와 API 키별·사용자별 사용량 통계를 영문과 국문 양쪽 관리자 REST API 레퍼런스에 문서화했습니다 (#773, closes #770). 새로 추가한 'API 키 관리 API' 절은 여덟 개 /admin/api-keys 엔드포인트(생성·목록·조회·수정·삭제·회전·활성화·비활성화)를 다룹니다. 여기에는 sk-***abcd 마스킹과 생성 시 전체 값을 한 번만 노출하는 동작을 반영한 요청·응답 스키마, ApiKeyConfig 필드, permissive와 blocking api_keys.mode 동작, persistence_file을 통한 런타임 키 영속과 핫 리로드 설명이 들어갑니다. 통계 절에는 네 개의 새 통계 엔드포인트, window 에코, anonymous와 unknown 버킷, 차원당 상한, api_key_id와 발급된 키 id의 연결을 추가했습니다.

v1.10.0 - 2026-06-12

추가됨

  • Codex(ChatGPT OAuth) 백엔드의 동적 모델 열거를 추가했습니다 (#753, closes #752). Codex 백엔드에는 표준 /v1/models가 없으므로, 라우터가 모델 검색 시 로드된 OAuth 토큰으로 플랜에 따라 게이팅되는 GET <base>/models?client_version=<ver> 엔드포인트를 조회하고, 사용자에게 노출되는 항목(visibility: list이며 available_in_plans가 비어 있거나 계정의 chatgpt_plan_type 클레임과 일치)만 남겨 /v1/models 응답과 라우팅을 채웁니다. 네트워크 오류, 2xx 아닌 응답, 빈 목록 등 어떤 실패에도 설정된 models: 목록이 폴백으로 남으며, 이 동작은 Codex OAuth 백엔드에만 적용됩니다.

변경됨

  • src/services/responses/sse.rs에 공유 parse_responses_body를, src/http/streaming/handler.rs에 공유 build_openai_chat_request_core를 추출해, Codex, 패스스루, Anthropic 핸들러 변형에 걸쳐 중복돼 있던 /responses SSE 집계 파싱 경로와 스트리밍 요청 구성 로직을 제거했습니다 (#766, closes #762). 동작 변화는 없으며, 한 사본의 수정이 다른 사본에 전파되지 않는 드리프트 위험을 없앴습니다.

수정됨

  • Codex(ChatGPT OAuth) 채팅 완성을 백엔드의 /responses 엔드포인트로 라우팅합니다 (#755, closes #754). build_responses_url이 .../backend-api/codex 루트에 /v1/responses를 덧붙였는데, 업스트림 엣지가 그 경로를 403 HTML 페이지로 거부했고 라우터는 OAuth 토큰이 유효한데도 이를 불투명한 인증 오류로 노출했습니다. 이제 Codex 루트는 내부 /v1을 제거하는 공유 URL 컴포저를 거칩니다. 경로가 고쳐진 뒤에는 Codex가 문자열 input을 HTTP 400 "Input must be a list"로 거부하므로, sanitize_codex_responses_request가 단일 메시지의 문자열 input을 한 개짜리 아이템 리스트로 변환합니다.
  • OAuth 토큰 저장소에서 RFC3339 형식의 expires_at을 받아들입니다 (#756, closes #751). 이 필드가 단순 u64였기 때문에 날짜 문자열로 기록된 토큰 저장소는 역직렬화에 실패했고, 백엔드가 조용히 OAuth 전략을 잃은 채 인증 없이 요청을 보내 실제 원인이 가려진 불투명한 403으로 드러났습니다. expires_at은 이제 정수·실수 epoch 초, 숫자 문자열, 오프셋 포함 RFC3339 날짜를 모두 epoch 초로 정규화해 받아들이고, 그 외 값은 허용 형식을 명시한 오류로 거부합니다. 디스크의 표준 형태는 JSON 숫자 그대로이므로 기존 저장소와 저장/로드 왕복은 변하지 않습니다.
  • 클라우드 OpenAI로 향하는 /v1/chat/completions 요청에서 로컬 엔진 전용 필드(chat_template_kwargs, thinking_budget_tokens, enable_thinking, preserve_thinking, top_k, min_p, repeat_penalty)를 제거합니다 (#760, closes #758). 클라우드 OpenAI는 알 수 없는 최상위 키를 HTTP 400 "Unknown parameter"로 거부합니다. 이 제거는 api.openai.com을 게이트로 하는 공유 field_filter 모듈을 통해 기존 클라우드 Gemini 동작을 그대로 따르고, 클라우드 OpenAI로 채팅을 보내는 모든 지점(비스트리밍, 스트리밍, 폴백 루프의 각 홉)에서 실행되며, extra_body와 reasoning_effort는 절대 건드리지 않고, 로컬 OpenAI 호환 엔진에는 아무 동작도 하지 않아 백엔드 패스스루 계약을 보존합니다.
  • Codex 동적 열거에서 설정된 models: 선택을 존중하고 열거된 모델에 정리된 owned_by를 기록합니다 (#765, closes #763). 라이브 열거가 성공하면 운영자가 설정한 models: 허용 목록이 무시되어 /v1/models가 열거된 Codex 모델을 전부 노출했는데, 이제 후처리 필터가 일관되게 적용되어 비어 있지 않은 목록은 열거 결과를 선택한 부분집합으로 좁히고 빈 목록은 전체를 노출합니다. 열거된 Codex 항목의 owned_by에 원시 백엔드 이름이 들어가던 문제도 백엔드 타입에서 해소한 값(openai)으로 바로잡아 다른 모든 백엔드와 일치시켰습니다.
  • Codex(ChatGPT OAuth) 백엔드에서 stream:false로 들어온 /v1/chat/completions 요청에 올바른 비스트리밍 응답을 반환합니다 (#764, closes #761). Codex의 /responses 엔드포인트는 stream:true + store:false만 받아들이므로 stream:false를 보내면 HTTP 400 "Stream must be set to true"가 발생했습니다. sanitize_codex_responses_request가 이제 stream:true와 store:false를 무조건 강제합니다. 두 번째 수정으로 PassthroughService::execute_request의 SSE 감지가 Content-Type: text/event-stream 없이 SSE 본문을 보내는 Codex 응답에도 대응합니다. looks_like_sse 스니퍼가 첫 비어 있지 않은 줄의 data:/event:/: 접두어를 확인하고, JSON 파싱이 실패하면 원래 파싱 오류를 드러내기 전에 SSE 집계를 한 번 재시도합니다.
  • Codex store:false 응답에서 response.output_text.delta 이벤트로부터 어시스턴트 메시지를 재구성합니다 (#768, closes #767). Codex가 store:false로 동작하면 종료 response.completed 이벤트의 output 배열이 비어 있고 어시스턴트 텍스트는 증분 response.output_text.delta 이벤트로만 전달됩니다. 이제 SSE 집계가 그 델타를 누적하고, 완료 이벤트에 어시스턴트 텍스트가 없으면 메시지 아이템을 합성해, 비스트리밍 클라이언트(제목/요약 유틸리티 경로 포함)가 빈 응답 대신 텍스트를 받습니다.

문서

  • v1.9.1 이후의 Codex 및 전송 계층 변경을 영문·국문 매뉴얼에 문서화했습니다. Codex Chat→Responses 요청 처리와 느슨한 expires_at 파싱(backends.md), 클라우드 OpenAI 필드 제거(backend-passthrough.md), Responses API의 item_reference 해소(api.md)를 다루고, 남은 국문 페이지를 정중한 -습니다 체로 통일해 모든 페이지가 한 목소리로 읽히게 했습니다.

v1.9.1 - 2026-06-11

추가됨

  • { "version": "<CARGO_PKG_VERSION>" }를 반환하는 GET /version 엔드포인트를 추가했습니다. appproxy를 비롯한 어떤 Cargo 피처에도 묶이지 않고 베이스 라우터에 무조건 등록되므로 표준 릴리스 바이너리에 항상 포함됩니다. 기존 GET /health 응답에도 version 필드를 더했습니다 (#750, closes #749). 두 엔드포인트 모두 /health와 마찬가지로 API 인증 경계 밖에 있어 다운스트림 소비자가 404에 막혀 fail-open으로 넘어가는 대신 실행 중인 라우터 버전을 확인해 기능을 게이팅할 수 있습니다.

v1.9.0 - 2026-06-10

추가됨

  • 옵트인 appproxy Cargo 피처로 AppProxy 워커 모드를 추가해, Continuum Router가 AppProxy 코디네이터의 제어를 받는 Backend.AI AppProxy 추론 워커로 동작할 수 있게 했습니다 (에픽 #709). 이 피처는 일부러 full에서 제외해 기본 빌드에는 영향이 없습니다.
  • 기반: 타입이 정의된 AppProxyWorkerConfig 섹션(코디네이터 URL, 공유 api_secret/jwt_secret, redis_url, 와일드카드 프런트엔드 파라미터, heartbeat/reconcile 주기, 이벤트 토글로 구성하며 두 bearer 시크릿은 Debug에서 가려지고 ${ENV_VAR} 참조는 backends[].api_key와 같은 경로로 해소됩니다), SerializableCircuit/RouteInfo 와이어 타입과 ProxyProtocol/AppMode/FrontendMode enum(snake_case와 kebab-case를 모두 허용하고 알 수 없는 필드는 무시), 그리고 모듈 골격을 마련했습니다 (#716).
  • 코디네이터 REST 클라이언트 CoordinatorClient를 추가했습니다. register, heartbeat, deregister, list_circuits, get_circuit를 제공하며 각 호출마다 X-BackendAI-Token과 매번 새로 발급한 X-BackendAI-RequestID를 싣고, 재시도 가능 오류(연결, 타임아웃)와 치명적 오류(HTTP 4xx)를 구분하는 오류 타입을 갖습니다 (#717).
  • 서킷-백엔드 변환과 reconcile: circuit_to_backends가 레플리카마다 BackendConfig 하나를 만들고(이름은 appproxy-<circuit_id>-r<route_key>, traffic_ratio는 라우트를 0으로 떨어뜨리지 않는 1..=1000 가중치로 매핑, runtime_variant로 vLLM 감지), apply_circuits가 변환된 백엔드를 기존 핫리로드 config_sender로 주입합니다. appproxy- 접두사로 네임스페이스를 두어 정적 설정과 Admin API 백엔드는 보존됩니다 (#718).
  • 워커 수명주기 서비스와 /status 엔드포인트: run_worker가 백오프와 함께 등록한 뒤 초기 서킷 풀을 수행하고, 이어 heartbeat 루프(코디네이터의 30초 LOST 타임아웃 안으로 유지)와 pull-reconcile 루프(놓친 이벤트를 받쳐 주는 상시 백스톱)를 돌립니다. 각 서킷의 모델은 레플리카의 GET /v1/models에서 자동 발견하고, 종료 시 등록을 해제합니다. 공유 인메모리 AppProxyRegistry는 서브도메인과 서킷 id로 색인됩니다 (#719).
  • 매니저가 발급한 엔드포인트 서브도메인을 구체적인 서킷으로 해소하고 요청 모델을 고정해, 기존 선택 경로가 그 서킷의 레플리카를 그대로 서빙하게 하는 Host/서브도메인 인그레스 리졸버를 추가했습니다. 디코드된 id를 서킷 id와 대조하고 alg:none 다운그레이드를 거부하는 HS256 서킷 bearer 검증, 교차 서킷 모델 집계를 위한 선택적 aggregation_hosts 필드, 그리고 wildcard_domain이 설정되지 않았을 때의 순수 통과 동작을 포함합니다 (#720).
  • 레거시 모드 코디네이터에 1초 미만의 서킷 갱신을 주는 Redis Pub/Sub 서킷-이벤트 오버레이: 지수 백오프 재연결을 갖춘 구독 루프, base64 + msgpack 봉투 코덱, circuit_created / circuit_removed / circuit_route_updated 핸들러, 그리고 코디네이터의 E10001 "Proxy worker not responding" 오류를 막는 ack 봉투를 추가했습니다 (#721).
  • Claude Fable 5(claude-fable-5)와 Mythos 5(claude-mythos-5) 모델을 지원합니다 (#747). 둘 다 1M 컨텍스트, 최대 출력 128K, MTok당 \(10/\)50 가격이며, Mythos 5는 안전 분류기를 제거한 동일 모델로 한정 공개인 Project Glasswing 릴리스로만 제공됩니다. 새 is_mythos_class 헬퍼가 두 id를 Anthropic 능력 게이트로 라우팅합니다. adaptive thinking이 필수이고(레거시 budget_tokens는 HTTP 400으로 거부되며 adaptive로 정규화), temperature/top_p/top_k는 제거되며, max effort 레벨을 지원하고(xhigh는 max로 매핑), 대화 중간 system 메시지를 보존합니다. 두 모델 모두 명시적 thinking.type == "disabled"를 거부하므로, explicit_thinking_for_model이 이제 Option을 반환해 400을 유발할 값을 전달하는 대신 thinking 파라미터를 아예 생략합니다. max effort 레벨이 더 이상 Opus 전용이 아니므로 opus_supports_max_effort는 supports_max_effort로 이름을 바꿨습니다. 같은 처리가 OpenAI Responses API 변환 경로에도 적용됩니다.
  • Gemma 4 QAT 다섯 개 양자화 인식 학습(quantization-aware-training) 체크포인트(E2B, E4B, 12B Unified, 26B-A4B MoE, 31B dense)의 모델 메타데이터를 추가했습니다. 동작에 필요한 -it-qat 별칭과 해소/드리프트 가드 테스트를 포함합니다 (#723, #722 종료).
  • Gemma 4 12B Unified 모델 메타데이터를 추가했습니다 (#705).

변경됨

  • 요청 본문 추출기의 거부를 warn 수준으로 로깅해, 핸들러 실행 전에 Axum이 거부하는 잘못되거나 과대한 본문이 조용히 실패하지 않고 로그에 드러나도록 했습니다 (#707).

수정됨

  • 일시적인 속도 제한과 비일시적인 쿼터/크레딧 소진을 구분하고 업스트림 Retry-After 힌트를 존중해, HTTP 429에서 재시도 루프가 같은 업스트림을 연타하지 않게 했습니다 (#742, #740 종료). 이전에는 모든 429가 공급자의 Retry-After를 무시한 고정 지수 백오프로 최대 max_attempts(기본값 3)까지 재시도되어, 단일 업스트림이 제공하는 모델에서는 라우터가 이미 소진된 같은 엔드포인트를 다시 때리며 부하를 키우고 피할 수 없는 실패 앞에 지연만 더했습니다. RouterError::RateLimited 변형이 이제 retryable 플래그를 가집니다. 비일시적 429(OpenAI insufficient_quota / billing_hard_limit_reached, 그리고 "prepayment credits are depleted" 같은 명확한 크레딧 소진 문구)는 재시도 불가로 분류되어 라우터가 호출 한 번 만에 빠르게 실패하고 공급자의 상태 코드와 본문을 그대로 전달하며, 일시적 신호(순수 RESOURCE_EXHAUSTED/RPM 스로틀링, rate_limit_exceeded, rate_limit_error)는 재시도 가능으로 남습니다. 분류기는 일부러 좁게 두었습니다. Google이 일시적 스로틀링에 그대로 재사용하는 지나치게 광범위한 "billing"과 "exceeded your current quota" 마커를 제거해, 회복 가능한 Google 429가 더 이상 빠른 실패로 뒤집히지 않습니다. 재시도되는 429는 백오프에 업스트림 Retry-After를 사용하고(max_delay로 캡), 요청된 간격이 남은 전체 타임아웃 예산을 넘으면 잠들지 않고 즉시 실패합니다. 힌트는 끝까지 보존됩니다(Google의 RetryInfo.retryDelay와 정수 초 형식 Retry-After 헤더에서 파싱해 클라이언트로 향하는 Retry-After 헤더에 반영). 예산 프로브는 이제 saturating_add를 쓰고 파싱된 힌트는 24시간(MAX_RETRY_AFTER_SECS)으로 클램프해, 적대적 업스트림이 u64::MAX에 가까운 Retry-After로 Duration 덧셈을 오버플로시켜 요청 태스크를 중단시키던 원격 트리거 가능 패닉을 막습니다.
  • 스트리밍 변환 경로(Anthropic, Chat-Completions/Gemini 폴백)에서 누적된 /v1/responses 응답을 저장해, 스트리밍된 출력 항목을 {"type":"item_reference","id":"item_..."}로 참조하는 후속 요청(OpenAI 및 Vercel AI SDK의 기본 동작)이 1단계에서 store:true를 썼더라도 HTTP 400 대신 정상 해소되게 했습니다 (#746, #745 종료). 완료된 응답은 첫 response.completed 이벤트가 클라이언트에 도달하기 전에 저장됩니다. response.completed를 내보내지 않는 오류 종료 스트림은 비스트리밍 오류 경로와 마찬가지로 저장하지 않으며, 패스스루 스트리밍은 업스트림이 저장을 소유하므로 변경 없습니다.
  • 전략 dispatch 전에 item_reference input item을 해소해, 인라인 항목 대신 item_reference를 보내는 멀티-스텝 도구 호출에서 /v1/responses가 Anthropic/Claude 백엔드에 더 이상 HTTP 400을 반환하지 않게 했습니다 (#743, #741 참조). 참조는 인라인 FunctionCall/Message/FunctionCallOutput 항목으로 재작성되고(같은 call_id는 first-wins로 중복 제거), build_context_for_user는 저장된 함수 호출 출력 항목을 올바른 tool_use/tool_result 쌍으로 재구성하며, OpenAI/Azure 패스스루 경로는 참조를 그대로 전달합니다. 해소할 수 없는 참조는 id를 명시한 설명적 400을 반환하고, 256개 상한(MAX_ITEM_REFERENCES)이 요청당 세션 스토어 스캔 비용을 제한합니다.
  • server.workers를 Tokio 런타임에 전달합니다 (#736, #734 참조). 이 값은 config.yaml.example에 문서화되고 출하됐지만, main이 인자 없는 #[tokio::main]을 써서 런타임이 항상 num_cpus::get() 워커 스레드로 돌았기에 아무 효과가 없었습니다. 이제 main은 동기 함수입니다. 설정 파일에서 server.workers를 미리 읽어 기존 RuntimeConfig::build_runtime 경로로 적절한 크기의 멀티스레드 런타임을 만들고 그 위에서 비동기 본문을 실행하며, 값이 설정되지 않았거나 0이면 CPU 개수로 폴백합니다.
  • 패스스루가 아닌 공급자(Anthropic 및 chat-completions 기반 경로)에서 /v1/responses 스트리밍이 규격을 따르는 function_call 출력 항목과 인자 이벤트를 내보내도록 했습니다. 텍스트 출력 페이로드를 보존하고, 교차된 병렬 도구 호출 인자를 업스트림 인덱스로 추적합니다 (#725).
  • Files API 라우트가 별도 스토어를 만드는 대신 공유 ApiKeyStore를 재사용하게 해, 런타임에서 관리하는 API 키가 Files API와 라우터의 나머지 부분에서 일관되게 인식되도록 했습니다 (#706).
  • AppProxy: 단일 서킷 이벤트에서 형제 서킷을 보존합니다 (#737, #731 종료). 둘 이상의 서킷을 서빙하는 워커가 단일 서킷 이벤트마다 영향받지 않는 모든 형제의 appproxy-* 백엔드를 지워(다음 pull-reconcile까지, 최대 15초 동안 404/502) 버리던 문제가 있었습니다. RegistryEntry에 route 정보가 없어 재구성된 집합이 델타 서킷만 담았기 때문입니다. 이제 각 RegistryEntry에 전체 서킷을 캐시하고 변경되지 않은 형제는 그로부터 재구성하므로, 델타 서킷의 백엔드만 바뀝니다.
  • AppProxy: 와일드카드 서브도메인 인그레스에서 폴백 체인에 도달하게 했습니다 (#738, #735 종료). 레플리카가 모두 죽은 등록 서킷이 더 이상 막다른 길이 아닙니다. 서킷별 인가 후 그 서킷의 정규 모델로 고정되어 일반 파이프라인으로 넘어가고, 거기서 FallbackService가 이어받습니다(운영자가 기대하는 "배포가 죽으면 교차 공급자 모델로 트래픽이 넘어간다" 동작). 이 통과는 등록됐지만 죽은 서킷으로 한정되고, 정말 알 수 없는 서브도메인은 404로 남으며, open-to-public / bearer 토큰 / IP 허용 목록 게이트가 먼저 실행됩니다.
  • AppProxy: 주기적 reconcile에서 이벤트로 알게 된 모델을 보존합니다 (#739). Redis 이벤트 오버레이가 성공적인 pull 프로브보다 먼저 서킷의 모델을 알게 된 경우, reconcile이 등록됐지만 죽은 레지스트리 항목을 evict 하고 스코프드 폴백을 깨뜨릴 수 있었습니다. 이제 reconcile은 레플리카를 프로브하기 전에 공유 레지스트리가 이미 아는 모델을 재사용합니다.
  • AppProxy: WorkerRegisterResponse 역직렬화를 Backend.AI 코디네이터의 실제 응답 형태(slots는 배열, available_slots는 그 개수)와 맞췄습니다 (#726).

문서

  • Zensical 사용자 문서를 현재 상태 기준 매뉴얼로 다시 썼습니다. 개발 로그식 서술, 로드맵, "coming soon" 항목을 걷어내고, 실제 config 구조체와 어긋난 설정 레퍼런스(존재하지 않는 섹션과 키, retry 필드 이름, admin 인증 형태, 환경 변수 표, 설정 탐색 순서)를 바로잡았으며, 그동안 빠져 있던 출하 동작(CLI 플래그 7종, auth login 하위 명령, Windows AF_UNIX 지원과 Unix 소켓 위 SSE, Windows/musl 및 .deb 릴리스 산출물)을 문서화하고, 한국어 문서를 영어와 같은 수준으로 맞췄습니다.
  • AppProxy 워커 모드 설계 문서를 추가했습니다 (#708).
  • README "Recent Updates" 목록을 릴리스별 한 줄로 압축했습니다.

의존성

  • validator derive 의존성과 그 전이 의존성 proc-macro-error2를 제거해, validator에 안전한 업그레이드 경로가 없어 임시 cargo-deny ignore가 필요했던 RUSTSEC-2026-0173 권고를 해소했습니다 (#733, #732).
  • Rust 패키지 버전을 업데이트했습니다 (#732).

v1.8.2 - 2026-06-02

수정됨

  • /v1/responses 경로에서 클라이언트의 Accept-Encoding 헤더 전달을 중단했습니다 (#702). 클라이언트가 Accept-Encoding: gzip, deflate, br을 보내면 responses 경로의 헤더 필터가 차단 목록에서 accept-encoding을 빠뜨려 업스트림 백엔드로 그대로 전달했고, 업스트림은 gzip을 협상해 압축된 바이트를 반환했습니다. reqwest는 Accept-Encoding 헤더를 수동으로 설정하면 자동 압축 해제를 비활성화하기 때문에(명시적인 .header("Accept-Encoding", "identity") 호출은 전달된 값을 교체하지 않고 두 번째 값으로 덧붙기만 했습니다), SSE 변환기가 raw gzip 바이트를 받아 텍스트로 파싱하면서 선두의 response.created, output_item.added, content_part.added, output_text.delta 이벤트를 떨어뜨리고 item_id와 text가 비어 있는 꼬리 조각만 남겼습니다. 이제 "accept-encoding"이 기본 convert 경로(src/http/handlers/responses.rs)와 responses 네이티브 패스스루 경로(src/proxy/responses_only.rs) 모두의 FILTERED_HEADERS에 포함되어 chat-completions 프록시(src/proxy/backend.rs)와 동등해졌고, 업스트림은 항상 Accept-Encoding: identity만 받습니다.
  • Responses SSE 라인의 이중 래핑을 중단해, 비 GPT /v1/responses 스트리밍 변환이 변환된 Anthropic 및 Chat-Completions 경로에서 중첩 레코드 대신 단일 레이어의 OpenAI 호환 SSE 레코드를 내보내도록 수정했습니다 (#701).

의존성

  • uuid 1.23.1 → 1.23.2, redis 1.2.1 → 1.2.2, socket2 0.6.3 → 0.6.4, serial_test 3.4.0 → 3.5.0으로 bump (#699).

테스트

  • 프로덕션 StreamService 변환 프로세서에 대한 회귀 커버리지를 강화해, 변환된 Anthropic 및 Chat-Completions 경로에서 단일 레이어 SSE 출력을 단언합니다 (#701).
  • Anthropic 백엔드와 gzip을 요청하는 클라이언트로 /v1/responses 스트리밍 경로를 검증하는 통합 회귀 테스트. 업스트림 요청이 Accept-Encoding: identity만 받는 것과, 변환된 Responses SSE 스트림이 텍스트와 item id가 채워진 전체 이벤트 시퀀스를 유지하는 것을 단언합니다 (#702).

v1.8.1 - 2026-05-29

추가됨

  • 하드코딩된 opus-4-7/opus-4-6 부분 문자열 게이트를 대체하는 claude_family_version 파서와 함께 Claude Opus 4.8 인식 추가 (#693, #687의 일부). 네 개의 Anthropic capability 술어가 이제 파싱된 (major, minor) 버전을 비교합니다: uses_adaptive_thinking_api ≥ (4,6), model_requires_adaptive_thinking / model_forbids_sampling_params ≥ (4,7), opus_supports_max_effort = Opus이면서 ≥ (4,6). 파서는 첫 번째 정수 토큰을 major로, 그다음 버전 모양 토큰(1~2자리, 값 < 100)을 minor로 취급하므로, 20250514 같은 8자리 날짜 접미사는 minor 0이 되어 버전으로 오인되지 않고, 새 minor 릴리스는 버전별 수정 없이 인식됩니다. claude-opus-4-8 메타데이터 항목(1M 컨텍스트, 128K 출력, \(5/\)25 가격, 적응형 사고, 2026년 1월 cutoff)을 추가하고 claude-opus-4-8 / claude-opus-4-8-latest를 내장 지원 모델 목록과 설정 샘플에 등록합니다. 4.5/4.6/4.7과 Sonnet 변형의 동작은 보존됩니다.
  • 백엔드별 anthropic_fast_mode opt-in(기본값 off) 뒤의 Anthropic fast mode (#694, #687의 일부). is_fast_mode_eligible은 Opus 4.6/4.7/4.8과 이후 Opus minor에서만 true를 반환하고, merge_beta_header는 클라이언트가 보낸 anthropic-beta를 보존하면서 beta 토큰을 쉼표로 결합하고 중복을 제거합니다. 네이티브 /anthropic/v1/messages 경로에서 resolve_fast_mode_beta는 요청이 speed: "fast"이고, 모델이 적합하며, 백엔드가 네이티브 Anthropic(Bedrock은 절대 아님)이고, opt-in이 켜진 경우에만 병합된 fast-mode-2026-02-01 beta 헤더를 주입합니다. fast mode가 적용되지 않으면 나가는 본문에서 speed를 제거해 의도치 않은 업스트림 400을 유발하지 못하게 합니다. OpenAI 호환 경로는 적합하고 opt-in된 네이티브 Anthropic 대상에만 speed: "fast"를 전달하고 beta 헤더를 주입합니다. Anthropic -> OpenAI와 Anthropic -> Google 폴백 파라미터 매핑은 speed를 제거해, fast mode 요청이 비 Anthropic 백엔드로 폴백할 때 네이티브 전용 필드가 누출되지 않게 합니다. 응답의 usage.speed는 보존됩니다.
  • Claude Opus 4.8+ 대화 중간 시스템 메시지 (#695, #687의 일부). 이전 Claude 패밀리가 HTTP 400으로 거부하는 messages 배열 내부의 role:"system" 항목이 이제 허용되며, claude_family_version을 재사용해 패밀리 버전 ≥ (4,8)에 매칭되는 새 supports_mid_conversation_system(model_id) 술어로 게이트됩니다. 네이티브 핸들러는 해당 항목을 네이티브 Anthropic 백엔드로 변경 없이 왕복시키고, 교차 공급자 변환은 System 역할을 OpenAI system 역할로 매핑하며 Gemini와 Responses에서는 user 역할 텍스트로 보존합니다. OpenAI 호환 변환은 지원 모델에 대해 대화 중간 system/developer 메시지(첫 user 턴 이후)를 배열 내 role:"system" 항목으로 내보내고, 선두 시스템 메시지는 여전히 최상위 system 필드를 채웁니다. 비지원 모델(Opus 4.7 이하, 모든 Sonnet/Haiku, Bedrock 접두사 id, 비 Claude id)은 단일 최상위 system으로 평탄화하는 기존 동작을 유지합니다.
  • 거부 응답의 stop_details와 refusal stop reason이 전체 Anthropic 응답 파이프라인을 통해 전파됩니다 (#696, #687의 일부). map_anthropic_finish_reason은 "refusal"을 "content_filter"로 매핑하고, 비스트리밍 변환과 스트리밍 message_delta 핸들러는 stop_reason이 "refusal"일 때 choice에 stop_details 객체를 붙이며, 업스트림이 명시적 null을 보내면 null을 전달하는 대신 키를 생략합니다.

수정됨

  • POST /v1/responses에서 인라인 image_url 대신 Files API 업로드를 file_id로 참조하는 input_image 콘텐츠 파트를 허용합니다 (#686, refs #681). 부모 enum들이 #[serde(untagged)]라서 {"type":"input_image","file_id":"file-..."} 파트는 이전에 일반적인 untagged-enum 오류로 역직렬화에 실패했고 Axum이 HTTP 422를 반환했습니다. 이제 image_url은 선택 사항이 되고 input_file을 따라 file_id가 추가되었습니다. 공유 헬퍼 resolve_local_file_to_data_url이 동일한 메타데이터, 소유권, 크기, 로드, base64 시퀀스를 거쳐 로컬 file_id를 인라인 base64 image_url 데이터 URL로 해석하며, 소유권과 10MB 크기 제한을 준수합니다. OpenAI/Anthropic/Gemini 변환기는 선택적 image_url을 처리해, 해석된 경우 이미지를 내보내고 해석되지 않은 file_id는 경고 후 건너뜁니다. validate_request는 메시지 콘텐츠를 순회해 image_url도 file_id도 없는 input_image를 파일 해석 전에 명확한 400으로 거부합니다.
  • Claude Opus 4.8 라우팅 게이트를 강화해, fast-mode speed는 전송 계층이 네이티브 Anthropic opt-in과 beta 헤더 주입을 확인한 경우에만 전달되고, 비 Opus Claude 패밀리는 대화 중간 시스템 메시지를 계속 평탄화하며, OpenAI 호환 Anthropic 응답은 usage.speed를 보존합니다 (#698, refs #687).

문서

  • Claude Opus 4.8 지원을 영어와 한국어로 문서화 (#697, closes #692, #687의 일부): reasoning-effort.md의 적응형 사고 모델 목록과 샘플링 파라미터 deprecated 경고에 claude-opus-4-8-* 추가; backends.md에 Claude Opus 4.8 모델 상세, Anthropic Fast Mode 섹션, 대화 중간 시스템 메시지 섹션, 거부 stop_reason -> content_filter 매핑 추가; 모델 인식, fast mode, 대화 중간 시스템 메시지, 거부 stop_details를 다루는 changelog 항목 추가.
  • api.md(영어와 한국어)에 input_image의 file_id 지원을 문서화. image_url과 file_id 중 정확히 하나가 필요하고, file_id는 input_file 경로와 같은 소유권 및 10MB 크기 제한 아래 백엔드에 도달하기 전에 인라인 base64 데이터 URL로 해석된다는 점을 명시합니다 (#686, refs #681).

테스트

  • claude_family_version과 supports_mid_conversation_system 경계 테스트(4.7 false, 4.8 true, Sonnet/Haiku/구버전 false, Bedrock 접두사와 교차 리전/ARN id false), fast-mode 적합성, beta 헤더 병합/중복 제거, speed/usage.speed (역)직렬화, 클라이언트 beta 병합을 포함한 resolve_fast_mode_beta 게이팅, 폴백 공급자 간 speed 비누출 대 보존 (#693, #694, #695).
  • 거부 응답 커버리지: stop_details가 있는 경우와 없는 경우의 비스트리밍 및 스트리밍 거부, 명시적 null 생략 경로, end_turn/max_tokens/stop_sequence/tool_use 회귀 테스트 (#696).
  • input_image의 file_id 단독 및 image_url 단독 역직렬화, 회귀용으로 실패하던 전체 요청 페이로드, 소유권과 크기 제한을 준수하는 FileResolver 해석, 세 백엔드 전체의 변환기 출력, 두 필드 모두 없는 검증 사례 (#686).

v1.8.0 - 2026-05-28

추가됨

  • 클라이언트 API 키의 선택적 allowed_backends 허용 목록을 통한 API 키별 백엔드 접근 제어 (#677, closes #674). 목록이 비어 있지 않으면 해당 키로 인증된 요청은 명시된 백엔드로만 라우팅될 수 있고, 빈 목록이나 누락된 목록은 기존의 무제한 동작을 유지합니다. 이 필드는 엔드 투 엔드로 통합됩니다: 설정 파일과 핫 리로드, 런타임 ApiKey와 AuthContext, 백엔드 선택 chokepoint와 Responses / Anthropic 선택 경로, 교차 공급자 폴백, Admin REST API(create/update/get/list), 런타임 키 영속화, 모델 목록 엔드포인트.
  • select_backend_with_retry와 Responses(StreamService), Anthropic 네이티브, count_tokens, 이미지 핸들러가 키의 허용 목록으로 후보를 필터링합니다. 모델은 존재하지만 허용 목록이 모든 후보를 거부하면, 403에 error_type = "permission_error"(재시도 불가)로 매핑되는 새 RouterError::Forbidden 변형으로 요청이 거부되며, 이는 401 AuthError와 구분됩니다.
  • /v1/models, /v1/models/extended, /anthropic/v1/models는 제한된 키로 인증된 경우 허용된 백엔드 중 적어도 하나가 제공하는 모델로 필터링되고, GET /v1/models/{model}은 키가 도달할 수 없는 모델에 404를 반환합니다. 비인증 또는 무제한 호출자는 전체 목록을 봅니다.
  • 새 api_optional_auth_middleware가 허용(permissive) 모드로 레이어링됩니다. 제시된 bearer 토큰을 최선 노력으로 검증하고 절대 거부하지 않으면서 AuthContext를 첨부하므로, 키별 제한은 인증된 호출자에게 적용되고 익명 및 잘못된 토큰 호출자는 제한 없이 통과합니다. 기존 차단 모드 api_auth_middleware는 변경되지 않으며, 둘은 절대 함께 레이어링되지 않습니다.
  • 키의 allowed_backends가 알 수 없는 백엔드 이름을 참조하면 설정 검증이 하드 실패 대신 경고만 하므로, 운영자가 키를 업데이트하기 전에 백엔드 이름 변경이 라우터를 먹통으로 만들지 않습니다.
  • fallback.mid_stream_enabled 설정 필드(기본값 true). 운영자가 초기 연결의 저렴한 pre-stream 백엔드 재선택은 유지하면서 스트림별 mid-stream 버퍼링은 끌 수 있습니다 (#680, closes #676). 이전에는 fallback.enabled가 all-or-nothing이었습니다. 켜면 pre-stream과 mid-stream 폴백 모두(스트림당 약 100~200KB를 버퍼링하는 StreamAccumulator 포함), 끄면 폴백 전체가 비활성화됐습니다. 메모리가 제한되거나 동시성이 높은 호스트에 중간 지점이 생겼습니다.
  • 스트리밍 디스패치가 순수 헬퍼 decide_streaming_fallback_dispatch로 분리된 3-way 결정이 됩니다. fallback.enabled가 true이고 체인이 설정된 상태에서 mid_stream_enabled = true는 버퍼링 경로(handle_streaming_with_mid_stream_fallback)를 유지하고, mid_stream_enabled = false는 되살아난 handle_streaming_with_pre_stream_fallback으로 라우팅하며(StreamAccumulator나 MidStreamFallbackContext 할당 없음, mid-stream 실패는 일반 스트림 오류로 표면화), 그 외에는 표준 무폴백 경로가 실행됩니다.
  • 이전에 dead code였던 handle_streaming_with_pre_stream_fallback과 advance_to_next_fallback이 이제 살아 있습니다. #[allow(dead_code)] 마커가 제거되었고, 키별 허용 목록이 폴백 재선택에 연결되어 새로 살아난 경로가 API 키별 접근 제어를 우회하지 않습니다.

변경됨

  • src/http/streaming/handler.rs의 스트리밍 로컬 복사본 transform_payload_for_openai와 그 requires_max_completion_tokens 헬퍼를 제거했습니다. 두 호출 사이트는 이제 crate::proxy::utils의 정식 구현으로 해석됩니다 (#679, closes #660). 두 복사본은 byte-for-byte 동일해서, 한쪽 변경이 다른 쪽에 반영되지 않는 drift 위험을 만들고 있었습니다. src/proxy/utils.rs의 정식 테스트가 같은 계약을 이미 커버하므로 스트리밍 로컬 중복 단위 테스트는 제거되었습니다.

수정됨

  • 기본 mid-stream 폴백 핸들러가 mid-stream 실패 후 백엔드를 재선택할 때 API 키별 백엔드 허용 목록을 강제합니다 (#683). fallback.mid_stream_enabled = true(기본값)에서, 폴백 체인이 비허용 백엔드로 매핑된 제한 키는 mid-stream 실패 시 그 백엔드로 투명하게 전환되었는데, 이는 PR #680의 새 mid_stream_enabled 작업이 pre-stream 경로에서만 닫았던 접근 제어 우회였습니다. handle_streaming_with_mid_stream_fallback은 이제 소유된 allowed_backends: Option<Vec<String>>을 받고(spawn된 스트리밍 태스크로 이동), 새 헬퍼 resolve_allowed_backend_name_for_model이 resolve_backend_name_for_model을 감싸 모듈의 다른 모든 곳(try_get_healthy_backend_for_model, get_backend_for_model_streaming, get_healthy_backend_for_streaming)에서 쓰는 것과 같은 allowed_backends.filter(|l| !l.is_empty()) + 정확한 이름 멤버십 의미론을 적용합니다. 세 폴백 재선택 사이트 모두 이 헬퍼를 통해 해석합니다. 비허용이거나 해석 불가능한 후보는 None을 반환하므로 기존 warn + continue arm이 건너뛰고 체인 인덱스가 전진합니다. 남은 후보가 모두 필터링되면 루프는 기존 체인 소진 경로로 종료되고 클라이언트에 오류를 표면화합니다. 빈 목록이나 None 허용 목록은 이전의 무제한 동작과 byte-for-byte 동일합니다.
  • mid-stream 폴백에서 폴백 페이로드를 재구성하기 전에 허용된 폴백 백엔드를 해석하므로, 비허용 체인 항목이 건너뛰어지기 전에 current_payload를 변형할 수 없습니다 (#684).
  • Anthropic 네이티브 x-api-key 호출자가 AuthContext가 없을 때 Authorization: Bearer 인증 호출자와 동일한 allowed_backends 정책을 적용받으며, Messages, count_tokens, 모델 목록을 커버합니다 (#684). 키별 허용 목록이 네이티브 Anthropic surface에서 강제되지 않는다고 문서화돼 있던 기존 제한이 닫혔습니다.
  • 677 머지 후 보안 감사에서 발견된 POST /v1/responses/compact의 HIGH 심각도 인가 우회를 닫았습니다. 이 엔드포인트는 요청 extensions에서 AuthContext를 전혀 읽지 않는 유일한 클라이언트 대상 모델 라우팅 핸들러여서, 백엔드 집합 A로 범위가 제한된 키가 요청한 모델을 B가 제공하기만 하면 압축(compaction)을 통해 비허용 패스스루 백엔드 B(OpenAI / Azure)에 도달할 수 있었습니다. compact_response는 이제 create_response를 정확히 미러링합니다: allow_list_from_auth로 키별 허용 목록을 도출하고, 헬스 체크나 패스스루 전에 모델의 후보 백엔드를 그 목록으로 필터링하며, 모델은 존재하지만 키가 그 모델을 제공하는 어떤 백엔드로도 라우팅할 수 없을 때 결정론적 403 permission_error를 반환합니다. 필터가 백엔드 전달보다 먼저 실행되므로 업스트림 접촉 없이 거부됩니다.

문서

  • config.yaml.example, src/services/streaming/mid_stream_config.rs rustdoc, docs/en/configuration/advanced.md에서 mid_stream_fallback.enabled가 mid-stream 버퍼링을 비활성화하거나 메모리를 줄이지 않는다는 점을 명확히 했습니다. 값과 무관하게 StreamAccumulator는 여전히 생성되어 스트림당 약 100KB까지 버퍼링하고, mid-stream 폴백은 여전히 백엔드 실패 시 활성화됩니다 (#678, closes #675). 이 플래그는 폴백 요청의 continuation 모드와 restart 모드 중 하나를 선택할 뿐입니다. 버퍼링과 메모리의 실제 kill-switch는 fallback.enabled: false이거나 fallback.fallback_chains에서 모델을 빼는 것입니다. 한국어 문서(docs/ko/configuration/advanced.md)에는 대응하는 Mid-Stream Fallback 섹션이 없어 이 명확화에 대한 한국어 변경은 없지만, #680이 새 토글을 다루는 번역된 "미드스트림 버퍼링 비활성화" 하위 섹션을 별도로 추가했습니다.
  • API 키별 allowed_backends 허용 목록을 보안 문서(영어와 한국어)에 문서화했으며, 이제 닫힌 x-api-key Anthropic Messages 제한 사항도 포함합니다 (#677, #684).

테스트

  • resolve_allowed_backend_name_for_model의 결정론적 단위 테스트: 허용 목록이 해석된 백엔드를 제외하면 None 반환, 포함하면 Some 반환, 빈 목록은 Some 반환, None은 필터 없는 해석기와 일치, 해석 불가능한 모델은 None 반환 (#683).
  • 차단 인증 뒤에서 /v1/responses/compact를 구동하는 tests/per_key_backend_access_test.rs 통합 테스트: 비허용 백엔드만 제공하는 모델을 요청한 제한 키는 403 permission_error를 받고(보안 사례), 허용된 백엔드의 모델을 요청한 제한 키는 필터를 통과합니다 (#677).
  • fallback.enabled, mid_stream_enabled, 체인 존재 여부 사이의 3-way 결정 매트릭스를 커버하는 새 decide_streaming_fallback_dispatch 헬퍼 단위 테스트 (#680).

v1.7.1 - 2026-05-28

수정됨

  • Anthropic web_search_20250305 서버 도구 에뮬레이션이 더 이상 공급자 실패를 빈 결과 집합으로 가리지 않습니다. API 키 누락, HTTP 401/403/429, 타임아웃, 파싱 오류는 이제 매핑된 error_code(HTTP 429는 "too_many_requests", 그 외 모든 실패는 "unavailable")를 가진 web_search_tool_result_error 콘텐츠 블록으로 클라이언트에 표면화됩니다. 진짜로 비어 있지만 성공한 검색은 계속 빈 content: [] 배열을 내보내므로 두 결과는 여전히 구분 가능합니다. 비스트리밍과 스트리밍 SSE 코드 경로 모두 커버되며, 스트리밍 오류 경로를 지키는 명시적 SSE 이벤트 순서 단언이 포함됩니다. Serper 응답 형태 drift(깨끗이 파싱되지만 organic 키가 없는 본문)는 이제 감지되어 공급자 태그가 붙은 warn!으로 로깅되며, 관찰된 최상위 키만 기록하고 사용자 쿼리 텍스트는 절대 기록하지 않습니다. (#671, #672, #673)

의존성

  • lru 0.16 → 0.18, reqwest 0.13.3 → 0.13.4, rusqlite 0.39 → 0.40(libsqlite3-sys 0.37 → 0.38, CI가 Rust 1.95를 실행하게 되어 핀 해제)으로 bump, 그리고 전이적 lockfile 갱신(aws-lc-rs 1.16.3 → 1.17.0, h2 0.4.13 → 0.4.14, http 1.4.0 → 1.4.1, hyper/axum/reqwest 연쇄 개정). cargo audit은 의존성 그래프 전체에서 취약점 0건을 보고합니다 (#669, #670).

v1.7.0 - 2026-05-26

추가됨

  • Bearer 토큰 기반 AWS Bedrock Claude 백엔드 Phase 1 (bedrock-mantle) (#616, closes #613)
  • serde 별칭(aws-bedrock, bedrock-anthropic, AwsBedrock, ...)을 가진 새 type: bedrock 백엔드. is_commercial()은 true를 반환하고 owned_by()는 Some("anthropic")을 반환해, OpenAI 형식 클라이언트가 기대하는 모델 계보를 보게 됩니다.
  • endpoint_type: mantle(기본값)은 리전 템플릿 https://bedrock-mantle.{region}.api.aws에서 네이티브 Anthropic Messages API를 사용하고, /anthropic/v1/messages로 라우팅하며, Authorization: Bearer를 사용하고, anthropic-version을 생략합니다(있으면 Bedrock이 HTTP 400을 반환). 명시적 url: 필드가 프록시와 테스트를 위해 템플릿을 덮어쓰고, 빈 리전이나 대문자 리전은 로드 시 거부됩니다.
  • model_ids.rs는 일반(anthropic.<family>), 지리적(us./eu./jp./au.), 글로벌(global.anthropic.<family>), 전체 ARN 식별자를 인식하며, 지역 접두사가 실제 청구와 레지던시 결과를 수반하므로 자동 별칭 매핑 없이 변경 없이 전달합니다.
  • 기존 OpenAI/Anthropic 본문 변환과 Anthropic SSE 스트림 변환기를 변경 없이 재사용하므로, 모델별 특이 동작(Opus 4.7 샘플링 파라미터 금지, 적응형 사고)이 중복 없이 Bedrock에 동일하게 적용됩니다. endpoint_type: runtime은 여기서는 예약만 되어 있고 아래 Phase 2에서 구현됩니다.

  • 요청 경로 속도 제한이 이제 실제로 강제되고, Redis 스토리지 백엔드가 추가되었습니다 (#635, #632, closes #626)

  • state.rs가 이전에 initialize_rate_limiting이 반환한 MiddlewareLayer를 버려서 rate_limiting.* 설정이 조용한 no-op였습니다. 이제 레이어가 ServiceHandles → ContinuumRouterBuilder::build_router → Router::layer를 거쳐 조립된 Axum 앱에 부착되므로, 설정된 예산이 실제로 429를 반환합니다.
  • 다섯 개 rate_limiting 차원이 모두 선택 사항이 되었습니다: 이미 선택 사항이던 per_api_key/per_model에 per_client, per_backend, global이 합류합니다. 운영자는 어떤 차원이든 생략해 비활성화 상태로 로드할 수 있고, Default impl은 세 차원을 그대로 유지하므로 기존 배포는 영향받지 않습니다 (#632).
  • 새 redis-cache feature 게이트의 rate_limit_v2::redis_backend는 토큰 버킷과 슬라이딩 윈도우 Lua 스크립트를 각각 단일 원자적 EVAL로 실행하고, cr:rl:per_client:10.0.0.1 같은 키로 공유 create_redis_pool 헬퍼를 재사용합니다. 어떤 Redis 실패(풀 사용 불가, 타임아웃, Lua 오류)에서든 백엔드는 BackendUnavailable을 보고하고 호출자는 인프로세스 토큰 버킷으로 폴백해, 요청을 떨구는 대신 레플리카별 강제로 degrade합니다.

  • 교차 공급자 폴백이 이제 요청 디스패치와 핫 리로드에 연결되었습니다 (#631, #637, #665)

  • 완성된 src/core/fallback/ 모듈(약 4,900줄, 단위 테스트 36개)이 요청 경로에서 한 번도 호출되지 않아, 설정된 fallback.fallback_chains가 조용한 no-op였습니다. FallbackService가 이제 chat_completions(웹 검색 제외), completions, embeddings, rerank, sparse_embeddings, 이미지 생성에 대해 실행되며, execute_with_optional_fallback 래퍼(폴백 미설정 시 오버헤드 없음), X-Fallback-* 응답 헤더, executor bound를 충족하는 From<RouterError> → TriggerReason 매핑을 포함합니다 (#631).
  • fallback.fallback_chains와 fallback.fallback_policy 변경이 이제 핫 리로드 구독자를 통해 FallbackService::update_config로 런타임에 적용됩니다. fallback.enabled 토글은 여전히 재시작 전용이며, config.yaml.example에 그렇게 문서화되어 있습니다 (#637, #665).

  • 인터랙티브 데스크톱 사용을 위한 POST /v1/models/refresh 강제 새로고침 엔드포인트 (#593, #664)

  • ModelCache(all_models 키)를 비우고 응답 전에 설정된 모든 백엔드에서 동기적으로 재집계해, GET /v1/models와 같은 {"object":"list","data":[...]} 형태를 반환합니다. 데스크톱 클라이언트(예: backend.ai-go "Refresh models" 버튼)는 두 번째 왕복 없이 응답을 즉시 사용할 수 있습니다.
  • 검증된 API 키별로 속도 제한되며, 익명 또는 잘못된 토큰 호출자는 하나의 글로벌 익명 버킷을 공유합니다: 5초 버스트 윈도우당 3회, 분당 12회. 제한을 초과한 호출자는 429 Too Many Requests를 받습니다. 각 호출이 설정된 모든 백엔드에서 업스트림 fetch를 유발하므로 일반 목록 엔드포인트보다 의도적으로 엄격합니다.
  • 새 model_aggregation.allow_force_refresh: bool 설정 필드(기본값 true)로 게이트됩니다. false로 설정하면 엔드포인트가 403 Forbidden을 반환하므로, 클라이언트가 TTL 기반 만료에 의존해야 하는 강화된 배포에 적합합니다.
  • 각 새로고침은 가능하면 검증된 API 키 ID를, 아니면 anonymous를 INFO 레벨로 로깅해 감사 상관관계를 지원합니다.
  • config.yaml.example이 데스크톱 임베디드 가이드로 확장되었습니다: backend.ai-go 스타일 임베디드 프록시를 위한 cache_ttl: 10, soft_ttl_ratio: 0.5, allow_force_refresh: true.
  • ModelAggregationService의 새 force_refresh(state) 헬퍼가 비우고 다시 집계하는 흐름을 캡슐화합니다. allow_force_refresh() 접근자가 설정 플래그를 핸들러에 노출합니다.

  • AWS Bedrock Claude 백엔드 Phase 2: SigV4 + AWS 바이너리 event-stream을 사용하는 bedrock-runtime (#614)

  • type: bedrock 백엔드의 새 endpoint_type: runtime 값은 https://bedrock-runtime.{region}.amazonaws.com/model/{modelId}/invoke[-with-response-stream]을 대상으로 합니다. 라우터는 각 요청을 AWS Signature V4(service: "bedrock")로 서명하고, OpenAI → Anthropic 본문을 "anthropic_version": "bedrock-2023-05-31"로 감싸고, 최상위 "model" 필드를 제거하며(Bedrock은 URL 경로에서 모델 ID를 취함), 모델 식별자를 경로에 퍼센트 인코딩해 버전 있는 파운데이션 ID(anthropic.claude-3-5-sonnet-20240620-v1:0)와 전체 ARN이 깔끔하게 왕복합니다.
  • 스트리밍 응답은 AWS application/vnd.amazon.eventstream 바이너리 프레임 형식으로 도착합니다. 새 bedrock::event_stream::EventStreamDecoder가 여러 TCP read에 걸친 프레임을 재조립하고, 각 chunk 페이로드를 base64 디코딩하며, 기존 AnthropicStreamTransformer가 OpenAI 형태 SSE로 번역할 수 있도록 합성 event: <type>\ndata: <json>\n\n SSE 바이트를 내보냅니다. 예외 프레임(ThrottlingException, ValidationException, ...)은 조용히 버려지는 대신 합성 event: error SSE 청크로 표면화됩니다.
  • AWS 자격 증명은 이 순서로 해석됩니다: 인라인 auth.aws.access_key_id + auth.aws.secret_access_key(+ 선택적 session_token), 그다음 auth.aws.profile을 통한 명명된 프로파일, 그다음 표준 AWS 체인(환경 변수, 공유 설정, IMDS, IRSA / EKS pod identity, ECS task role). 해석기 앞에 aws_credential_types::provider::SharedCredentialsProvider를 두어 임시 자격 증명이 요청 사이에 투명하게 갱신됩니다.
  • BackendAuthConfig의 새 BackendAuthType::Sigv4 변형과 auth.aws 아래의 AwsAuthConfig 서브 블록이 추가되었습니다. 둘 다 Debug redaction을 거치므로 정적 자격 증명이 로그로 새지 않습니다. BackendAuthType은 YAML 표기로 sigv4, aws_sigv4, aws-sigv4를 받습니다.
  • runtime 헬스 체크는 단일 토큰 본문으로 POST /model/{probe_model}/invoke를 프로브합니다. HTTP 2xx, 400, 401, 403, 429 모두 정상으로 간주되는데, AWS surface가 도달 가능함을 증명하고 인증/과금 문제는 운영자가 별도로 해결할 수 있기 때문입니다.
  • 모든 AWS SDK 크레이트(aws-sigv4, aws-smithy-eventstream, aws-credential-types, aws-config)는 새 선택적 bedrock-sigv4 Cargo feature 뒤에 있습니다. 기본 빌드는 이들을 끌어오지 않으며, feature 없이 endpoint_type: runtime을 설정하면 AWS 경계에서 실패하는 대신 리빌드 플래그를 가리키는 명확한 오류를 반환합니다. Phase 1 mantle 경로는 영향받지 않고 feature 유무와 무관하게 동작합니다.
  • src/proxy/backend.rs와 src/http/handlers/anthropic/handler.rs의 프록시 헤더 정책이 endpoint_type에 따라 분기합니다: Bedrock-mantle은 Authorization: Bearer를 유지하고, Bedrock-runtime은 Backend trait 구현이 각 요청을 SigV4로 서명하므로 정적 Bearer 주입을 억제합니다.
  • docs/{en,ko}/configuration/backends.md의 영어와 한국어 문서가 새 endpoint_type: runtime 설정, 빌드 요구 사항, 자격 증명 체인, IAM 정책 스니펫, 지리/글로벌 프로파일 동작, 스트리밍 파이프라인 개요로 제자리에서 확장되었습니다.

  • 요청 게이트 하이브리드 모델 thinking 변환과 함께 EXAONE 4.0 (vLLM) 등록 (#640, refs #639)

  • EXAONE 4.0(예: EXAONE-4.0-32B-FP8-RNGD)은 하이브리드 reasoning 모델입니다. reasoning 모드에서는 단독 </think>로 끝나는 chain-of-thought를 content에 인라인으로 스트리밍하고, 비 reasoning 모드에서는 </think> 없이 일반 답변을 내보냅니다.
  • assume_reasoning_first(unterminated_start) 변환이 이제 HTTP와 Unix 소켓 스트리밍 결정 지점 모두에서 요청이 실제로 thinking을 켰는지(chat_template_kwargs.enable_thinking 또는 최상위 enable_thinking, 보수적 기본값 false)에 게이트되므로, 비 reasoning 모드가 더 이상 전체 답변을 빈 content와 함께 reasoning_content로 내보내지 않습니다. 실제 <think> 마커를 키로 쓰는 표준 패턴 모델은 영향받지 않습니다.
  • unterminated_start 설정으로 exaone-4.0-32b를 등록합니다. 서빙되는 -RNGD 이름은 아래의 하드웨어 접미사 peel로 해석됩니다.

  • 모델 ID 매칭의 NPU/가속기 하드웨어 변형 접미사 정규화 (#662)

  • is_recognized_format_token()에 HARDWARE/ACCELERATOR 카테고리(rngd, warboy, atom, atommax, rebel)를 추가해, FuriosaAI(RNGD/WARBOY)와 Rebellions(ATOM/ATOMMAX/REBEL) 서빙 타깃 접미사가 모델별 alias 없이 기존 계층적 peel 체인을 통해 정규 base 메타데이터 항목으로 정규화됩니다. layered_format_strip()이 peel 전에 소문자화하므로 런타임이 내보내는 대문자 이름도 정확히 해석됩니다.
  • 정확한 id와 정확한 alias phase가 peel보다 먼저 실행되므로, *-atom이나 *-rebel로 정당하게 등록된 모델은 먼저 정확한 매칭으로 승리합니다. 배포된 model-metadata.yaml의 grep으로 현재 충돌이 0건임을 확인했습니다. EXAONE-4.0-32B-FP8-RNGD는 이제 peel(-rngd 다음 -fp8)로 exaone-4.0-32b에 정규화됩니다.

변경됨

  • 폴백 핸들러의 LM Studio 호환 shim을 좁혀 /와 /v1/models만 200을 반환하고, 그 외 매칭되지 않은 모든 라우트는 이제 404를 반환합니다. JSON 오류 본문 형태는 변경 없이 보존되므로, 본문을 이미 읽고 있는 소비자는 계속 동작합니다 (#628).

수정됨

  • 알 수 없는 키에 HTTP 400 INVALID_ARGUMENT를 반환하는 Gemini /v1beta/openai/chat/completions 엔드포인트로 전달하기 전에 비 OpenAI 최상위 필드 7개(chat_template_kwargs, thinking_budget_tokens, enable_thinking, preserve_thinking, top_k, min_p, repeat_penalty)를 제거합니다. extra_body 탈출구는 건드리지 않고 reasoning_effort는 유지됩니다(Google이 thinking_level로 매핑). 또한 3-flash가 3.5-flash에 부분 문자열 매칭되지 않았으므로 3.5-flash thinking 비활성화 및 is-thinking 매처도 확장했습니다 (#642).
  • 모든 Anthropic 핸들러 코드 경로(네이티브 HTTP/Unix, Bedrock mantle/runtime, OpenAI 호환, Responses API)의 스트리밍과 비스트리밍 모두에 요청 통계 기록을 연결하고, raw 패스스루 SSE에서 입력/출력 토큰을 누적하는 AnthropicStreamUsageTracker를 추가했습니다 (#627, #634).
  • mid-stream 비활성 감지에 하드코딩된 60초 대신 설정된 timeouts.request.streaming.chunk_interval을 사용하고 제한된 keep-alive를 내보내, 침묵하는 백엔드가 keep-alive 주석을 영원히 내보내는 대신 StreamOutcome::Failed를 통해 다음 폴백 모델로 전진하게 했습니다 (#633).
  • Option<String> 필드가 base 설정 위로 병합되는 새 StreamingTimeoutOverride/StandardTimeoutOverride 구조체로 부분적인 model_overrides.<model>.streaming/standard 블록을 허용해, --generate-config 출력 경로의 YAML 파싱 실패를 수정했습니다 (#630).
  • admin/metrics/metrics-persistence/webui import와 함수를 해당 Cargo feature 뒤로 게이트하고, 항상 컴파일되는 호출자가 쓰는 공개 surface를 미러링하는 #[cfg(not(feature = "metrics"))] no-op 메트릭 스텁을 추가해, feature를 줄인 빌드가 깔끔하게 컴파일됩니다 (#629, #666, closes #636).
  • 강제 새로고침 속도 제한을 강화해 익명과 잘못된 토큰 호출자가 하나의 글로벌 버킷을 공유하므로, 스푸핑된 Authorization/X-Forwarded-For/X-Real-IP 헤더로 예산을 우회할 수 없습니다.
  • 메트릭 히스토리 쿼리 제한, UTF-8 안전 메트릭 레이블 절단, 타입 있는 SigV4 구현을 통한 Bedrock runtime 라우팅 (#608, #609, #613, #614 후속).

문서

  • docs/{en,ko}/configuration/backends.md에 Bedrock 백엔드(리전 선택, 지리적 vs 글로벌 인퍼런스 프로파일, 모델 ID 형식, 자격 증명 체인, IAM 정책 스니펫, 스트리밍 파이프라인)를 문서화하고, docs/en/api.md에 Force-Refresh Models 섹션을 추가하고, config.yaml.example을 데스크톱 임베디드 모델 집계 가이드와 폴백 핫 리로드 주석으로 확장했습니다.

테스트

  • transform_payload_for_openai의 negative 및 positive 사례 커버리지 (#661).
  • 문서화된 버킷 리셋 동작을 포함한 속도 제한 미들웨어 핫 리로드 테스트와, 실제 ContinuumRouter를 빌드해 버스트 소진 시 429가 발생함을 단언하는 router_wiring_tests (#635, #638, #667).
  • web_search 주입이 패스스루 계약과 올바르게 상호작용하는지 검증 (#663).
  • MLxcel 스트리밍 패스스루 통합 테스트 (#659).
  • Bedrock 단위 및 통합 커버리지: serde 별칭, URL 템플릿, 헤더 정책, 모델 ID 파싱(지리/글로벌/ARN), runtime SigV4, wiremock 서버로 구동되는 event-stream 프레임 디코딩 (#616, #614).

의존성

  • tokio 1.52.1 → 1.52.3, tower-http 0.6.8 → 0.6.11, dashmap 6.1.0 → 6.2.1, serde_json 1.0.149 → 1.0.150, aws-config 1.8.16 → 1.8.17, aws-sigv4 1.4.3 → 1.4.4, aws-smithy-types 1.4.7 → 1.4.8로 bump (#619, #658).

v1.6.3 - 2026-05-12

추가됨

  • API 키별 LLM 토큰 사용량 메트릭 (#608, #610)
  • API 키, 모델, 백엔드, 토큰 종류별로 실제 프롬프트와 컴플리션 토큰 소비를 기록하는 새 Prometheus llm_tokens_total{api_key_id, model, backend, kind} 카운터. 핫 패스 카운터의 레이블 집합은 의도적으로 최소한으로 유지되며, 추가 차원은 아래의 동반 info-metric에 있습니다.
  • 동반 api_key_info{api_key_id, ...} info-metric은 API 키별 주석 레이블(예: email, team, environment)의 설정 가능한 allowlist를 노출하므로, 대시보드가 핫 패스 카운터의 레이블 집합을 부풀리지 않고 표준 PromQL * on(api_key_id) group_left(...) 조인으로 토큰 카운터를 그룹화/필터링할 수 있습니다.
  • derive_api_key_id는 설정된 id(인증 레이어가 요청을 매칭한 경우) 또는 raw bearer 토큰의 SHA-256 첫 12 hex 접두사 k_<hex>를 반환합니다. raw 키는 절대 레이블로 사용되지 않습니다. 전용 ApiKeyCardinalityTracker(기본 캡: 고유 키 ID 1000개)가 레이블 카디널리티 폭발을 방지합니다.
  • ApiKeyConfig와 인메모리 ApiKey에 annotations: HashMap<String, String> 필드가 추가됩니다. MetricsConfig에는 annotation_labels: Vec<String>이 추가되는데, api_key_info의 레이블로 구체화되는 allowlist입니다. 예약된 정규 주석 키(email, uuid, owner, team, environment)가 문서화되어 있고, 운영자는 커스텀 키를 추가할 수 있습니다.
  • 스트리밍과 비스트리밍 경로는 StreamTransformConfig의 새 StreamObservabilityContext 필드를 통해 기존 usage 파싱 지점에서 기록합니다. 이 필드는 handle_anthropic_streaming / handle_gemini_streaming / handle_successful_backend_response를 관통해, OpenAI 호환 / Anthropic / Gemini / thinking-pattern 스트리밍 응답 빌더가 모두 중복 파싱 없이 카운터를 내보냅니다. 라우터는 OpenAI 호환 백엔드에 이미 stream_options.include_usage=true를 주입하므로, 클라이언트 opt-in과 무관하게 스트리밍 메트릭이 균일하게 동작합니다.
  • api_key_info는 시작 시 metrics.annotation_labels에서 한 번 초기화됩니다. 레이블 이름은 등록 시점에 고정됩니다(Prometheus는 레이블 이름 변경을 허용하지 않음). 주석 값은 기존 config-watch 경로를 통해 핫 리로드되며, admin 작업이 동기화를 유지하도록 load_from_config, add_key, remove_key_by_id에서 ApiKeyStore::refresh_info_metric이 호출됩니다.
  • 모든 레이블 값은 CardinalityManager / sanitize_label_value를 거칩니다. 주석 값은 @, +, :를 보존하는 약간 덜 엄격한 sanitize_annotation_value를 사용해, 이메일과 네임스페이스 식별자가 깔끔하게 왕복합니다.
  • 설정 가능한 보존 기간을 가진 SQLite 기반 영속 로컬 메트릭 로그 (#609, #611)
  • WAL 모드, prepared-statement 캐시, PRAGMA user_version 스키마 버저닝을 갖춘 src/metrics/persistence/ 아래의 새 MetricsStore async trait + 번들 rusqlite v1 구현(store.rs / sqlite.rs / snapshot.rs / snapshot_task.rs). 히스토그램과 서머리는 샘플별 행 형태로 풀어집니다.
  • 카운터와 게이지는 시작 시 절대 복원되지 않습니다. 영속 로그는 별도의 읽기 경로이므로, 라이브 /metrics 엔드포인트는 Prometheus의 단조 카운터 의미론을 유지합니다. 과거 샘플은 새 GET /admin/metrics/history?metric=...&from=...&to=... surface(src/admin_metrics_history.rs)로 읽으며, 런타임에 영속화가 비활성화되면 404를, feature가 컴파일되지 않았으면 503을 반환합니다. v1에는 PromQL이 없습니다.
  • src/server/serve.rs의 핫 리로드 파이프라인이 설정 변경을 PersistenceCommand::{SetSnapshotInterval, SetRetentionDays, SetCompaction} 메시지로 번역해, 진행 중인 스냅샷을 떨구지 않고 ticker와 prune cutoff를 원자적으로 재구성합니다.
  • 사실상 일일 타이머에 불과한 작업에 전체 cron 크레이트를 끌어오지 않도록, 압축 스케줄은 minute hour * * * cron 서브셋을 따릅니다.
  • 기본값은 enabled: true이며 metrics.persistence.enabled: false로 끕니다. redb와 duckdb 변형은 YAML 스키마의 예약 키워드로, 구현이 생기기 전까지 시작 시 NotImplemented를 반환합니다.
  • 디스크 사용량: 합성 100-series × 10-snapshot 워크로드에서 샘플당 약 119바이트 측정(tests/metrics_persistence_test::disk_usage_smoke_check_under_synthetic_load 참조). 공식은 docs/en/persistent-metrics.md와 config.yaml.example에 문서화되어 있습니다.

수정됨

  • release 빌드의 타입 추론 실패를 막기 위해 with_label_values의 토큰 사용량 레이블 값을 &str로 강제합니다. &String 레이블 변수를 &str 리터럴("prompt" / "completion")과 섞으면 컴파일러가 &[&String]을 선택해 리터럴을 거부했습니다. #610에서 도입된 회귀입니다.

문서

  • 두 메트릭 기능의 한국어 번역 (#612)
  • docs/ko/metrics.md: llm_tokens_total, api_key_id 도출, annotation_labels allowlist, api_key_info info-metric, PromQL 예시, Grafana 패널, 검증 단계를 다루는 새 ### API 키별 LLM 토큰 사용량 섹션.
  • docs/ko/persistent-metrics.md: docs/en/persistent-metrics.md를 번역한 새 페이지(SQLite 기반 스냅샷 의미론, 설정 필드, 디스크 사용량 공식, /admin/metrics/history surface, 스키마 레이아웃, 운영 노트).
  • docs/ko/admin-api.md: Stats와 Response Cache 사이에 ## 지속 메트릭 로그 API 섹션을 삽입하고 대응하는 TOC 항목 추가.
  • zensical.ko.toml: 한국어 사이드바에서 페이지에 도달할 수 있도록 운영 아래에 지속 메트릭 로그 nav 항목 추가.
  • 메트릭 정의, api_key_id 도출 규칙, 주석 설정 스키마, 카디널리티와 핫 리로드 의미론, 예시 PromQL(이메일별 토큰, top-10 키, 팀별 롤업), Grafana 패널 예시, 검증 단계를 다루는 새 docs/en/metrics.md ### Per-API-Key LLM Token Usage 섹션. config.yaml.example에는 문서화된 metrics.annotation_labels 블록과 각 API 키 항목 아래의 annotations: 예시가 추가되었습니다.

테스트

  • API 키별 토큰 사용량 단위 커버리지: derive_api_key_id 우선순위(설정된 id 우선, 없으면 해시, 없으면 anonymous), 결정론, 해시 형식 ^k_[0-9a-f]{12}$, 주석 레이블 정규화, info 게이지 1회 초기화, refresh 원자성, 카디널리티 경계, 이메일 보존 주석 sanitizer; 스트리밍 변환기 write-through; 미들웨어 주석 스냅샷 노출; tests/metrics_integration_test.rs의 통합 커버리지(두 kind를 모두 가진 4-label 카운터, 익명 폴백, 해시 정규식). (#610)
  • 영속 메트릭 SQLite 스토어 단위 커버리지(insert, 시간 범위 쿼리, 보존 삭제, 멱등 open, unknown-kind 왕복)와 tests/metrics_persistence_test.rs의 통합 커버리지(스냅샷 태스크가 SQLite에 행을 기록, 보존이 오래된 샘플만 prune, 보존 핫 리로드가 진행 중 스냅샷을 보존, 디스크 사용량 스모크 체크). (#611)

v1.6.2 - 2026-05-10

수정됨

  • /v1/responses와 /v1/chat/completions가 이제 OpenAI reasoning-API의 developer 역할을 허용합니다 (#603, #605, #606)
  • serde lowercase rename을 가진 MessageRole::Developer를 추가해 "developer"가 일급 변형으로 역직렬화됩니다. 이전 실패는 알 수 없는 역할 이름을 밝히는 대신 오해를 부르는 did not match any variant of untagged enum ResponseInput으로 표면화됐는데, 암묵적 메시지 역직렬화 오류가 이제 문제의 역할 문자열을 명시하고 유효한 역할 목록을 나열합니다.
  • 백엔드별 번역: OpenAI 호환 서버에는 developer 그대로 패스스루; Anthropic은 최상위 system 파라미터로 병합(system과 developer 텍스트가 모두 있으면 \n\n으로 연결되어 기존 덮어쓰기 버그 수정); Gemini는 system_instruction으로 병합; Ollama는 system으로 매핑(구버전 빌드가 developer를 거부).
  • Chat Completions → Responses 변환기가 developer를 instruction을 담는 역할로 인식합니다: 첫 번째 등장은 최상위 instructions가 되고, 이후 등장은 원래 역할을 와이어에 유지한 input 항목으로 남습니다.
  • 문자열 기반 인식 사이트 전반에서 developer와 system을 동등하게 취급합니다: prefix-cache 키 추출, 교차 공급자 폴백 번역, OpenAI-to-Anthropic 변환의 시스템 콘텐츠 추출, global-prompt injector의 기존 시스템 메시지 lookup, smart-routing 분류기 / LLM 프롬프트 빌더.

문서

  • 문서 사이트를 MkDocs에서 Zensical로 마이그레이션하고 브랜드 스타일 복원 (#602)
  • mkdocs.yml과 mkdocs.ko.yml을 제거하고 네이티브 zensical.toml과 zensical.ko.toml로 대체. 둘 다 Zensical의 TOML 스키마에 따라 [project] 네임스페이스 아래에 루트를 두고, 확장별 옵션은 dict로 [project.markdown_extensions] 안에 둡니다(Zensical 설정 로더는 별도의 mdx_configs 테이블을 무시).
  • Zensical이 에셋 디렉터리의 심볼릭 링크를 따라가지 않으므로, docs/en/shared와 docs/ko/shared 심볼릭 링크를 각 빌드 전에 실행되는 rsync -a --delete docs/shared/ docs/{en,ko}/shared/로 교체.
  • Zensical이 문서화한 primary = "custom" 메커니즘과, 오렌지 CSS 변수를 정의하는 docs/shared/stylesheets/extra.css의 [data-md-color-scheme="default"][data-md-color-primary="custom"] 셀렉터로 lablup 브랜드 컬러 등록.
  • Mermaid는 이제 호환되지 않는 mermaid2 플러그인에 의존하는 대신 pymdownx.superfences 커스텀 펜스로 등록. favicon이 없으면 logo.png로 폴백.
  • 아이콘, 다이어그램, 브랜드 컬러의 Zensical 렌더 출력 복원 (#604)
  • 제거된 materialx를 대체하는 zensical.extensions.emoji twemoji 인덱스/생성기로 pymdownx.emoji를 다시 활성화해, :material-*: 아이콘 문법이 리터럴 텍스트로 렌더링되지 않게 함.
  • <!-- diagram: PATH --> ... <!-- /diagram --> ASCII 대체를 Python-Markdown 확장(docs/hooks/diagram_extension.py)으로 재구현. Zensical이 MkDocs hook 라이프사이클을 노출하지 않아 기존 MkDocs on_page_content hook이 실행되지 않기 때문입니다. Zensical의 콘솔 스크립트 엔트리 포인트에서 확장을 import할 수 있도록 docs/__init__.py를 추가하고 빌드 앞에 PYTHONPATH=.를 붙임.
  • 커스텀 팔레트에 --md-primary-bg-color를 설정하고 .md-header / .md-tabs를 오버라이드해, Zensical의 modern 레이아웃 위에 오렌지 브랜드 밴드가 칠해지게 함.
  • 두 TOML 모두에서 nav 테이블을 첫 [project.X] 서브 테이블 위로 이동해 [[project.extra.social]] 아래로 조용히 파싱되지 않게 함(알파벳순 폴백이 정렬되지 않은 상단 메뉴와 잘못된 prev/next 푸터 이웃을 만들고 있었음).

테스트

  • 스트리밍과 비스트리밍 경로 모두에서 Anthropic 변환의 system/developer 연결에 대한 회귀 커버리지, 그리고 다섯 백엔드 전체에 대한 developer 역할의 백엔드별 변환기 매핑과 Chat Completions → Responses 변환기의 developer-then-system 순서 (#605, #606).

의존성

  • redis 1.2.0 → 1.2.1로 bump (#598).

v1.6.1 - 2026-05-07

수정됨

  • Claude Opus 4.7(claude-opus-4-7)이 이제 Anthropic 백엔드를 통해 올바르게 라우팅됩니다 (#599, #600, #601)
  • 적응형 사고 API 게이트(uses_adaptive_thinking_api)를 4.7 계열 모델 ID로 확장. Claude Opus 4.7은 thinking.type == "adaptive" + output_config.effort를 요구하며, 레거시 budget_tokens 형태를 보내면 HTTP 400이 발생합니다.
  • 4.7 계열 요청 형태 규칙을 위한 model_requires_adaptive_thinking과 model_forbids_sampling_params 술어 추가: 명시적 수동 thinking은 적응형 사고로 정규화되고, temperature, top_p, top_k는 전달 전에 무조건 제거됩니다.
  • opus_supports_max_effort를 Opus 4.7로 확장해, xhigh reasoning effort가 Opus 4.7에서 output_config.effort = "max"로 매핑됩니다.
  • claude-opus-4-7과 claude-opus-4-7-latest를 내장 지원 모델 목록과 model-metadata.yaml에 추가. 추측성 claude-sonnet-4-7 항목은 Anthropic이 공개하기 전까지 의도적으로 광고하지 않습니다(사용자 제공 설정을 위한 방어적 요청 형태 매칭은 유지).

문서

  • reasoning-effort 문서(EN + KO)와 backends.md를 업데이트해, Claude 4.7 패밀리의 적응형 사고 요구 사항과 무조건적인 샘플링 파라미터 deprecation을 다룹니다 (#600).

테스트

  • Opus 4.7 적응형 사고와 무조건적 샘플링 파라미터 제거에 대한 Responses API 회귀 커버리지; Opus 4.6 / Sonnet 4.6 / Haiku 4.5 / Haiku 3.5에 대한 negative 회귀를 포함한 4.7 패밀리의 두 변환 경로(Chat Completions와 Responses) (#600, #601).

v1.6.0 - 2026-05-04

추가됨

  • ChatGPT 구독 / Codex 백엔드 OAuth 디바이스 플로우 인증 (#551, #592)
  • continuum-router auth login --backend <name> 명령이 OpenAI Codex 3단계 헤드리스 디바이스 코드 플로우를 실행합니다. POST /api/accounts/deviceauth/usercode로 일회성 user_code를 발급받고, POST /api/accounts/deviceauth/token을 폴링한 뒤, /oauth/token에서 PKCE 교환을 진행합니다. 표준 RFC 8628 디바이스 플로우는 향후 다른 공급자가 구현할 경우 그대로 쓸 수 있도록 남겨두었고, 새 OpenAICodexDeviceFlowClient는 provider: openai에서 자동으로 선택됩니다.
  • 토큰은 SecretString으로 감싸져 설정된 token_store에 Unix 0600 모드로 기록되며, O_CREAT|O_EXCL 오픈 + 원자적 rename을 사용합니다. tempfile 접미사에 무작위 성분을 포함해 동시 저장 시 충돌을 방지하고, 중간 실패 시 부분 작성된 파일을 unlink해 비밀 정보가 디스크에 남지 않게 합니다.
  • 액세스 토큰 만료는 JWT exp 클레임에서 파싱하며(JWT가 아닌 토큰은 1시간 폴백), 공급자가 비정상적인 expires_in을 보내도 갱신 폭주를 막도록 사용 가능한 최소값으로 클램프됩니다.
  • 만료 60초 전에 사전 갱신이 발생하며, tokio::sync::Mutex로 single-flight 처리됩니다. 업스트림 백엔드가 401을 반환하면 정확히 한 번 강제 갱신 후 한 번만 재시도합니다. 공급자가 갱신 응답에서 refresh_token을 생략해도 이전 refresh token이 race-free로 보존됩니다.
  • 스트래티지가 identity_fingerprint()(백엔드 이름, client_id, token_store)를 보고하므로, 핫 리로드에서 이 값이 변경되면 이전 인메모리 상태를 조용히 유지하는 대신 스트래티지를 다시 만듭니다.
  • CLI는 verification_uri_complete와 user_code에서 C0/C1 제어 문자를 출력 전에 제거해, 적대적인 공급자 응답이 ANSI 이스케이프로 터미널을 다시 그리지 못하게 막습니다.
  • auth.openai.com과 chatgpt.com/backend-api/codex로 향하는 모든 디바이스 플로우 및 런타임 요청에 originator: codex_cli_rs(auth.oauth.originator로 변경 가능)와 codex_cli_rs/<version> User-Agent(auth.oauth.user_agent로 변경 가능)가 들어가, 공식 Codex CLI와 일치시켜 Cloudflare가 403 JS 챌린지를 반환하는 대신 트래픽을 받아들이게 합니다.
  • YAML에서 auth.type: oauth가 받아들여지며, 레거시 snake_case 표기 o_auth도 함께 인식됩니다. client_id와 scope는 공개된 Codex CLI 값으로 기본 설정되어, ChatGPT 구독 사용 시 token_store만 지정하면 됩니다.
  • Anthropic Messages와 Chat Completions 양쪽 surface 모두 ChatGPT Codex 백엔드로 투명하게 라우팅됩니다 (#592)
  • auth.type이 oauth이고 공급자가 Codex 플로우(현재 openai)를 사용하는 백엔드는 모델별 responses_only 메타데이터와 무관하게 모든 요청을 Responses API로 강제합니다. chatgpt.com/backend-api/codex는 /responses만 노출하고 /chat/completions이 없기 때문에, chat 모양 모델(예: gpt-5.5, alias 매핑된 claude-haiku-4-5)이나 알 수 없는 모델 ID도 모두 /v1/responses → …/backend-api/codex/responses 경로로 dispatch됩니다. OAuth가 아닌 OpenAI 백엔드는 모델별 responses_only 플래그를 그대로 따릅니다.
  • 새 core::url_utils::compose_backend_url이 /v1, /openai, /backend-api/codex 세 OpenAI 호환 루트의 백엔드 URL 합성을 한 곳으로 모았습니다. proxy/backend.rs, http/handlers/responses.rs, http/streaming/handler.rs, services/responses/stream_service.rs, Anthropic 핸들러에 흩어져 있던 ends_with("/v1") || ends_with("/openai") 체크를 대체해, /backend-api/codex 규칙이 일관되게 적용됩니다.
  • 프록시 핫 패스(proxy/backend.rs, proxy/responses_only.rs, proxy/image_gen.rs, proxy/image_edit.rs)는 이제 src/proxy/oauth_helper.rs를 통해 AppState에 노출된 백엔드 이름 키 기반 AuthStrategyRegistry를 거칩니다. 헬퍼가 스트래티지를 조회하고, 전송 직전에 refresh_if_needed()를 호출하며, 정적 bearer 헤더를 스트래티지가 만든 헤더로 교체하고, 401 응답에 한 번만 강제 갱신 + 재시도합니다. 스트래티지가 등록되지 않은 백엔드는 정적 api_key 인증이 변경 없이 계속 동작합니다.
  • Anthropic 호환 핸들러(src/http/handlers/anthropic/handler.rs)도 같은 레지스트리를 조회합니다. OAuth 스트래티지가 있는 백엔드에서는 클라이언트가 보낸 Authorization: sk-ant-…와 x-api-key 헤더를 OpenAI로 bearer로 전달하지 않고 떨어뜨립니다.
  • 모델 fetcher가 OAuth 인증 백엔드를 감지해 /v1/models 프로브 대신 설정된 models 목록으로 폴백합니다. chatgpt.com/backend-api/codex가 모델 엔드포인트를 노출하지 않기 때문입니다.
  • Codex 호환 Responses API 확장 (#536, #537)
  • 컨텍스트 압축용 POST /v1/responses/compact 엔드포인트. OpenAI / Azure OpenAI 네이티브 /v1/responses/compact로 패스스루하며, 다른 백엔드 유형은 501을 반환합니다.
  • ResponsesRequest의 store 필드(기본값 true)가 업스트림 세션 영속화를 제어합니다. Codex는 임시(ephemeral) 요청에 store: false를 보냅니다.
  • input 항목에서 어시스턴트 콘텐츠와 사용자 콘텐츠를 구분할 수 있도록 input_text 옆에 output_text 콘텐츠 파트 타입이 추가되었습니다. 모든 변환기(OpenAI, Anthropic, Gemini)가 새 변형을 처리합니다.

문서

  • 루트 CHANGELOG.md와 한국어 문서(ko/configuration/backends.md, ko/configuration/advanced.md, ko/api.md, ko/architecture.md) 전반에 Codex / Responses-API 확장을 동기화. EN과 KO 빌드 양쪽의 zensical 빌드 경고를 모두 해결하고, pymdownx.slugs.slugify로 toc 앵커 slug의 유니코드를 보존합니다 (#596).
  • 영어와 한국어 mkdocs 소스 전반의 AI-slop 패턴 정리: 산문의 em dash 교체, filler/slop 단어 제거, 끝에 붙는 분사구와 과장된 동사 재작성, 콜론+불릿 AI 스타일 도입부 정리, 마무리 요약 slop을 구체적인 next-action 링크로 교체 (#597).

CI/CD

  • apple-actions/import-codesign-certs를 6에서 7로 bump (#590).

의존성

  • tokio 1.51.0 → 1.52.1, axum 0.8.8 → 0.8.9, reqwest 0.13.2 → 0.13.3, clap 4.6.0 → 4.6.1, fastrand 2.4.0 → 2.4.1, uuid 1.23.0 → 1.23.1, rand 0.10.0 → 0.10.1, lru 0.16.3 → 0.16.4로 bump (#595).

v1.5.6 - 2026-04-29

수정됨

  • responses_only reasoning 모델(gpt-5.4-pro, gpt-5.5-pro)에 대한 /v1/chat/completions 요청이 HTTP 502 responses_parse_failed로 실패하던 문제 수정. OpenAI의 /v1/responses 응답에 { "id": "rs_...", "type": "reasoning", "summary": [] } 모양의 출력 항목이 들어 있는데, OutputItem::Reasoning이 content와 status를 필수로 요구하던 탓에 serde가 missing field 'content'로 거절했습니다. Anthropic Messages surface는 다른 변환 경로로 우회하고 있어서 직접 테스트하기 전까지 버그가 가려져 있었습니다. 이제 OutputItem::Reasoning의 content와 status가 모두 선택 사항이며, reasoning 항목은 기존 정책에 따라 Chat Completions 클라이언트에 도달하기 전에 드롭됩니다. 즉 본문 모양은 deserialize에 성공하기만 하면 됩니다 (#594)

변경됨

  • model-metadata.yaml에서 Gemini 3.1 Pro 패밀리의 정규 ID를 gemini-3.1-pro-preview로 재정렬하고, gemini-3.1-pro(와 기존 -latest / -customtools 형태)는 alias로 강등. generativelanguage.googleapis.com이 실제로 제공하는 ID와 맞춘 결과로, 정규 형태의 gemini-3.1-pro는 업스트림에서 404를 돌려주기 때문에 GA 가용성이 있는 것처럼 보이지 않게 하려는 조정입니다. 메타데이터 캐시는 두 형태 모두 같은 항목으로 해석합니다. 참고로 업스트림으로 보내는 페이로드에서 alias를 정규 ID로 다시 쓰는 작업은 이번 릴리스 범위 밖이라, gemini-3.1-pro alias로 호출하는 클라이언트는 후속 작업이 들어오기 전까지 업스트림 404를 그대로 받게 됩니다 (#594)
  • 샘플 config.yaml에 새로 사용 가능해진 pro / 5.5 패밀리 모델(gpt-5.4-pro, gpt-5.2-pro, gpt-5.5, gpt-5.5-pro, claude-opus-4-7, gemini-3.1-pro, gemini-3.1-pro-preview)을 등록해 responses_only dispatch 경로를 실제 업스트림으로 end-to-end 검증할 수 있게 했고, 중복된 claude-haiku-4-5 항목 제거

v1.5.5 - 2026-04-27

추가됨

  • OpenAI Pro 모델용 투명한 Responses-API 라우팅 (epic #581)
  • model-metadata.yaml과 내장 OpenAI 레지스트리에 responses_only: true capability 플래그를 추가. gpt-5.2-pro, gpt-5.4-pro, gpt-5.5-pro가 업스트림 /v1/responses로만 제공되는 모델로 표시됨 (#574, #582)
  • responses_only 모델에 대한 /v1/chat/completions 요청이 업스트림 /v1/responses 엔드포인트로 dispatch되고, 응답이 strict 모드의 chat.completion(스트리밍은 chat.completion.chunk) 봉투로 다시 변환됩니다. 클라이언트에는 투명합니다. 스트림 usage는 stream_options.include_usage로 제어되며, responses_only 경로에 대한 모델별 latency / success 카운터가 기록됩니다 (#578, #584)
  • responses_only 모델에 대한 /anthropic/v1/messages 요청은 Responses API 모양으로 변환되어 /v1/responses로 dispatch되고, Anthropic Messages JSON(스트리밍은 Anthropic SSE 이벤트 시퀀스)으로 다시 변환됩니다. 도구 호출 왕복, 웹 검색 에뮬레이션, Unix 소켓 전송 모두 같은 플래그에서 분기합니다 (#575, #577, #583, #585, #586)
  • Anthropic Messages ↔ Responses 요청 변환기는 system → instructions, tools, tool_choice(disable_parallel_tool_use → parallel_tool_calls: false 포함), max_tokens → max_output_tokens, reasoning effort 추론, 멀티턴 도구 왕복을 모두 다룹니다. 응답 변환기는 thinking/text/tool_use 순서와 stop-reason 충실도를 그대로 보존합니다 (#575, #583)
  • SSE 스트리밍 브리지(AnthropicResponsesStreamTranslator)는 Responses API 이벤트를 Anthropic Messages 이벤트로 매핑하면서 Anthropic의 엄격한 이벤트 순서 불변량(단일 message_start, 짝지어진 content_block_start/content_block_stop, 종결 message_stop)을 유지합니다. 중간 error / response.failed / response.cancelled, response.incomplete → stop_reason: max_tokens, 지연된 input 토큰, 그레이스풀한 조기 종료 합성도 처리합니다 (#576, #585)
  • /v1/responses는 OpenAI와 Azure OpenAI 백엔드만 제공합니다. responses_only 모델이 다른 백엔드 유형과 짝지어지면 업스트림 호출 전에 400 invalid_request_error가 반환됩니다(/v1/chat/completions와 /anthropic/v1/messages 양쪽에서 거절) (#577, #589)
  • (backend, model) 쌍별 첫 dispatch가 info 레벨로 로깅되므로, 디버그 로그를 켜지 않고도 운영자가 Responses-API 라우팅 여부를 확인할 수 있습니다
  • Anthropic Messages → Responses 요청은 명시적으로 store: false를 보내 업스트림 부작용을 피합니다 (#589)
  • {Anthropic, Chat} × {gpt-5.4-pro, gpt-5.2-pro} × {non-streaming, streaming} × {plain, tool-call, reasoning} 매트릭스, 양쪽 surface의 중간 백엔드 실패 negatives, 업스트림 바이트 단편화 회귀 가드를 다루는 22개 결정론적 in-process 통합 테스트 (#579, #588)
  • docs/en/configuration/advanced.md(Responses-API-only Models 섹션을 Models-marked-out-of-the-box, Marking-a-new-model, Dispatch-behavior, Backend-type-constraint 하위 섹션으로 분리), docs/en/architecture.md(Responses-API Routing 데이터 흐름 다이어그램), docs/en/api.md Chat Completions 및 Anthropic Messages surface 노트(투명한 Responses-API 라우팅 하위 섹션 포함)에 문서화 (#580, #587)

수정됨

  • Chat Completions responses-only 라우팅이 업스트림 dispatch 전에 호환되지 않는 백엔드 설정을 거절하고, 가능한 경우 호환되는 OpenAI/Azure Responses 백엔드를 선택하도록 수정 (#589)
  • Chat assistant tool_calls[]가 /v1/chat/completions의 stateless 도구 결과 턴에 대해 Responses function_call input 항목으로 보존되도록 수정 (#589)

v1.5.4 - 2026-04-25

변경됨

  • 2026년 4월 말 frontier 모델 출시에 맞춰 model-metadata.yaml 갱신 (#572, #573)
  • GPT-5.5 추가 (\(5/\)30 per 1M, 1M 컨텍스트, 지식 cutoff 2025-12, omnimodal, Terminal-Bench 2.0에서 82.7%로 1위)와 GPT-5.5 Pro 추가 (\(30/\)180 per 1M, Responses API 전용, 심층 reasoning) — 2026-04-23 출시
  • DeepSeek V4 Pro 추가 (1.6T total / 49B active MoE, 1M 컨텍스트, 384K 최대 출력, 3가지 reasoning effort 모드)와 DeepSeek V4 Flash 추가 (284B total / 13B active MoE, 1M 컨텍스트, 384K 최대 출력). 공식 API 문서에 따라 deepseek-chat과 deepseek-reasoner는 deprecated alias로 유지 — 2026-04-24 출시
  • gpt-image-2 추가 (이미지당 과금 대신 토큰 단위 과금: 텍스트 \(5/\)30, 이미지 \(8/\)30 per 1M 토큰; 1K/2K/4K 해상도 단계; 거의 모든 언어에서 ~99% 텍스트 정확도; 생성 전 내장 reasoning; 컨텍스트 인식 멀티턴 편집; gpt-image-2-latest alias) — 2026-04-21 출시
  • Claude Opus 4.7 추가 (\(5/\)25 per 1M, 1M 컨텍스트, 128K 최대 출력, 지식 cutoff 2026-01, 최대 2576px / 3.75MP 고해상도 이미지 지원, 새 토크나이저로 이전 모델 대비 ~1.0–1.35× 토큰 사용, 새 xhigh effort 레벨) — 2026-04-16 출시
  • Gemini 3.1 시리즈를 preview에서 GA로 승격하고, fallback 호환을 위해 -preview 접미사를 alias로 유지 (#573)
  • gemini-3.1-pro-preview → gemini-3.1-pro (alias: gemini-3.1-pro-preview, gemini-3.1-pro-preview-customtools, gemini-3.1-pro-latest)
  • gemini-3.1-flash-image-preview → gemini-3.1-flash-image (alias: gemini-3.1-flash-image-preview, nano-banana-2, gemini-3.1-flash-image-latest)
  • gemini-3.1-flash-lite-preview → gemini-3.1-flash-lite (alias: gemini-3.1-flash-lite-preview, gemini-3.1-flash-lite-latest)
  • gemini-3-flash-preview의 deprecation 노트를 새 GA gemini-3.1-pro ID로 가리키도록 업데이트

v1.5.3 - 2026-04-23

추가됨

  • src/models/pattern_matching.rs의 새 매칭 phase 5로 HuggingFace repo 접두사 제거 (#555)
  • try_strip_hf_repo_prefix()가 vendor/repo(또는 org/team/repo) 접두사를 MAX_PREFIX_SEGMENTS = 3 한계 안에서 검증하고, 빈 segment(/repo, vendor/, vendor//repo)를 거절하며, ASCII 공백이 들어가 있으면 거절한 뒤 잔류 문자열을 반환합니다
  • phase 5는 잔류 문자열로 phase 1-4에 재진입하는데, 재귀 깊이가 구조적으로 정확히 1로 보장됩니다(재진입 호출이 allow_prefix_strip 게이트를 닫습니다). 따라서 접두사 제거가 기존 계층적 접미사 peel과 단일 lookup 안에서 합성됩니다 — 동기 사례인 unsloth/Qwen3.6-35B-A3B-GGUF가 별도 alias 등록 없이도 qwen3.6-35b-a3b로 해결됩니다
  • phase 5는 wildcard phase 앞에서 실행됩니다. blast-radius 감사 결과 model-metadata.yaml에 *가 포함된 alias 중 /를 가진 게 없어, 순서 변경이 기존 라우팅 동작에 영향을 주지 않습니다
  • tracing 출력의 phase 번호를 문서화된 phase 체인과 일치시킴(이전 코드는 namespace fallback에 phase = 7을 출력하면서 주석은 phase 6이라고 부르고 있었음)
  • 표준 HF 형태, suffix peel과의 합성, 대소문자 구분 vendor, 등록된 alias 우선순위, 해결 불가능한 잔류, 3-segment 형태, segment-cap 거절, no-slash 입력, 공백 거절, 빈 segment, 재진입 한계, alias-phase 우선순위를 다루는 12개 단위 테스트
  • tests/format_suffix_normalization_test.rs의 9개 통합 테스트가 phase 5를 거치는 전체 RouterConfig / BackendConfig 공개 API를 검증
  • docs/en/configuration/advanced.md(과 한국어 카운터파트)의 파이프라인 문서에 합성 의미, 보안 한계, out-of-scope 항목(하이픈 접두사, HF API discovery)을 다루는 새 "HuggingFace repo-prefix stripping (phase 5)" 섹션 추가

변경됨

  • 이전 phase-6 namespace fallback을 새 phase-5 HuggingFace 접두사 제거 계층으로 교체. 이전 phase는 대소문자를 구분했고 suffix peel과 합성되지 않았는데, 새 phase는 더 엄격한 입력 검증(segment cap, 빈 segment 거절, 공백 거절)을 적용하면서 phase 4의 대소문자 무시 peel과 bounded re-entry로 합성됩니다. MAX_PREFIX_SEGMENTS(3)를 넘는 병적인 입력(provider/deep/nested/model 등)은 이제 재귀적인 rsplit_once fallback으로 조용히 매칭되는 대신 phase 5에서 거절됩니다 (#555)
  • 560 감사에서 vendor-prefix로 분류되었던 alias(Qwen/Qwen3.6-35B-A3B, MiniMaxAI/MiniMax-M2.5 등)가 #555 이후로 peel-coverable-adjacent가 되었습니다. phase 2가 명시적 alias에서 여전히 먼저 승리하지만, phase 5 + phase 4 조합으로도 같은 메타데이터에 도달합니다. 소급 제거는 #555 디자인 섹션 7에 따라 후속 감사로 미룹니다

수정됨

  • 선택된 백엔드가 unix:// URL로 설정되었을 때 POST /anthropic/v1/messages가 동작하도록 수정 (#567)
  • 네이티브 Anthropic 백엔드와 OpenAI 호환 백엔드 모두 비스트리밍과 스트리밍 요청에서 Unix 소켓 위로 동작
  • 공백이 포함된 소켓 경로(macOS ~/Library/Application Support/... 등)도 정확히 처리
  • Auth 헤더 선택(Anthropic 백엔드는 x-api-key, OpenAI 호환 백엔드는 Authorization: Bearer)이 Unix 소켓 경로에서 정확히 동작
  • anthropic-version 헤더가 Unix 소켓 경로의 Anthropic 백엔드에 자동으로 추가되어, HTTP 경로 동작과 일치

v1.5.2 - 2026-04-21

추가됨

  • llama.cpp와 MLxcel 백엔드의 transport-layer passthrough 계약을 잠그는 회귀 테스트 (#562)
  • 새 tests/llamacpp_passthrough_test.rs와 tests/mlxcel_passthrough_test.rs가 4개 passthrough 호출 사이트를 모두 다룸: 직접 백엔드 execute_chat_completion, factory로 빌드된 백엔드(BackendFactory -> LlamaCppBackend), proxy/backend.rs HTTP 핸들러, 스트리밍 핸들러
  • 새 test_mlxcel_factory_backend_passthrough_nonstandard_fields가 BackendFactory -> LlamaCppBackend::execute_chat_completion이 비표준 필드를 transport 시점에 byte-for-byte 보존함을 단언
  • Anthropic input 테스트(tests/anthropic_input_test.rs)에 명시적 passthrough 커버리지 추가
  • passthrough 계약과 4개 보호된 호출 사이트, transport 직전에 실행되는 라우터 측 변환 목록(global_prompts, o1/o3/gpt-5*용 transform_payload_for_openai, web_search 주입)을 문서화한 docs/en/architecture/backend-passthrough.md와 한국어 카운터파트 docs/ko/architecture/backend-passthrough.md (#562, #563)
  • model-metadata.yaml의 모든 alias를 peel-redundant, peel-redundant-but-kept, peel-independent로 분류한 docs/reports/alias-audit-2026-04.md. docs/en/configuration/advanced.md(과 한국어 카운터파트)에 두 메커니즘 중 무엇을 선호할지 설명하는 "aliases vs peel" 정책 섹션 추가 (#560)

변경됨

  • passthrough 계약을 암묵적인 "byte-equivalent" 전역 보장에서 transport-layer 범위로 좁힘. 라우터는 transport 직전에 global_prompts 주입, o1/o3/gpt-5* 페이로드 변환, web_search 도구 주입을 여전히 수행할 수 있지만, transport 경계에서는 공급자별 재작성이 없음 (#563)
  • src/http/streaming/handler.rs, src/infrastructure/backends/factory/backend_factory.rs, src/infrastructure/backends/llamacpp/backend.rs, src/proxy/backend.rs에 주석 수준의 명확화
  • model-metadata.yaml alias를 peel-normalization 중복 여부로 감사: 정규 ID와 계층적 peel이 이미 처리하는 접미사(-4bit, -q4_k_m, -fp8, -gguf, -mlx, -awq 등)만 다른 alias는 제거하고, 정규 flavor 변형(-qat, -instruct)을 인코딩하거나 파라미터 카운트를 구분하는(-1.5b vs -7b) alias는 유지 (#557)
  • 새 tests/alias_audit_helper.rs와 tests/format_suffix_normalization_test.rs가 앞으로 peel-vs-alias 경계를 강제

CI

  • Debian 빌드 워크플로에서 25.10 (Questing) 대신 Ubuntu 26.04 LTS (Resolute) 타겟으로 변경
  • debian/update-changelog.sh가 최신 릴리스가 아직 draft 상태일 때 changelog가 회귀하지 않도록 release publishedAt이 null이면 createdAt로 폴백

v1.5.1 - 2026-04-20

추가됨

  • 자체 호스팅 LLM 백엔드용 내장 web_search 도구 (#553)
  • vLLM, Ollama, llama.cpp, MLxcel, LM Studio, Continuum Router, Generic 백엔드의 chat completion 요청에 라우터 단에서 투명하게 주입되는 도구
  • src/services/search/에 플러거블 SearchProvider 트레이트와 SerperProvider 구현. Exa와 Brave는 같은 트레이트 뒤에 스캐폴드 완료
  • 백엔드별 오버라이드가 가능한 inject_policy (auto/always/never). 상용 백엔드(OpenAI, Azure, Gemini, Anthropic)는 손대지 않아, 네이티브 web_search가 그대로 흐름
  • bounded 비스트리밍 도구 실행 루프가 web_search 도구 호출을 파싱해 공급자를 실행하고, tool-role 결과를 추가한 뒤 백엔드를 최대 max_tool_iterations 라운드까지 다시 호출
  • 상용/자체 호스팅 분할 불변량을 강제하는 단위 테스트가 포함된 새 BackendTypeConfig::is_self_hosted / is_commercial 헬퍼
  • API 키는 Debug 출력에서 마스킹되고 로그에 남지 않음. ${ENV} 치환을 지원하는 핫 리로드 친화적 WebSearchConfig
  • 도구 호출, 주입, iteration-cap 도달에 대한 Prometheus 카운터를 src/metrics/web_search에 추가
  • 모델 메타데이터 lookup용 계층적 양자화 및 형식 접미사 정규화 (#549)
  • src/models/pattern_matching.rs의 새 layered_format_strip()이 모델 ID 오른쪽에서 allowlist된 양자화/형식/flavor 토큰을 반복적으로 peel하며, peel마다 정확한 ID/alias/날짜 접미사 매칭을 다시 시도
  • 토큰 카테고리: BIT_WIDTH, GGUF_QUANT, FP_FORMAT, INT_FORMAT, LIBRARY, IMATRIX, UNSLOTH, CONTAINER, FLAVOR (전부 대소문자 무시)
  • 파라미터 카운트 접미사 보존: -Nbit은 양자화로 제거되고, -Nb, -aNb, -eNb, -0.6b는 파라미터 카운트로 유지
  • allowlist된 flavor로 끝나는 정규 base ID(gemma-3-12b-qat 등)는 peel 실행 전 정확한 ID 매칭으로 승리
  • 정규화 파이프라인이 find_matching_config, BackendConfig::get_model_metadata, RouterConfig::get_model_metadata, RouterConfig::get_thinking_pattern_config, resolve_model_tier(라우팅), get_model_profile(admin)에 연결됨
  • GLM 5.1, Qwen 3.6, MiniMax M2.7 모델 메타데이터 (#548)
  • 빌드와 Docker 작업이 끝난 뒤 Power Automate 웹훅을 통해 Microsoft Teams로 릴리스 알림 게시

변경됨

  • 문서 도구 체인을 MkDocs + Material for MkDocs에서 Zensical로 마이그레이션. mkdocs.yml을 네이티브로 읽고 필요한 확장을 번들링

수정됨

  • 보안: 계층적 peel phase에 MAX_MODEL_ID_LEN=256과 MAX_PEEL_ITERATIONS=8 캡을 적용해, -4bit-4bit-4bit-... 같은 병적 모델 ID로 인한 DoS(이전엔 O(n²) 할당)를 제거
  • 보안: /v1/chat/completions, /v1/completions, /v1/embeddings, /v1/embeddings/sparse에 256자 model 필드 길이 제한 적용 (기존 /v1/responses 체크와 동등)
  • 7-phase 메타데이터 매칭 파이프라인을 단일 구현(find_matching_config_slice)에 통합하고 호출 사이트마다 얇은 어댑터를 두어, BackendConfig, Config::get_model_metadata, Config::get_thinking_pattern_config, find_matching_config 사이의 drift 제거
  • hot path에서 cfg.to_ascii_lowercase() == peel을 str::eq_ignore_ascii_case로 교체 (요청당 String 할당 약 4000개 감소)
  • MkDocs 빌드 실패를 막기 위해 Pygments <2.20 핀 (Zensical 마이그레이션으로 무효화)

CI

  • softprops/action-gh-release를 2에서 3으로 bump (#544)
  • actions/github-script를 8에서 9로 bump (#545)
  • actions/upload-pages-artifact를 4에서 5로 bump (#554)

문서

  • docs/en/configuration/advanced.md에 접미사 순서 모호성(-qat-4bit vs -4bit-qat)과 내부 peel phase 한계 문서화
  • docs/en/architecture.md의 Model Aggregation Service 모듈 목록에 pattern_matching.rs 추가하고, suffix normalization 섹션으로 상호 참조 링크
  • 새 docs/en/web-search.md 기능 문서. config.yaml.example에 web_search 섹션 확장

v1.5.0 - 2026-04-11

추가됨

  • 모델 tier 및 capability profile 레지스트리를 갖춘 smart routing 시스템 (#525, #531)
  • 규칙 기반 요청 분류기 및 smart routing 정책 엔진 (#526, #532)
  • 부하 인식 동적 tier 조정 (#527, #533)
  • 하이브리드 모드를 갖춘 LLM 기반 요청 분류기 (#528, #534)
  • Smart routing 관측, admin API, 문서 (#529, #535)
  • Codex 호환 Responses API 확장 (#536, #537)

변경됨

  • 핵심 의존성 업그레이드 — axum 0.8, sha2 0.11, rand 0.10 (#523)
  • Gemma 4 모델 패밀리 메타데이터 추가 (#538)

수정됨

  • Smart routing 통합 갭 보완
  • DefaultTransformer PDF 크기 제한을 20MB에서 32MB로 증가 (#542)

CI

  • actions/deploy-pages를 4에서 5로 bump (#521)

의존성

  • minor-and-patch 의존성 그룹 4개 업데이트 bump (#539)

문서

  • Codex 호환 Responses API 갭 분석 보고서 추가

v1.4.5 - 2026-03-27

수정됨

  • 파일 서비스가 설정되지 않은 상태에서 파일 참조가 사용되면 400 오류를 반환하도록 수정 (#519)

변경됨

  • GLM-5-Turbo 모델 메타데이터 추가 (#516)

문서

  • ko/ 문서의 한국어 anti-AI-slop 위반 수정
  • api.md의 슬롭 단어와 전환어 수정

v1.4.4 - 2026-03-18

수정됨

  • high/xhigh reasoning effort에서 Anthropic thinking이 실패하던 문제 수정. budget_tokens(32768)가 기본 max_tokens(16384)를 초과해 API가 거절했음 (#514)
  • thinking이 켜져 있고 budget이 max를 초과하면 max_tokens를 budget_tokens + 4096으로 자동 조정

변경됨

  • GPT-5.4 모델 패밀리 추가: 1M 컨텍스트 윈도우의 gpt-5.4, gpt-5.4-pro, gpt-5.4-mini, gpt-5.4-nano (#515)
  • Gemini 3 시리즈 업데이트: gemini-3.1-pro-preview, gemini-3-flash-preview, gemini-3.1-flash-lite-preview 추가, gemini-3-pro-preview deprecation 표시
  • include_thoughts 자동 주입을 위해 Gemini 3 Flash와 3.1 Flash-Lite를 thinking 모델로 인식
  • Claude 4.6 모델 업데이트: 컨텍스트 윈도우 1M(GA), Sonnet 4.6 max_output을 64K로 수정, 지식 cutoff 보정
  • 8개 파일에 걸쳐 설정 예시와 문서를 최신 모델 이름으로 업데이트

v1.4.3 - 2026-03-18

수정됨

  • 라우터를 거친 스트리밍 응답에서 Gemini thinking 모델(2.5 Pro, 3 Pro 등)이 reasoning_content를 반환하지 않던 문제 수정 (#513)
  • transform_payload_for_gemini()를 transform_request_gemini()로 3개 Gemini 스트리밍 경로 모두에서 교체해 include_thoughts: true 자동 주입을 보장

v1.4.2 - 2026-03-17

변경됨

  • 스트리밍 안정성 개선을 위해 mid-stream fallback 기본값을 활성화로 변경 (#504)
  • Breaking: mid-stream fallback이 이제 기본 활성화. 이전 동작으로 복원하려면 mid_stream_fallback.enabled: false를 설정

문서

  • fallback 동작을 최적화하기 위한 failover latency 튜닝 가이드 추가

v1.4.1 - 2026-03-17

추가됨

  • 스트리밍 추론용 mid-stream fallback (#497) — SSE 스트리밍 중에 백엔드가 실패하면 라우터가 fallback 백엔드로 투명하게 재시도

변경됨

  • pre-stream fallback과 mid-stream fallback을 분리 (#500) — 각각 독립적으로 활성화/비활성화 가능
  • 의존성 버전을 최신 메이저로 bump

수정됨

  • 스트리밍 설정 변경이 핫 리로드에서 감지되지 않던 문제 수정 (#503)
  • fallback 중 mid-stream 연결 오류가 클라이언트로 누설되던 문제 수정 (#502)
  • 사용하지 않는 config crate 의존성 제거

CI

  • dorny/paths-filter를 3에서 4로 bump (#493)
  • actions/create-github-app-token을 2에서 3으로 bump (#494)

v1.4.0 - 2026-03-14

추가됨

  • Prefix-aware routing: PrefixAwareHash 선택 전략과 Bounded Loads를 적용한 Consistent Hash (CHWBL) (#455, #457, #461)
  • Response caching: SHA256 기반 캐시 키 계산, 스트리밍 응답 버퍼링과 완료 후 캐싱 (#456, #459, #462)
  • 다단계 CacheStore: 인메모리 백엔드 (#466), 연결 풀링이 있는 Redis/Valkey 백엔드 (#467), S3 기반 L1/L2 다단계 캐시 (#483)
  • KV 캐시 인덱스: 공유 데이터 구조 (#470), vLLM 백엔드 스트림용 KV 이벤트 컨슈머 (#471), 백엔드 선택에 통합된 prefix overlap 점수 (#473), 설정/메트릭/admin 엔드포인트 (#474)
  • 스토리지 tier 인식이 있는 Tiered KV cache (GPU hot / external warm) (#484)
  • 외부 KV 텐서 전송을 사용하는 disaggregated prefill/decode 오케스트레이션 (#485)
  • Anthropic cache_control breakpoint 자동 주입 (#460)
  • Gemini Embedding 2용 멀티모달 임베딩 지원 (#492)
  • 공유 캐시 설정 및 운영 메트릭 (#468)
  • model-metadata.yaml에 30개 신규 모델 추가 (#472)

변경됨

  • VAST 전용 식별자를 일반 S3/외부 스토리지 이름으로 변경 (#490) — VAST 전용 필드 이름을 사용 중이라면 설정 파일을 업데이트하세요

수정됨

  • 공백이 포함된 Unix 소켓 경로용으로 RequestExecutor를 transport-aware로 변경 (#488)
  • 문서의 상대 source tree 링크를 GitHub URL로 교체

CI

  • docker/setup-qemu-action을 3에서 4로 bump (#428)
  • docker/metadata-action을 5에서 6으로 bump (#426)
  • docker/setup-buildx-action을 3에서 4로 bump (#429)
  • docker/build-push-action을 6에서 7로 bump (#430)
  • docker/login-action을 3에서 4로 bump (#427)

문서

  • 종합적인 KV cache 기능 문서, 벤치마크, 설정 예시 (#477)
  • VAST Data 연결 가이드 및 통합 예시 (#486)
  • 한국어 문서를 영문 카운터파트와 동기화
  • 단일 configuration.md를 6개 파일로 분할

v1.3.0 - 2026-03-12

추가됨

  • Agent Communication Protocol (ACP) 지원: JSON-RPC 2.0 프로토콜 계층과 stdio transport (#414, #420)
  • ACP 세션 관리: 프로토콜 라이프사이클, initialize/shutdown handshake (#415, #421)
  • ACP-to-LLM 추론 파이프라인: 스트리밍 지원 (#416, #422)
  • ACP 도구 호출 보고와 권한 위임 (#417, #423)
  • MCP 서버 터널링용 MCP-over-ACP 브리지 (#418, #424)
  • 메타데이터와 설정 지원이 있는 ACP 에이전트 레지스트리 (#419, #425)
  • 프로토콜 라이프사이클과 세션 관리용 ACP 통합 테스트

수정됨

  • ACP 통합 테스트의 clippy field_reassign_with_default 경고 해결

CI

  • actions/upload-artifact를 6에서 7로 bump (#398)

문서

  • MkDocs 통합이 있는 ACP 아키텍처 문서
  • IDE 통합 예시가 있는 ACP 실용 사용 가이드
  • 라우터 단 캐싱 전략용 KV 캐시 통합 계획

v1.2.1 - 2026-03-07

추가됨

  • MLX 기반 모델 서빙용 MLxcel 백엔드 유형 지원 (#412, #413) — llama-server와 API가 완전히 호환되며, 헬스 체크/모델 디스커버리/프록싱에 같은 백엔드 구현을 재사용

v1.2.0 - 2026-03-06

추가됨

  • 관리자 통계 API - 관리자 API 엔드포인트를 통한 포괄적인 요청 수준 통계 수집 및 보고 추가 (#409)

    • GET /admin/stats - 전체, 모델별, 백엔드별 통계
    • GET /admin/stats/models - 모델별 통계만
    • GET /admin/stats/backends - 백엔드별 통계만
    • POST /admin/stats/reset - 수집된 모든 통계 초기화
    • ?window=1h, ?window=24h 등을 통한 시간 윈도우 쿼리
    • OpenAI 호환 API 응답에서의 토큰 사용량 추적
    • 링 버퍼에서 계산되는 지연 백분위수 (p50, p95, p99)
    • 최소 성능 영향을 위한 핫 패스에서의 락프리 원자적 카운터
    • 통계 설정 변경에 대한 핫 리로드 지원
  • 통계 영속화 - 관리자 통계를 위한 영속적 스토리지 추가 (#410, #411)

    • 설정 가능한 스냅샷 경로, 간격, 비활성 검사를 위한 최대 수명
    • 데이터 안전을 위한 원자적 쓰기 (임시 파일 + 이름 변경)
    • 누락, 손상 또는 비활성 파일의 그레이스풀 처리로 시작 시 디스크에서 통계 복원
    • 그레이스풀 셧다운 (SIGTERM/SIGINT) 시 최종 스냅샷 저장

문서

  • 설정 가이드에 관리자 통계 및 영속화 추가
  • v1.1.0 리팩토링 후 벤치마크 보고서 추가 (#407)
  • admin-api.md에 통계 API 문서화 (영어 및 한국어)

v1.1.1 - 2026-03-04

추가됨

  • 임베딩 가능한 라이브러리 크레이트 - continuum-router를 임베딩 가능한 라이브러리 크레이트로 활성화 (1단계) (#394)

    • 프로그래밍 방식의 라이브러리 사용을 위한 타입 안전 설정 빌더 (#400)
    • 선택적 라이브러리 의존성을 위한 Cargo 피처 플래그 (#399)
  • 영속적 런타임 API 키 - 런타임 API 키를 위한 영속적 스토리지 추가 (#405)

  • 모델 메타데이터 - 새로운 LLM 모델에 대한 모델 메타데이터 항목 추가 (#403)

수정됨

  • Anthropic 핸들러에서 Gemini 전용 변환 적용 (#404)

v1.1.0 - 2026-03-01

추가됨

  • 임베디드 WebUI - 설정 관리 및 API 키 관리를 위한 임베디드 WebUI 추가 (#388)

    • 라우터 바이너리에서 직접 제공되는 브라우저 기반 관리 인터페이스
    • 외부 도구 없이 설정 편집 및 관리
    • 그래픽 인터페이스를 통한 API 키 관리
  • Windows AF_UNIX 소켓 지원 - socket2 크레이트를 통한 Windows AF_UNIX 소켓 지원 추가 (#390)

    • socket2 크레이트를 사용한 Windows에서의 완전한 Unix 도메인 소켓 지원
    • Windows 전용 소켓 주소 처리 및 전송 파싱
    • Windows 플랫폼에서 로컬 IPC 활성화
  • Nano Banana 2 (Gemini 이미지 생성) - Nano Banana 2 이미지 생성 모델 지원 추가

    • 최신 모델 세대에 대한 업데이트된 Gemini 이미지 생성 지원

수정됨

  • ClientAddr::is_unix에서 튜플 변형 매칭 컴파일 오류 해결
  • Windows AF_UNIX 소켓 accept 실패 및 설정 검증 해결
  • Unix 소켓 설정 검증에서 Windows 절대 경로 허용 (#393)
  • Unix 소켓 테스트 및 전송 파싱에서 Windows 컴파일 오류 해결 (#392)

v1.0.0 - 2026-02-19

추가됨

  • Continuum Router / Backend.AI GO 백엔드 타입 - 연합 LLM 라우팅을 위한 continuum-router 전용 백엔드 타입 추가 (#385)

    • 원격 Continuum Router 인스턴스 또는 Backend.AI GO 배포에 연결
    • 다중 serde 별칭: continuum-router, continuum_router, ContinuumRouter, backendai, backend-ai, backend_ai
    • Bearer 토큰 인증 및 자동 헤더 주입
    • 실시간 응답을 위한 SSE 스트리밍 지원
    • /v1/models 엔드포인트를 통한 자동 모델 탐색
    • /health를 기본으로 /v1/models 폴백을 사용하는 헬스 체크
    • 사용 사례: 연합 라우팅, 다중 리전 로드 밸런싱, 계층적 페일오버
  • LM Studio 백엔드 타입 - lmstudio 전용 백엔드 타입 추가 (#381)

    • LM Studio 로컬 서버에 대한 네이티브 지원
    • 기본 URL: http://localhost:1234 (LM Studio 기본 포트)
    • Serde 별칭: LMStudio, lm-studio, lm_studio
    • /v1/models 기본, /api/v1/models 폴백을 사용하는 헬스 체크
    • LM Studio 모델 목록에서 자동 모델 탐색
    • /v1/models 응답에서 owned_by: lmstudio 적절한 귀속
  • Claude 4.6 적응형 사고와 output_config.effort - Claude 4.6 모델을 위한 Anthropic의 output_config.effort 파라미터 지원 추가 (#384)

    • Claude 4.6 모델(claude-opus-4-6-*, claude-sonnet-4-6-*)은 이제 budget_tokens 대신 thinking: {"type": "adaptive"} + output_config: {"effort": "<level>"} 사용
    • 새로운 xhigh → "max" effort 매핑: Opus 4.6용, Sonnet 4.6은 자동으로 "high"로 다운그레이드 (max는 Opus 4.6 전용)
    • minimal reasoning effort는 "low" effort로 매핑 (Anthropic API에 minimal 레벨 없음)
    • auto effort는 output_config를 생략하여 Anthropic 기본값(high) 사용
    • 4.6 이전 모델은 기존 budget_tokens 방식 유지
    • 역변환: Anthropic 요청을 OpenAI 형식으로 변환 시 output_config.effort를 reasoning_effort로 매핑
    • Responses API 변환기가 Claude 4.6 모델에서 적응형 사고를 사용하도록 업데이트
  • 적응형 사고 및 auto Reasoning Effort 레벨 - Claude Opus 4.6과 함께 도입된 Anthropic의 적응형 사고({"type": "adaptive"}) 지원 추가 (#377)

    • Anthropic 백엔드에서 {"type": "adaptive"}로 매핑되는 새로운 auto reasoning effort 레벨. 모델이 사고의 시점과 정도를 동적으로 결정
    • 모든 백엔드에서 auto 허용: Anthropic에서는 네이티브 적응형 사고, OpenAI 및 Gemini에서는 자동으로 medium으로 다운그레이드
    • 적응형 사고(고정 예산 없음)를 지원하기 위해 AnthropicThinking.budget_tokens가 Option<u32>로 변경
    • 평면 형식(reasoning_effort: "auto")과 중첩 형식(reasoning: {effort: "auto"}) 모두 지원
    • 적응형 사고 활성화 시 temperature 파라미터 자동 제거
  • Cohere/Jina 호환 리랭킹 및 희소 임베딩 엔드포인트 - 고급 검색 API 지원 추가 (#374)

    • 2단계 검색을 위한 새로운 /v1/rerank 엔드포인트 (Cohere 호환)
    • 희소 임베딩 (SPLADE 형식)을 위한 새로운 /embed_sparse 엔드포인트 (TEI/Jina 호환)
    • 리랭킹을 위한 단순 문자열 문서 및 text 필드가 있는 구조화된 문서 모두 지원
    • model, query, documents 필드에 대한 포괄적인 검증이 포함된 요청/응답 타입
    • 새로운 기능 매핑: rerank -> rerank 메서드, sparse_embedding -> embed_sparse 메서드
    • model-metadata.yaml에 예제 모델 추가: BGE Reranker, Jina Reranker, SPLADE 모델
  • BGE-M3 및 다국어 임베딩 모델 지원 - BGE-M3 및 동등한 다국어 임베딩 모델에 대한 모델 메타데이터 및 설정 예제 추가 (#373)

    • BGE-M3: 568M 파라미터, 1024 차원, 100개 이상 언어, 8192 컨텍스트. Dense, sparse(어휘), ColBERT 멀티벡터 검색 지원
    • BGE-Large-EN-v1.5: 335M 파라미터, 1024 차원, 영어 전용, 512 컨텍스트
    • Multilingual-E5-Large: 560M 파라미터, 1024 차원, 100개 이상 언어, 514 컨텍스트
    • vLLM, Ollama, Text Embeddings Inference (TEI) 배포를 위한 예제 백엔드 설정
    • RAG 시스템의 교차 언어 검색 요구 사항 해결
  • Anthropic 파일 변환기에 일반 텍스트 지원 추가 - Anthropic 파일 변환기에 text/plain 지원 추가 (#342)

    • 텍스트 파일은 base64 데이터가 포함된 document 블록으로 변환 (PDF와 동일한 형식)
    • 최대 텍스트 파일 크기: 32MB (PDF와 동일)
    • 텍스트 파일은 매직 바이트 검증 없음 (모든 콘텐츠 허용)
    • SUPPORTED_DOCUMENT_TYPES에 application/pdf와 함께 text/plain 추가
    • 오류 메시지에 일반 텍스트 지원 언급 추가
  • OpenAI 및 Anthropic 파일 변환기에 PDF 지원 추가 - 파일 변환기에 PDF 파일 지원 추가 (#340)

    • OpenAI 변환기: PDF를 base64 데이터 또는 파일 ID가 포함된 file 블록으로 변환
    • Anthropic 변환기: PDF를 base64 데이터가 포함된 document 블록으로 변환
    • 보안을 위한 PDF 매직 바이트 검증 (%PDF- 시그니처)
    • 최대 PDF 크기: 32MB (백엔드에서 100페이지 제한 적용)
    • 이미지는 20MB 제한 유지
  • 네이티브 Anthropic Responses API 지원 - Responses API에 대한 네이티브 Anthropic Messages API 변환 추가 (#332)

    • Responses API 요청을 네이티브 Anthropic Messages 형식으로 변환하는 새로운 AnthropicConverter
    • Anthropic 문서 이해 기능을 통한 전체 PDF 파일 지원 (file_data가 포함된 input_file)
    • 자동 미디어 타입 감지를 통한 이미지 파일 지원
    • Claude 3+ 모델을 위한 확장 사고(Extended Thinking) 콘텐츠 지원
    • Anthropic 형식의 적절한 SSE 이벤트 변환을 통한 스트리밍 지원
    • 완전한 응답 변환을 통한 비스트리밍 지원

수정됨

  • 외부 파일 URL에 대한 SSRF 검증 - 외부 파일 가져오기 시 SSRF 보호 추가 (#332)

    • 사설 IP 주소 검증 (10.x.x.x, 172.16-31.x.x, 192.168.x.x, 127.x.x.x 차단)
    • Localhost 및 링크 로컬 주소 차단
    • IPv6 루프백 및 링크 로컬 주소 차단
    • 해석 후 IP 검증을 통한 DNS 리바인딩 보호
  • 파일 입력에 대한 미디어 타입 화이트리스트 - 허용된 파일 유형에 대한 보안 화이트리스트 추가 (#332)

    • PDF: application/pdf
    • 이미지: image/jpeg, image/png, image/gif, image/webp
    • 지원되지 않는 미디어 타입은 명확한 오류 메시지와 함께 거부
  • AI SDK Responses API 스트리밍 호환성 - Responses API 스트리밍에서 Vercel AI SDK 호환성 문제 수정 (#334)

    • OpenAI 스펙에 맞춰 ResponseStreamEvent 직렬화가 점으로 구분된 타입 이름 사용 (예: "type": "output_text_done" 대신 "type": "response.output_text.done")
    • OutputItemAdded, OutputItemInProgress, OutputItemDone 및 기타 스트리밍 이벤트에 item_id 필드 추가
    • 이벤트 순서 추적을 위한 sequence_number 필드 추가 (긴 스트리밍 세션에서 오버플로우 방지를 위해 u64 사용)
    • ResponseStreamEvent에 대한 커스텀 Serialize 구현으로 올바른 JSON 출력 형식 보장
    • 새 필드가 올바르게 직렬화되는지 확인하기 위해 모든 기존 스트리밍 테스트 업데이트
  • 핫 리로드 백엔드 동기화 후 즉시 헬스 체크 - 핫 리로드로 백엔드 추가 시 즉시 헬스 체크 실행 (#367)

    • 설정 핫 리로드로 추가된 새 백엔드가 이제 즉시 헬스 체크됨
    • 이전에는 새 백엔드가 최대 30초(기본 헬스 체크 간격) 동안 사용 불가 상태로 유지됨
    • 외부 호출을 위해 HealthChecker::perform_health_checks() 공개 설정
    • Backend.AI GO 및 기타 클라이언트에서 모델 가용성 응답성 개선

v0.36.1 - 2026-01-30

수정됨

  • 핫 리로드 중 sync_backends 직후 즉시 헬스 체크가 트리거되도록 수정 (#368) — 새 백엔드를 최대 30초 대기 대신 1-2초 안에 사용 가능
  • 핫 리로드 중 health_check_info 동기화와 URL 기반 업데이트 사용 (#369) — 새 백엔드가 API 키 인증을 정확히 수신
  • 최근 추가된 백엔드의 헬스 체크 가속화 — 추가 후 5분 동안 1초 간격으로 체크
  • 백엔드가 정상 상태로 전환될 때 5초 디바운스로 모델 캐시 갱신 트리거

v0.36.0 - 2026-01-27

추가됨

  • 엔드포인트 라우팅이 포함된 네이티브 Anthropic Messages API 핸들러 (#355)
  • Anthropic ↔ OpenAI 요청/응답 변환 (#356, #357)
  • Anthropic 스트리밍 응답 형식 (#358)
  • Anthropic ↔ Gemini 직접 요청/응답 변환 (#359)
  • Anthropic 입력의 file_id 소스 타입과 파일 해석 (#360)
  • Anthropic 핸들러용 Claude Code 호환성 (#365)
  • 모든 백엔드 유형에 대한 계층적 토큰 카운팅
  • 성능 향상을 위한 병렬 파일 참조 해석
  • anthropic-version 헤더 형식 검증

수정됨

  • SSRF 방지를 위해 이미지 및 문서 URL에 HTTPS 요구
  • 백엔드 세부 정보 대신 일반 오류 메시지를 클라이언트에 반환
  • 파일 소유권 체크에 API 키로 인증된 user_id 사용
  • 안전한 메시지/도구 ID 생성을 위한 UUID v4 사용
  • Anthropic-to-OpenAI 변환에서 도구 메시지를 사용자 텍스트 앞에 배치
  • tool_use 블록이 있을 때 stop_reason을 tool_use로 오버라이드
  • OpenAI로 라우팅되는 Anthropic 요청에 max_completion_tokens 변환 적용
  • 파일 접근 거부 및 not found 오류를 클라이언트로 전파
  • 일관된 동작을 위해 요청당 current_config()를 한 번만 호출

리팩터

  • 공통 SSE 이벤트 타입과 데이터 추출 로직 분리
  • 정확한 UTF-8 처리를 위해 SseParser에 parse_bytes 메서드 추가
  • AnthropicFileResolver의 불필요한 Arc 래퍼 제거
  • enum 크기를 줄이기 위해 FileResolutionResult::Resolved를 Box 처리

v0.35.0 - 2026-01-23

추가됨

  • 함수 호출에서 Gemini 3 thoughtSignature 지원 (#354)
  • OpenAI와 Anthropic 파일 변환기에 PDF 지원 (#340)
  • AnthropicFileTransformer의 text/plain 지원 (#342)

수정됨

  • DefaultTransformer와 파일 해석에 PDF 지원 추가 (#343)
  • 비스트리밍 Anthropic 요청에 도구 메시지 변환 추가 (#344)
  • DefaultTransformer가 이미지가 아닌 파일을 명확한 오류 메시지로 거절 (#338)
  • AI SDK가 Responses API 스트리밍 형식과 호환되지 않던 문제 수정 (#335)

v0.34.0 - 2026-01-16

추가됨

  • 자동 품질 파라미터 변환 - DALL-E와 GPT Image 모델 간 자동 품질 파라미터 변환 추가 (#330)
    • GPT Image 품질 값을 DALL-E 동등 값으로 변환하는 ImageQuality 열거형의 to_dalle_quality() 메서드
    • handle_openai_image_generation() 및 handle_streaming_image_generation()에서 투명하게 적용되는 품질 변환
    • 품질 변환 매핑:
      • DALL-E 3: low/medium/auto → standard, high → hd
      • GPT Image: standard → medium, hd → high
      • Gemini 모델: 품질 파라미터 무시 (변경 불필요)
    • 정확한 DALL-E 3 모델 매칭을 위한 is_dalle3_model() 헬퍼
    • 코드 중복 제거를 위한 convert_quality_for_model() 헬퍼
    • 변환은 디버깅을 위해 로그되며 사용자 대면 경고 없이 투명하게 수행

v0.33.0 - 2026-01-13

추가됨

  • Responses API 로컬 파일 해석 - Responses API 요청에서 로컬 file_id 참조 해석 (#325)
    • Files API를 통해 업로드된 파일을 이제 Responses API 요청에서 file_id로 참조 가능
    • FileResolver 서비스가 요청에서 file_id 참조를 스캔하고 로컬 스토리지에서 콘텐츠 로드
    • 파일 콘텐츠가 백엔드로 전송되기 전에 base64 file_data 형식으로 변환
    • 보안 기능: 파일 소유권 검증(user_id 확인) 및 주입을 위한 10MB 크기 제한
    • 그레이스풀 디그레이드: 해석 실패 시 경고 로그와 함께 원본 요청으로 폴백

수정됨

  • Responses API 플랫 도구 형식 - Responses API /v1/responses 엔드포인트가 플랫 도구 형식을 허용하도록 수정 (#323)
    • 함수 도구는 이제 플랫 형식 사용: {"type": "function", "name": "...", "parameters": {...}}
    • OpenAI Responses API 사양에 맞춤
    • 중첩 형식(function 래퍼 객체 포함)은 더 이상 Responses API에서 허용되지 않음
    • 플랫 도구 형식 예제로 문서 업데이트

v0.32.0 - 2026-01-09

추가됨

  • Reasoning Effort 문서 및 xhigh 폴백 로깅 - 포괄적인 reasoning effort 문서 추가 및 xhigh effort 레벨의 폴백 로깅 개선 (#317)
    • reasoning effort 파라미터 사용법을 설명하는 새로운 문서
    • 비 GPT-5.2 모델에서 xhigh effort 레벨이 high로 폴백될 때의 개선된 로깅

수정됨

  • Responses API InputItem의 암시적 메시지 타입 추론 - role 필드가 없을 때 암시적 메시지 타입 추론 지원 (#316)
    • 더 나은 성능을 위한 최적화된 InputItem 역직렬화기
    • 잘못된 role 테스트 커버리지 추가
    • Responses API에서 더 유연한 입력 처리 활성화

v0.31.5 - 2026-01-09

추가됨

  • 네이티브 OpenAI 백엔드용 Responses API 패스스루 - 백엔드 유형에 따른 /v1/responses API 스마트 라우팅 (#313)

    • OpenAI 및 Azure OpenAI 백엔드는 이제 패스스루 모드를 사용하여 /v1/responses 엔드포인트로 요청을 직접 전달
    • 다른 백엔드(Anthropic, Gemini, vLLM, Ollama, LlamaCpp, Generic)는 자동으로 네이티브 형식으로 변환
    • 패스스루 모드 이점: 네이티브 PDF 지원, 보존된 추론 상태, 내장 도구(web_search, file_search) 접근, 향상된 캐시 활용
    • 라우팅 결정을 위한 ResponsesApiStrategy 열거형이 포함된 새로운 router.rs 모듈
    • 직접 요청 전달을 위한 PassthroughService가 포함된 새로운 passthrough.rs 모듈
    • DoS 방지를 위한 요청 페이로드 크기 검증 (16MB 제한)
    • 라우팅 전략, 오류 처리, 요청 검증에 대한 포괄적인 테스트 커버리지
  • OpenAI Responses API 파일 입력 유형 - Responses API에서 멀티모달 파일 입력 지원 추가 (#311)

    • 새로운 input_text, input_file, input_image 콘텐츠 파트 유형
    • base64 데이터 URL(file_data)을 통한 PDF 문서 및 이미지 지원
    • SSRF 검증이 포함된 외부 파일 URL(file_url) 지원
    • 지원되지 않는 file_id 참조에 대한 경고 로그 (Files API 통합 보류 중)
    • Anthropic(document/image 블록) 및 Gemini(inline_data/file_data)용 백엔드별 변환기
    • 모든 입력 유형에 대한 포괄적인 테스트 커버리지

수정됨

  • 패스스루 원시 오류 응답 - 더 나은 오류 디버깅을 위해 패스스루 모드에서 백엔드 원시 오류 응답 전달

v0.31.4 - 2026-01-07

수정됨

  • API 키 전달을 위한 핫 리로드 지원 - 프록시 및 스트리밍 핸들러에서 핫 리로드 지원 수정 (#310)
    • 프록시 및 스트리밍 핸들러에서 캡처된 설정 스냅샷 대신 current_config() 사용
    • 핫 리로드를 통한 API 키 및 기타 설정 변경이 이제 새 요청에 올바르게 적용됨
    • 런타임 설정 업데이트가 백엔드 요청 전달에 영향을 미치도록 보장
    • 핫 리로드 api_key 적용을 위한 포괄적인 엔드투엔드 테스트 추가

v0.31.3 - 2026-01-06

수정됨

  • Unix 소켓 Anthropic 요청/응답 변환 - 변환 누락으로 인한 Unix 소켓을 통한 Anthropic 백엔드 실패 수정 (#307, #308)

    • Unix 소켓 전송이 이제 HTTP 전송과 동일한 요청 변환을 Anthropic 백엔드에 적용
    • OpenAI 형식 요청이 전송 전에 Anthropic 형식으로 올바르게 변환됨
    • Anthropic 응답이 OpenAI 형식으로 다시 변환됨
    • 엔드포인트가 /v1/chat/completions에서 /v1/messages로 올바르게 재작성됨
    • Unix 소켓 Anthropic 변환을 위한 포괄적인 통합 테스트 추가
  • Anthropic 비스트리밍 스트림 파라미터 - Anthropic 비스트리밍 요청에 대한 스트림 파라미터 보존 (#305, #306)

    • 비스트리밍 경로에서 transform_openai_to_anthropic_request(stream: true 강제) 대신 transform_openai_to_anthropic_with_global_prompt 사용
    • stream: false인 요청이 잘못 stream: true로 Anthropic API에 전송되는 문제 수정
    • 명확성을 위해 transform_openai_to_anthropic_request를 transform_openai_to_anthropic_streaming으로 이름 변경

문서

  • Jinja2 구문 이스케이프 - mkdocs-macros-plugin 오류를 방지하기 위해 한국어 설정 문서에서 Jinja2 구문 이스케이프

v0.31.2 - 2026-01-05

추가됨

  • Anthropic 백엔드의 비스트리밍 지원 - 비스트리밍 채팅 완료 호출에 대해 OpenAI 형식 요청을 Anthropic 형식으로 변환하고 Anthropic 응답을 OpenAI 형식으로 다시 변환

    • Anthropic 백엔드로의 비스트리밍 요청이 이제 요청/응답 형식을 올바르게 변환
    • 적절한 도구 호출 처리를 위해 transform_str을 사용하도록 스트리밍 핸들러 업데이트
  • Anthropic 백엔드의 도구 호출 및 도구 결과 변환 - Anthropic 모델로 라우팅 시 적절한 도구 사용 워크플로우 활성화

    • 어시스턴트 메시지의 OpenAI 스타일 tool_calls를 Anthropic의 tool_use 형식으로 변환
    • 도구 결과 메시지를 Anthropic의 tool_result 형식으로 변환
    • Anthropic 모델과의 멀티턴 도구 사용 대화 활성화

의존성

  • rustls, tokio-stream, syn을 포함한 12개 패키지를 최신 버전으로 업데이트

v0.31.1 - 2026-01-04

수정됨

  • Anthropic 비스트리밍 인증 헤더 수정 - 잘못된 인증 헤더로 인한 Anthropic 비스트리밍 요청 실패 문제 수정 (#300, #301)
    • Anthropic 백엔드로의 비스트리밍 요청이 이제 Authorization: Bearer 대신 x-api-key 헤더를 올바르게 사용
    • 모든 Anthropic 백엔드 요청에 anthropic-version 헤더 추가
    • HTTP 및 Unix 소켓 전송 경로 간 일관된 헤더 처리 적용
    • Anthropic API가 "Invalid Anthropic API Key" 오류 (HTTP 400)를 반환하던 문제 수정

v0.31.0 - 2026-01-04

추가됨

  • Unix 소켓 서버 바인딩 - TCP와 함께 Unix 소켓 바인딩 지원 추가 (#298)

    • 서버가 이제 로컬 통신을 위해 Unix 도메인 소켓에 바인딩 가능
    • 설정 파일에서 server.unix_socket으로 설정
    • TCP와 Unix 소켓 동시 바인딩 지원
  • Responses API의 Reasoning 파라미터 지원 - /v1/responses 엔드포인트에 reasoning 파라미터 지원 추가 (#296)

    • 중첩 형식 지원: {"reasoning": {"effort": "high"}}
    • 유효한 노력 수준: low, medium, high, xhigh (GPT-5.2 전용)
    • ReasoningEffortLevel 열거형을 사용한 타입 안전 검증
    • 백엔드를 위한 평면 reasoning_effort 형식으로 자동 변환
    • 역직렬화 시 잘못된 노력 값은 명확한 오류 메시지와 함께 거부
    • ResponsesRequest에 with_reasoning() 빌더 메서드 추가
  • 설정 가능한 헬스 체크 엔드포인트 - 백엔드 타입별 설정 가능한 헬스 체크 엔드포인트 추가

    • 백엔드 타입에 따른 헬스 체크 경로 커스터마이징
    • 백엔드별 헬스 검증 지원

v0.30.0 - 2026-01-01

추가됨

  • 와일드카드 패턴 및 모델 별칭의 날짜 접미사 처리 - 모델 별칭에서 와일드카드 패턴 및 자동 날짜 접미사 처리 지원 (#286)
    • 자동 날짜 접미사 정규화: 날짜 접미사가 있는 모델 (예: claude-opus-4-5-20251130)이 기본 모델 (예: claude-opus-4-5-20251101)의 메타데이터와 자동으로 매칭
    • 지원되는 날짜 형식: -YYYYMMDD, -YYYY-MM-DD, -YYMM, @YYYYMMDD
    • * 문자를 사용한 별칭의 와일드카드 패턴 매칭
    • 접두사 패턴: claude-*가 claude-opus, claude-sonnet 등과 매칭
    • 접미사 패턴: *-preview가 gpt-4o-preview, o1-preview 등과 매칭
    • 중위 패턴: gpt-*-turbo가 gpt-4-turbo, gpt-3.5-turbo 등과 매칭
    • 제로 설정 날짜 처리: 설정 변경 없이 자동으로 작동
    • 매칭 우선순위: 정확한 ID > 정확한 별칭 > 날짜 접미사 > 와일드카드 > 기본 이름 폴백

수정됨

  • Anthropic 백엔드 기본 URL - 지정되지 않은 경우 Anthropic 백엔드에 기본 URL 적용 (#288)
  • 백엔드별 owned_by 값 - owned_by 플레이스홀더를 백엔드 타입별 값으로 교체 (#287)

문서

  • 와일드카드 패턴 및 날짜 접미사 처리 문서를 한국어로 번역 (#289)

v0.29.0 - 2026-01-01

추가됨

  • 백엔드 웜업 중 가속 헬스 체크 - 백엔드 웜업 시 가속 헬스 체크 구현 (#282)
    • 백엔드가 HTTP 503 (Service Unavailable)을 반환하면 "웜업 중" 상태로 진입
    • 웜업 중 헬스 체크가 가속된 간격(기본: 1초)으로 수행
    • 모델 가용성 감지 지연을 최대 30초에서 약 1초로 감소
    • warmup_check_interval (기본: 1초) 및 max_warmup_duration (기본: 300초)으로 설정 가능
    • 모델 로딩 중 HTTP 503을 반환하는 llama.cpp 같은 백엔드에 특히 유용
  • 모델 메타데이터 CLI 옵션 - 모델 메타데이터 파일 경로 지정을 위한 --model-metadata 옵션 추가 (#281)
    • 런타임에 모델 메타데이터 YAML 파일을 지정하는 새로운 --model-metadata CLI 인자
    • 설정 파일의 model_metadata_file 설정을 오버라이드
    • 절대 경로, 상대 경로, 틸다 확장(~) 지원

수정됨

  • OpenAI owned_by 필드 - OpenAI owned_by 플레이스홀더를 'openai'로 교체 (#280)
    • OpenAI 백엔드의 모델이 이제 플레이스홀더 텍스트 대신 owned_by: openai를 올바르게 표시
  • 관리자 API 경쟁 조건 - 관리자 API 동시 백엔드 생성에서 경쟁 조건 방지 (#278)
    • 동시 백엔드 생성 요청이 데이터 손상을 일으킬 수 있는 문제 수정
    • 백엔드 관리 작업에 적절한 동기화 추가
  • 핫 리로드 처리 단계 - 핫 리로드에 누락된 처리 단계 추가 (#277)
    • 일부 설정 변경이 핫 리로드 중 제대로 적용되지 않는 문제 수정
    • 설정 리로드 시 모든 처리 단계가 실행되도록 보장
  • 클라우드 백엔드 가용성 상태 - 클라우드 백엔드가 이제 /v1/models/{model_id}에서 available:true 표시 (#272)
    • 클라우드 백엔드(OpenAI, Anthropic, Gemini)가 잘못 사용 불가로 표시되는 문제 수정
    • 클라우드 백엔드가 건강할 때 사용 가능으로 올바르게 표시

문서

  • v0.29.0 기능에 대한 테스트 및 문서 추가 (#459da6a)

v0.28.0 - 2025-12-31

추가됨

  • 도구 호출을 위한 SSE 스트리밍 지원 - 도구 호출에 SSE 스트리밍 지원 추가 (#258)
    • Server-Sent Events를 통한 도구 호출 응답의 실시간 스트리밍
    • 함수 호출 시나리오에서 효율적인 스트리밍 응답 활성화
  • llama.cpp 도구 호출 자동 감지 - /props 엔드포인트를 통한 도구 호출 지원 자동 감지 (#263)
    • 모델 검색 시 /props 엔드포인트를 쿼리하여 chat_template 분석
    • 도구 관련 키워드 감지 (tool, tools, tool_call, function 등)
    • 감지 시 function_calling 기능 자동 활성화
    • /props 엔드포인트를 사용할 수 없는 경우 우아한 폴백
    • HTTP 및 Unix 소켓 백엔드 모두에서 작동
  • 확장된 /v1/models/{model_id} 엔드포인트 - 풍부한 메타데이터 필드로 확장 (#262)
    • 기능 및 가격을 포함한 종합적인 모델 메타데이터 반환
    • 추가 모델 정보가 포함된 향상된 응답 형식
  • 도구 결과 메시지 변환 - 멀티턴 대화에서 도구 결과 메시지 변환 구현 (#265)
    • 도구 결과 메시지 (role: "tool")를 백엔드 네이티브 형식으로 변환
    • Anthropic: user 역할로 tool_result 콘텐츠 블록과 함께 변환
    • Gemini: function 역할로 functionResponse 파트와 함께 변환
    • Anthropic용으로 연속 도구 결과 결합 (병렬 도구 호출)
    • Gemini 변환을 위한 자동 함수 이름 조회
    • 오류 응답을 위한 is_error 표시자 보존
  • 백엔드별 owned_by 플레이스홀더 - llamacpp, vllm, ollama, http 백엔드에 owned_by 플레이스홀더 추가 (#267)

개선됨

  • CLI 도움말 출력 포맷팅 - 제목 헤더 및 프로젝트 귀속을 포함한 --help 출력 포맷팅 개선 (#269)
    • 향상된 커맨드 라인 도움말 시각적 외관
    • 도움말 출력에 프로젝트 귀속 추가

수정됨

  • 모델 메타데이터 캐시 동기화 - 모델 메타데이터 캐시를 ConfigManager와 동기화 (#270)
    • 모델 캐시가 설정 변경을 적절히 반영하도록 보장

CI/CD

  • 도구 호출에 대한 포괄적인 통합 테스트 (#264)

의존성

  • minor-and-patch 그룹에서 3개 업데이트 (#257)

기술 개선

  • Windows 빌드에서 Unix 전용 항목에 대한 dead_code 경고 수정

v0.27.0 - 2025-12-29

추가됨

  • llama.cpp 도구 호출 자동 감지 - llama.cpp 백엔드의 도구 호출 지원 자동 감지 (#260)
    • 모델 검색 시 /props 엔드포인트를 쿼리하여 chat_template 분석
    • 도구 관련 키워드 감지 (tool, tools, tool_call, function 등)
    • 감지 시 function_calling 기능 자동 활성화
    • /props 엔드포인트를 사용할 수 없는 경우 우아한 폴백
    • HTTP 및 Unix 소켓 백엔드 모두에서 작동
  • 완전한 Unix 소켓 지원 - 모델 검색 및 스트리밍을 위한 완전한 Unix 소켓 지원 (#248, #252, #253, #254, #256)
    • Unix 소켓 백엔드의 SSE/스트리밍 지원으로 로컬 소켓을 통한 실시간 응답 가능
    • Unix 소켓 연결을 위한 백엔드 타입 자동 감지
    • Unix 소켓을 통한 vLLM 모델 검색 지원
    • Unix 소켓을 통한 llama.cpp 모델 검색 지원
    • 모델 페처가 Unix 소켓 백엔드를 완전히 지원
  • 도구 호출 변환 (Tool Call Transformation) - 모든 백엔드에서 OpenAI 도구 호출 변환 구현 (#244, #245, #246)
    • Anthropic, Gemini, llama.cpp 백엔드에 대한 도구 정의 변환
    • auto, none, required, 특정 함수 선택을 지원하는 도구 선택 변환
    • 프로바이더 간 통합 응답 형식을 위한 도구 호출 응답 변환
  • 멀티턴 도구 대화 지원 - 멀티턴 대화에서 도구 호출을 위한 메시지 변환 (#241)
    • 도구 결과 메시지 (role: "tool")를 백엔드 네이티브 형식으로 변환
    • Anthropic: user 역할로 tool_result 콘텐츠 블록과 함께 변환
    • Gemini: function 역할로 functionResponse 파트와 함께 변환
    • Anthropic용으로 연속 도구 결과 결합 (병렬 도구 호출)
    • Gemini 변환을 위한 자동 함수 이름 조회
    • 오류 응답을 위한 is_error 표시자 보존

v0.26.0 - 2025-12-27

추가됨

  • 도구 선택 변환 (Tool Choice Transformation) - 백엔드 간 tool_choice 파라미터 자동 변환 (#239)
    • auto, none, required, 특정 함수 선택 지원
    • Anthropic: {"type": "auto|any|tool"} 형식으로 변환, "none"은 도구 제거로 처리
    • Gemini: tool_config.function_calling_config 구조로 변환
    • llama.cpp: 병렬 함수 호출을 위해 parallel_tool_calls 파라미터 유지
    • 크로스 프로바이더 도구 호출을 위해 모델 폴백 시스템과 통합
  • 단일 모델 조회 엔드포인트 - 가용성 상태가 포함된 단일 모델 조회를 위한 GET /v1/models/{model} 엔드포인트 추가 (#236)
    • 실시간 가용성을 나타내는 추가 available 필드와 함께 모델 정보 반환
    • available: true: 최소 하나의 건강한 백엔드가 모델을 제공할 때
    • available: false: 모델은 존재하지만 이를 제공하는 모든 백엔드가 비정상일 때
    • 모델이 어느 백엔드에도 존재하지 않으면 404 반환
    • 특정 모델 조회를 타겟팅하여 전체 모델 집계를 피함으로써 성능 최적화

v0.25.0 - 2025-12-26

추가됨

  • CORS (Cross-Origin Resource Sharing) 지원 - 웹 애플리케이션에 라우터를 임베드하기 위한 설정 가능한 CORS 미들웨어 (#234)
    • Tauri 데스크톱 앱, Electron 앱, 웹 프론트엔드 지원
    • 와일드카드 오리진 및 포트 패턴 (예: http://localhost:*)
    • 커스텀 스킴 지원 (예: tauri://localhost)
    • 설정 가능한 메서드, 헤더, credentials
    • 설정 가능한 max-age의 프리플라이트 캐시
  • Unix 도메인 소켓 백엔드 지원 - Unix 소켓을 통한 안전한 로컬 LLM 통신 (#232)
    • 로컬 백엔드를 위한 unix:///path/to/socket URL 스킴 사용
    • 파일 시스템 권한을 통한 더 나은 보안 (TCP 포트 노출 없음)
    • localhost TCP 대비 낮은 지연 시간 (~30% 개선)
    • 여러 LLM 서버 실행 시 포트 충돌 없음
    • 플랫폼 지원: Linux 및 macOS (Windows는 향후 릴리스 예정)

v0.24.0 - 2025-12-26

추가됨

  • 로컬 LLM 추론용 llama.cpp 백엔드 지원 (#230)
  • 백엔드 설정 없이 라우터 시작을 허용 (#226)

변경됨

  • 설정의 백엔드 추가/제거에 핫 리로드 활성화 (#229)

v0.23.1 - 2025-12-25

CI/CD

  • Windows 빌드 지원 - 릴리스 워크플로우에 Windows x86_64 빌드 타겟 추가 (#224)
    • 릴리스 파이프라인에서 네이티브 Windows 빌드 활성화
    • mingw-w64를 사용한 Linux에서의 크로스 컴파일

v0.23.0 - 2025-12-23

추가됨

  • GLM 4.7 모델 지원 - 사고 기능을 포함한 Zhipu AI의 GLM 4.7 모델 지원 추가 (#222)
    • 전체 사양이 포함된 model-metadata.yaml의 모델 메타데이터 (355B MoE, 32B 활성 파라미터)
    • 사고(thinking) 파라미터 지원: enable_thinking (불린) 및 thinking_budget (1-204,800 토큰)
    • 최대 131K 토큰 출력이 가능한 200K 컨텍스트 윈도우
    • config.yaml.example에 Z.AI 백엔드 설정 예제 추가
    • SiliconFlow 대체 백엔드 설정
    • 모델 메타데이터에 대한 종합적인 통합 테스트
    • 가격: 입력 토큰 $0.60/1M, 출력 토큰 $2.20/1M
  • 사고 패턴 메타데이터 - 암시적 시작 태그를 사용하는 모델에 대한 사고 패턴 메타데이터 추가 (#218)
    • 암시적 사고 시작 태그를 사용하는 모델 지원
    • 사고 콘텐츠 추출을 위한 패턴 기반 감지
  • GCP 서비스 계정 인증 - Gemini 백엔드에 GCP 서비스 계정 인증 지원 추가 (#208)
    • JSON 키 파일 인증 지원
    • 환경 변수 기반 인증
    • 자동 토큰 갱신 및 관리
  • 분산 추적 - 상관관계 ID 전파를 통한 분산 추적 추가 (#207)
    • traceparent 헤더를 사용한 W3C Trace Context 지원
    • 설정 가능한 trace ID, request ID, correlation ID 헤더
    • 모든 재시도 시도에 걸친 Trace ID 전파
    • 헤더에서 가져온 trace ID에 대한 보안 검증
  • 새 모델 메타데이터 - NVIDIA Nemotron 3 Nano, Qwen Image Layered, Kakao Kanana-2 모델 메타데이터 추가 (#202)
  • ASCII 다이어그램 교체 - MkDocs용 ASCII 다이어그램을 이미지로 교체하는 시스템 추가 (#200)
    • MkDocs 빌드 시 ASCII 다이어그램을 SVG 이미지로 자동 교체
    • 원시 Markdown에서 ASCII 아트 가시성 유지

변경됨

  • CI 최적화 - 비코드 파일만 변경된 경우 Rust 테스트 건너뛰기 (#204)
    • 문서 전용 변경에 대한 더 빠른 CI
    • 테스트 실행을 위한 경로 기반 필터링

수정됨

  • 캐시 스탬피드 방지 - singleflight, stale-while-revalidate, 백그라운드 갱신으로 캐시 스탬피드 방지 (#220)
    • Singleflight 패턴으로 모델 캐시 만료 시 썬더링 허드 방지
    • Stale-while-revalidate로 백그라운드에서 갱신하면서 즉시 캐시된 데이터 반환
    • 백그라운드 갱신으로 만료 전 캐시를 사전에 업데이트
  • 전역 프롬프트 핫 리로드 - 핫 리로드를 통한 global_prompts 변경 적용 (#219)
    • 전역 프롬프트 설정 변경이 이제 재시작 없이 적용
  • 모델 캐시 무효화 - 백엔드 설정 변경 시 모델 캐시 무효화 (#206)
    • 백엔드 설정 변경이 이제 모델 캐시 갱신을 적절히 트리거
  • 문서 개선 - 인라인 SVG 및 반응형 크기 조정으로 다이어그램 렌더링 개선
  • 번역 오타 - 문서의 번역 오타 수정
  • Docker CI 수정 - Docker 매니페스트 생성에서 멀티라인 태그 처리
  • 프라이빗 저장소 접근 - CI에서 프라이빗 저장소 에셋 다운로드에 gh CLI 사용
  • GitHub 토큰 인증 - 프라이빗 저장소 접근을 위한 GitHub 토큰 인증 추가
  • 문서 워크플로우 - 환경 보호 오류를 방지하기 위해 문서 워크플로우에서 릴리스 트리거 제거

CI/CD

  • actions/github-script를 7에서 8로 업데이트 (#210)
  • apple-actions/import-codesign-certs를 3에서 6으로 업데이트 (#212)
  • actions/cache를 4에서 5로 업데이트 (#211)
  • actions/checkout을 4에서 6으로 업데이트 (#209)

v0.22.0 - 2025-12-19

추가됨

  • 사전 빌드 바이너리 Docker 지원 - GitHub Releases에서 사전 빌드된 바이너리를 다운로드하는 Dockerfile 및 Dockerfile.alpine 추가 (#189)
    • 일반 사용을 위한 Debian Bookworm 기반 이미지 (~50MB)
    • 최소 배포를 위한 Alpine 3.20 기반 이미지 (~10MB)
    • TARGETARCH를 사용한 멀티 아키텍처 지원 (linux/amd64, linux/arm64)
    • 릴리스 버전 선택을 위한 VERSION 빌드 인자
    • 보안을 위한 비루트 사용자 실행
    • 이미지 메타데이터를 위한 OCI 라벨
  • 컨테이너 헬스 체크 CLI - 컨테이너 오케스트레이션을 위한 --health-check CLI 인자 구현 (#189)
    • 서버가 정상이면 종료 코드 0, 비정상이면 1 반환
    • 사용자 정의 헬스 엔드포인트를 위한 선택적 --health-check-url
    • 적절한 IPv6 주소 처리
    • 기본 5초 타임아웃
  • Docker Compose 빠른 시작 - 간편한 배포를 위한 docker-compose.yml 추가 (#189)
    • 설정을 위한 볼륨 마운트
    • 환경 변수 지원 (RUST_LOG)
    • 리소스 제한 및 헬스 체크
  • 자동화된 Docker 이미지 퍼블리싱 - 릴리스 워크플로우에 Docker 빌드 및 ghcr.io 푸시 추가 (#189)
    • 바이너리 릴리스 후 Debian 및 Alpine 이미지 빌드
    • 멀티 플랫폼 지원 (linux/amd64, linux/arm64)
    • semver로 자동 태깅 (VERSION, MAJOR.MINOR, latest)
    • Alpine 이미지는 -alpine 접미사로 태그
    • 더 빠른 빌드를 위한 GitHub Actions 캐시
  • MkDocs 문서 웹사이트 - Material 테마로 종합적인 문서 사이트 구축 (#183)
    • 시작하기, 기능, 운영, 개발 섹션이 있는 전체 네비게이션 구조
    • GitHub Pages 자동 배포를 위한 GitHub Actions 워크플로우
    • 사용자 정의 스타일시트 및 테마 구성
  • 한국어 문서 번역 - 모든 문서의 완전한 한국어 현지화 (#190)
    • 20개 문서 파일 모두 한국어로 번역
    • 네비게이션에 언어 전환기 (영어/한국어)
    • GitHub Actions 워크플로우에서 다국어 빌드
  • 의존성 보안 감사 - 취약점 스캔을 위한 cargo-deny 추가 (#192)
    • CI 워크플로우에서 보안 권고 확인
    • 라이선스 준수 검증
    • 의존성 소스 검증
  • Dependabot 통합 - Cargo 및 GitHub Actions 자동 의존성 업데이트 (#192)
  • 보안 정책 - 취약점 보고 프로세스를 포함한 종합적인 SECURITY.md 추가 (#191)

변경됨

  • 고아 아키텍처 문서를 MkDocs 사이트에 통합 (#186)
  • URL 친화적 파일명을 위해 문서 파일명을 소문자 케밥케이스로 변경 (#186)
  • 다양한 GitHub Actions를 최신 버전으로 업데이트 (checkout@v6, setup-python@v6, upload-artifact@v6 등)

수정됨

  • 헬스 체크 응답 검증 로직 버그 (연산자 우선순위 문제)
  • 설정 오류를 조용히 숨기던 주소 파싱 폴백
  • 헬스 체크에서 IPv6 주소 형식 지정 (이제 브라켓 표기법을 올바르게 사용)

보안

  • reqwest 0.11→0.12, prometheus 0.13→0.14, validator 0.18→0.20 업데이트
  • 더 나은 유지보수를 위해 dotenv를 dotenvy로 교체
  • 빌드 컨텍스트에서 민감한 파일 제외를 위한 .dockerignore 추가

v0.21.0 - 2025-12-19

추가됨

  • Gemini 3 Flash Preview 모델 - gemini-3-flash-preview 모델 지원 추가 (#168)
  • 백엔드 오류 전달 - 4xx 응답에 대해 백엔드의 상세 오류 메시지 전달 (#177)
    • OpenAI, Anthropic, Gemini 백엔드의 원본 오류 메시지 파싱 및 전달
    • 가능한 경우 param 필드 보존 (잘못된 파라미터 오류에 유용)
    • 백엔드 응답 파싱 불가 시 일반 오류 메시지로 폴백
    • OpenAI 호환 오류 형식 유지
    • 모든 백엔드 형식에 대한 오류 파싱 단위 테스트 포함
  • API 엔드포인트 기본 인증 모드 - API 엔드포인트에 대한 설정 가능한 인증 적용 (#173)
    • api_keys 설정에 새 mode 필드: permissive (기본값) 또는 blocking
    • permissive 모드: API 키 없는 요청 허용 (하위 호환성 유지)
    • blocking 모드: 인증된 요청만 처리, 미인증 요청은 401 수신
    • 보호 엔드포인트: /v1/chat/completions, /v1/completions, /v1/responses, /v1/images/*, /v1/models
    • 헬스 엔드포인트 (/health, /healthz)는 항상 인증 없이 접근 가능
    • 인증 모드 변경에 대한 핫 리로드 지원
    • 두 모드에 대한 포괄적인 통합 테스트
    • API.md, configuration.md, manpage 문서 업데이트

수정됨

  • UTF-8 멀티바이트 문자 손상 - 스트리밍 응답에서 UTF-8 멀티바이트 문자 손상 처리 (#179)
  • GPT Image response_format - GPT Image 모델에서 response_format 파라미터 제거 (#176)
  • 자동 검색 검증 - Anthropic을 제외한 모든 백엔드에 대해 자동 검색 허용 (#172)

변경됨

  • architecture.md 업데이트 및 문서 오류 수정 (#167, #169)
  • AGENTS.md 추가 및 CLAUDE.md 연결

v0.20.0 - 2025-12-18

추가됨

  • Gemini 이미지 변형 지원 - Gemini (nano-banana) 모델에 대한 이미지 변형 지원 추가 (#165)
  • Gemini 이미지 편집 지원 - Gemini (nano-banana) 모델에 대한 제한적 이미지 편집 지원 구현 (#164)
  • 향상된 이미지 생성 - 스트리밍 및 GPT Image 기능으로 /v1/images/generations 향상 (#161)
  • GPT Image 1.5 모델 - gpt-image-1.5 모델 지원 추가 (#159)
  • 이미지 변형 엔드포인트 - 이미지 변형을 위한 /v1/images/variations 엔드포인트 구현 (#155)
  • 이미지 편집 엔드포인트 - 이미지 편집(인페인팅)을 위한 /v1/images/edits 엔드포인트 구현 (#156)
    • 완전한 OpenAI Images Edit API 호환성
    • GPT Image 모델 지원: gpt-image-1, gpt-image-1-mini, gpt-image-1.5 (권장)
    • dall-e-2 모델 레거시 지원
    • 공유 유틸리티를 사용한 multipart form-data 파싱
    • PNG 이미지 검증 (형식, 크기, 정사각형 치수)
    • 선택적 마스크 검증 (원본 이미지와 치수 일치)
  • 공유 이미지 유틸리티 - 이미지 편집/변형 엔드포인트를 위한 공유 유틸리티 구현 (#154)
  • 외부 프롬프트 파일 - 외부 Markdown 파일에서 시스템 프롬프트 로딩 지원 (#146)
    • BackendPromptConfig 및 ModelPromptConfig에 새 prompt_file 필드
    • GlobalPromptConfig에 새 default_file 및 prompts_dir 필드
    • 경로 순회 공격 방지를 위한 안전한 경로 검증
    • 프롬프트 파일 관리를 위한 REST API 엔드포인트
    • 크기 제한이 있는 파일 캐싱 (최대 100개 항목, 총 50MB)
    • 프롬프트 파일에 대한 핫 리로드 지원
  • Solar Open 100B 모델 - Solar Open 100B 모델 메타데이터 추가
  • 자동 모델 검색 - 모델이 명시적으로 설정되지 않은 경우 백엔드가 /v1/models API에서 자동으로 사용 가능한 모델 검색 (#142)
    • OpenAI, Gemini, vLLM 백엔드가 자동 검색 지원
    • Ollama 백엔드는 vLLM의 검색 메커니즘 사용 (OpenAI 호환 API)
    • 10초 타임아웃으로 시작 차단 방지
    • 검색 실패 시 하드코딩된 기본값으로 폴백

변경됨

  • BackendFactory::create_backend_from_typed_config()가 비동기 모델 검색을 지원하기 위해 async로 변경
  • OpenAI, Gemini, vLLM의 백엔드 from_config() 메서드가 async로 변경

보안

  • API 키 수정 - 자격 증명 노출 방지를 위한 API 키 수정 구현 (#150)

성능

  • 바이너리 크기 최적화 - 릴리스 바이너리 크기를 20MB에서 6MB로 최적화 (70% 감소) (#144)

리팩토링

  • 이슈 #147의 우선순위 2를 위한 대용량 파일 분할
  • 각 파일을 500줄 미만으로 유지하기 위한 대용량 파일 분할 (#148)

v0.19.0 - 2025-12-13

추가됨

  • 런타임 설정 관리 API - 런타임에 설정을 보고 수정하기 위한 포괄적인 REST API (#139)
    • 설정 쿼리 API:
      • GET /admin/config/full - 민감 정보가 마스킹된 전체 설정 조회
      • GET /admin/config/sections - 15개 설정 섹션 전체 목록
      • GET /admin/config/{section} - 특정 섹션 설정 조회
      • GET /admin/config/schema - 클라이언트 측 검증을 위한 JSON Schema
    • 설정 수정 API:
      • PUT /admin/config/{section} - 섹션 설정 교체
      • PATCH /admin/config/{section} - 부분 업데이트 (JSON merge patch)
      • POST /admin/config/validate - 적용 전 설정 검증
      • POST /admin/config/apply - 핫 리로드로 설정 적용
    • 설정 저장/복원 API:
      • POST /admin/config/export - 설정 내보내기 (YAML/JSON/TOML)
      • POST /admin/config/import - 설정 가져오기 및 적용
      • GET /admin/config/history - 설정 변경 이력 조회
      • POST /admin/config/rollback/{version} - 이전 버전으로 롤백
    • 백엔드 관리 API:
      • POST /admin/backends - 새 백엔드 추가
      • GET /admin/backends/{name} - 백엔드 설정 조회
      • PUT /admin/backends/{name} - 백엔드 설정 업데이트
      • DELETE /admin/backends/{name} - 백엔드 제거
      • PUT /admin/backends/{name}/weight - 백엔드 가중치 업데이트
      • PUT /admin/backends/{name}/models - 백엔드 모델 목록 업데이트
    • API 키, 비밀번호, 토큰에 대한 민감 정보 마스킹
    • 모든 설정 섹션에 대한 JSON Schema 생성
    • 설정 이력 추적 (최대 100개 항목, 설정 가능)
    • 크기 기반 퇴거를 통한 메모리 효율적 이력 저장 (10MB 제한)
    • 스레드 안전성을 위한 AtomicU64 사용 원자적 버전 카운터
    • 오류 코드가 포함된 구조화된 오류 응답
  • Admin REST API 문서 - 포괄적인 개발자 가이드 (docs/admin-api.md)
    • 요청/응답 예제가 포함된 완전한 API 레퍼런스
    • Python, JavaScript/TypeScript, Go용 클라이언트 SDK 예제
    • 모범 사례 및 보안 고려사항
  • 통합 테스트 - 설정 관리 API 엔드포인트에 대한 33개 통합 테스트

수정됨

  • 심각: 설정 변경이 이제 실행 중인 시스템에 실제로 적용됨
  • 심각: JSON 문자열 저장 및 크기 기반 퇴거로 메모리 증가 제어
  • 높음: 입력 검증 추가 (1MB 콘텐츠 제한, 32레벨 중첩 깊이)
  • 높음: 민감 내보내기에 권한 상승 및 감사 로깅 필요
  • 높음: 포괄적인 민감 필드 감지 (30개 이상 패턴)
  • 중간: 검증 함수가 이제 실제 검증 수행
  • 중간: 버전 카운터에 AtomicU64로 경쟁 조건 수정
  • 중간: 허용된 백엔드 이름 문자에서 콜론 제거
  • 중간: 오류 코드가 포함된 구조화된 오류 응답
  • 중간: 초기화 플래그로 중복 이력 항목 방지
  • 낮음: 더 나은 성능을 위해 불필요한 클론 제거
  • 낮음: AdminConfig를 통해 제한 설정 가능
  • 낮음: 중복 검증 로직 리팩토링
  • 낮음: 엣지 케이스에 대한 테스트 커버리지 개선

변경됨

  • 모든 가이드에서 설정 관리 API 문서 향상
  • 새 관리 엔드포인트로 manpage 업데이트
  • 포괄적인 설정 관리 API 섹션으로 API.md 업데이트

v0.18.0 - 2025-12-13

추가됨

  • API 키별 속도 제한 - API 키별 속도 제한 구현 (#137)
    • 각 API 키에 대한 개별 속도 제한
    • 키당 분당 요청 수 설정 가능
  • API 키 관리 시스템 - 포괄적인 API 키 관리 및 설정 시스템
    • 다중 키 소스: 설정 파일, 외부 파일, 환경 변수
    • 키 속성: 스코프, 속도 제한, 만료, 활성화 상태
    • 키 설정 변경에 대한 핫 리로드 지원
  • Files API 인증 - Files API에 대한 인증 및 권한 부여 구현 (#131)
    • 파일 작업에 대한 API 키 인증
    • 파일 소유권 적용
    • 모든 파일에 대한 관리자 접근 제어
  • 런타임 설정 핫 리로드 - 런타임 설정 업데이트를 위한 완전한 핫 리로드 기능 (#130)
    • 자동 설정 파일 감시
    • 분류된 업데이트: 즉시, 점진적, 재시작 필요

변경됨

  • 모듈식 구조로 대규모 리팩토링
    • CLI 및 앱 유틸리티를 모듈식 구조로 추출 (#132)
    • converter.rs를 모듈식 구조로 분할 (#132)
    • 대용량 소스 파일을 모듈식 구조로 분할
    • find_gemini_backend 함수 로직 통합
  • 리팩토링된 모듈 구조를 반영하도록 architecture.md 업데이트

수정됨

  • admin/metrics/files 엔드포인트에 ConnectInfo 확장 추가
  • API 키 관리의 보안 취약점 해결
  • API 키 관리의 코드 품질 문제 해결

문서

  • API 키 관리 문서 추가
  • 포괄적인 API 키 관리 테스트 추가

v0.17.0 - 2025-12-12

추가됨

  • Anthropic 백엔드 파일 콘텐츠 변환 - 라우터에 업로드된 파일을 이제 Anthropic 백엔드와 함께 사용 가능 (#126)
    • 파일 콘텐츠를 Anthropic 메시지 형식으로 자동 변환
    • base64 인코딩을 사용한 텍스트 및 문서 파일 지원
    • 파일 해결 미들웨어와 원활한 통합
  • Gemini 백엔드 파일 콘텐츠 변환 - 라우터에 업로드된 파일을 이제 Gemini 백엔드와 함께 사용 가능 (#127)
    • 파일 콘텐츠를 Gemini API 형식으로 자동 변환
    • 적절한 MIME 타입 처리를 통한 인라인 데이터 지원
    • 크로스 프로바이더 파일 지원으로 한 번 업로드한 파일을 모든 백엔드에서 사용 가능

수정됨

  • 스트리밍 파일 업로드 - 메모리 소진 방지를 위한 스트리밍 파일 업로드 구현 (#128)
    • 대용량 파일 업로드가 더 이상 전체 파일을 메모리에 로드하지 않음
    • 효율적인 메모리 사용을 위한 스트리밍 처리
    • 대용량 파일 업로드 시 OOM 오류 방지

변경됨

  • 없음

v0.16.0 - 2025-12-12

추가됨

  • OpenAI 호환 Files API - OpenAI Files API 엔드포인트 전체 구현 (#111)
    • multipart/form-data 지원 파일 업로드
    • 파일 목록, 조회, 삭제
    • 파일 콘텐츠 다운로드
    • 용도 지원: fine-tune, batch, assistants, user_data
  • 파일 해결 미들웨어 - 채팅 완료를 위한 자동 파일 콘텐츠 주입 (#120)
    • 파일 ID로 채팅 메시지에서 업로드된 파일 참조
    • 채팅 컨텍스트에 자동 콘텐츠 주입
  • 영속적 메타데이터 스토리지 - 서버 재시작 후에도 파일 메타데이터 유지 (#125)
    • 데이터 파일과 함께 저장되는 사이드카 JSON 파일 (.meta.json)
    • 파일에서 메타데이터 재빌드를 통한 시작 시 자동 복구
    • 고아 파일 감지 및 선택적 정리
  • OpenAI 백엔드 파일 처리 - 로컬에 업로드된 파일이 필요시 OpenAI로 전달됨 (#121, #122)
  • GPT-5.2 모델 지원 - OpenAI 백엔드에 GPT-5.2 모델 메타데이터 추가 (#124)
  • 서킷 브레이커 패턴 - 서킷 브레이커를 통한 자동 백엔드 장애 조치 (#93)
    • 상태: Closed → Open → Half-Open → Closed 사이클
    • 설정 가능한 실패 임계값 및 복구 타임아웃
    • 백엔드별 서킷 브레이커 인스턴스
    • 서킷 브레이커 상태 및 제어를 위한 관리자 엔드포인트
  • 관리자 엔드포인트 인증 - 인증 및 감사 로깅으로 관리자 엔드포인트 보안
  • 설정 가능한 폴백 모델 - 모델 사용 불가 시나리오에 대한 자동 모델 폴백 (#50)
    • 기본 모델에 대한 폴백 체인 정의 (예: gpt-4o → gpt-4-turbo → gpt-3.5-turbo)
    • 크로스 프로바이더 폴백 지원 (예: OpenAI → Anthropic)
    • 프로바이더 간 자동 파라미터 변환
    • 계층화된 장애 조치 보호를 위한 서킷 브레이커와 통합
    • 설정 가능한 트리거 조건 (오류 코드, 타임아웃, 연결 오류, 서킷 브레이커 오픈)
    • 폴백 사용 시 응답 헤더 표시 (X-Fallback-Used, X-Original-Model, X-Fallback-Model)
    • 폴백 모니터링을 위한 Prometheus 메트릭
  • Pre-commit Hook - 커밋 전 자동 코드 포맷팅 및 린팅

수정됨

  • 폴백 체인 검증 - 체인 검증을 Validate derive에 통합
  • 폴백 성능 - 폴백 체인 순회를 위한 인덱스 기반 조회 사용
  • 락 경합 - 스냅샷 패턴으로 FallbackService의 락 경합 감소
  • 보안 - 폴백 오류 헤더 및 메트릭 레이블 살균
  • 서킷 브레이커 보안 - 관리자 엔드포인트에 백엔드 이름 검증 추가
  • 스레드 안전성 - half-open 요청 제한을 위한 스레드 안전 CAS 루프 사용

변경됨

  • 문서 업데이트 - 폴백 설정, 서킷 브레이커, Files API에 대한 포괄적인 문서
  • 코드 품질 - clippy 경고 수정 및 코드 포맷팅
  • Pre-commit Hook 위치 - pre-commit hook을 .githooks 디렉토리로 이동

v0.15.0 - 2025-12-05

추가됨

  • Nano Banana API 지원 - OpenAI 호환 인터페이스로 Gemini 이미지 생성 API 지원 추가 (#102)
    • nano-banana 및 nano-banana-pro 모델 지원
    • OpenAI Images API와 Gemini Imagen API 간 자동 형식 변환
  • /v1/models 엔드포인트 분리 - 표준 경량 응답 vs 확장 메타데이터 응답 (#101)
    • /v1/models는 더 나은 성능을 위해 경량 응답 반환
    • /v1/models?extended=true는 상세 모델 정보를 위한 전체 메타데이터 반환

변경됨

  • StreamService 추출 - 모듈식 아키텍처를 위해 스트리밍 핸들러 로직을 전용 StreamService로 추출 (#106)
  • 재시도 로직 중복 제거 - proxy.rs에서 재시도 로직 코드 통합 (#103)

수정됨

  • 적절한 오류 전파 - HttpClientFactory에서 .expect() 패닉을 적절한 오류 전파로 교체 (#104)

성능

  • LRU 캐시 최적화 - 캐시 조회에 쓰기 락 대신 읽기 락 사용 (#105)

v0.14.2 - 2025-12-05

추가됨

  • 토큰 사용량 로깅 - 요청 완료 시 입력/출력 토큰 수 로깅 (#92)
  • 리포트 제외 목록 - 리포트에 대한 제외 목록 설정 추가

변경됨

  • 없음

수정됨

  • 없음

v0.14.1 - 2025-12-05

추가됨

  • TTFB 벤치마크 타겟 - Makefile에 TTFB 벤치마크 타겟 추가
  • 연결 사전 준비 - Anthropic, Gemini, OpenAI 백엔드에 대한 연결 사전 준비 추가

수정됨

  • Anthropic 백엔드 TTFT - 연결 풀링 및 HTTP/2로 Anthropic 백엔드 TTFT 최적화 (#90)
  • Gemini 백엔드 TTFT - 연결 풀링 및 HTTP/2로 Gemini 백엔드 TTFT 최적화 (#88)
  • 모델 메타데이터 별칭 매칭 - 모델 메타데이터 조회에서 별칭에 기본 이름 폴백 매칭 적용 (#84)

변경됨

  • 공유 HTTP 클라이언트 - HealthChecker와 요청 핸들러 간 HTTP 클라이언트 공유
  • 아키텍처 및 성능 문서 업데이트

v0.14.0 - 2025-12-04

추가됨

  • 전역 시스템 프롬프트 주입 - 라우터 전역 시스템 프롬프트 주입 추가 (#82)

수정됨

  • GitHub Actions - 더 이상 사용되지 않는 actions-rs/toolchain을 dtolnay/rust-toolchain으로 교체
  • macOS ARM64 빌드 - macOS ARM64 ring 빌드를 위한 RUSTFLAGS 추가
  • musl 빌드 - musl 크로스 컴파일 지원을 위해 rustls-tls로 전환

변경됨

  • GitHub Action runner 업데이트

v0.13.0 - 2025-12-04

추가됨

  • OpenAI Responses API (/v1/responses) - OpenAI Responses API 전체 구현 (#49)
    • 자동 만료가 있는 세션 기반 응답 관리
    • 만료된 세션에 대한 백그라운드 정리 작업
    • Responses API와 Chat Completions 간 요청/응답 형식 변환기
  • API 키용 SecretString - 모든 백엔드에서 SecretString을 사용한 안전한 API 키 저장 (#76)
  • 모델 메타데이터 오버라이드 - model-metadata.yaml을 통한 /v1/models 응답 필드 오버라이드 허용 (#75)

수정됨

  • 진정한 SSE 스트리밍 - /v1/responses API에 대한 적절한 Server-Sent Events 스트리밍 구현

변경됨

  • SseParser 즉시 모드 - 즉시 파싱 모드로 첫 응답 지연 감소
  • 문자열 할당 최적화 - 할당 감소로 성능 개선
  • 오류 처리 표준화 - 코드베이스 전체에 일관된 오류 처리 패턴

보안

  • 세션 접근 제어 - 세션 관리에 적절한 접근 제어 추가
  • 입력 검증 - Responses API에 대한 포괄적인 입력 검증

v0.12.0 - 2025-12-04

추가됨

  • SSRF 방지 모듈 - 포괄적인 SSRF 방지를 위한 새 UrlValidator 모듈 (#66)
  • 중앙화된 HTTP 클라이언트 팩토리 - 백엔드 간 일관된 HTTP 클라이언트 생성을 위한 HttpClientFactory (#67)

수정됨

  • 일관된 해시 알고리즘 - 적절한 라우팅을 위해 이진 검색에서 정확한 해시 일치 처리 (#72)
  • 패닉을 Option 반환으로 교체 - 패닉을 Option 반환으로 교체하여 신뢰성 향상 (#71)
  • 하드코딩된 인증 요구사항 제거 - /v1/models 엔드포인트에 더 이상 하드코딩된 인증 필요 없음
  • GitHub Actions - Projects V2 API 접근을 위해 GitHub App 토큰 사용

변경됨

  • OpenAI 모델 메타데이터 재구성 - 더 나은 유지보수성을 위해 모델 메타데이터를 패밀리별로 구성 (#74)
  • AnthropicStreamTransformer 추출 - Anthropic 스트림 변환을 위한 전용 모듈 (#73)
  • Backends 모듈 분할 - 더 깔끔한 아키텍처를 위해 backends mod.rs를 별도 모듈로 분할 (#69)
  • 임베디드 테스트 추출 - 더 나은 구성을 위해 테스트를 별도 파일로 이동 (#68)
  • RequestExecutor 추출 - 요청 실행을 위한 공유 공통 모듈 (#65)
  • HeaderBuilder 추출 - 인증 전략을 전용 모듈로 이동 (#64)
  • AtomicStatistics 추출 - 원자적 통계를 위한 공유 공통 모듈

기술 개선

  • 모듈식 아키텍처로 코드 구성 개선
  • 더 나은 관측성을 위한 통계 집계 구현
  • SSRF 방지 기능으로 보안 강화

v0.11.0 - 2025-12-03

추가됨

  • 네이티브 Anthropic Claude API 백엔드 (type: anthropic) - OpenAI 호환 엔드포인트 (#33)
    • CONTINUUM_ANTHROPIC_API_KEY 환경 변수에서 자동 API 키 로드
    • Claude 사고 모델을 위한 확장 사고 블록 지원
    • OpenAI에서 Claude로 reasoning 파라미터 변환 (reasoning_effort)
    • 플랫 reasoning_effort 파라미터 지원
  • Claude 4, 4.1, 4.5 모델 메타데이터 문서

수정됨

  • Anthropic/Gemini 백엔드에 대한 헬스 체크 및 모델 가져오기 개선
  • 압축 문제 방지를 위해 스트리밍 요청에 Accept-Encoding: identity 헤더 추가
  • 적절한 Accept-Encoding 처리를 위해 proxy.rs의 make_backend_request 수정

변경됨

  • 리팩토링: 코드 포맷팅 적용 및 clippy 경고 수정
  • 리팩토링: Accept-Encoding 헤더 대신 reqwest no_gzip/no_brotli/no_deflate 사용

v0.10.0 - 2025-12-03

추가됨

  • 네이티브 Google Gemini API 백엔드 (type: gemini) - OpenAI 호환 엔드포인트 (#32)
    • CONTINUUM_GEMINI_API_KEY 환경 변수에서 자동 API 키 로드
    • 사고 모델 (gemini-2.5-pro, gemini-3-pro)에 대한 300초 확장 스트리밍 타임아웃
    • 응답 잘림 방지를 위한 사고 모델의 자동 max_tokens 조정
    • reasoning_effort 파라미터 지원
  • 네이티브 OpenAI API 백엔드 (type: openai) - 내장 설정
    • CONTINUUM_OPENAI_API_KEY 환경 변수에서 자동 API 키 로드
    • /v1/models 응답에 내장 OpenAI 모델 메타데이터
  • OpenAI Images API 지원 (/v1/images/generations) - DALL-E 및 gpt-image-1 모델 (#35)
    • 설정 가능한 이미지 생성 타임아웃 (timeouts.request.image_generation)
    • 이미지 생성 파라미터에 대한 포괄적인 입력 검증
    • 이미지 생성 API에 대한 응답 형식 검증
  • OpenAI 및 API 키 백엔드에 대한 인증된 헬스 체크
  • 스트리밍 요청에 API 키 인증
  • 설정된 모델만 표시하도록 /v1/models 필터링
  • -c/--config로 명시적으로 지정된 경우 모든 설정 파일 경로 허용
  • .env.example 및 타입이 지정된 백엔드 설정 예제
  • GLM 4.6, Kimi K2, DeepSeek, GPT, Qwen3 시리즈에 대한 포괄적인 모델 메타데이터

수정됨

  • 사고 모델 (gemini-2.5-pro, gemini-3-pro)의 스트리밍 응답 잘림
  • Gemini 백엔드의 모델 ID 정규화 및 스트리밍 호환성
  • 최신 OpenAI 모델에 대해 max_tokens를 max_completion_tokens로 변환
  • 모든 API 엔드포인트에 대한 올바른 URL 구성
  • 보안: 디버그 로그에서 민감 데이터 제거
  • 보안: DoS 공격 방지를 위한 요청 본문 크기 제한 추가

변경됨

  • 리팩토링: RequestType enum으로 요청 재시도 로직 통합
  • 리팩토링: 락 프리 통계 및 슬라이스 반환으로 Gemini 백엔드 성능 개선
  • Gemini 백엔드 문서 및 max_tokens 동작 문서 추가
  • 이미지 생성 API 문서 추가
  • model-metadata.yaml의 기능 명명 표준화

v0.9.0 - 2025-12-02

추가됨

  • 네이티브 Google Gemini API 백엔드 (type: gemini) - OpenAI 호환 엔드포인트 (#32)
    • CONTINUUM_GEMINI_API_KEY 환경 변수에서 자동 API 키 로드
    • 사고 모델 (gemini-2.5-pro, gemini-3-pro)에 대한 300초 확장 스트리밍 타임아웃
    • 응답 잘림 방지를 위한 사고 모델의 자동 max_tokens 조정
    • reasoning_effort 파라미터 지원
  • DALL-E 및 gpt-image-1 모델을 위한 OpenAI Images API 지원 (/v1/images/generations) (#35)
  • 설정 가능한 이미지 생성 타임아웃 (timeouts.request.image_generation)
  • GPT-5 패밀리, o-시리즈, 오디오/음성, 비디오 (Sora), 임베딩 모델을 포함한 OpenAI 모델의 포괄적인 메타데이터
  • 토큰 버킷 알고리즘을 사용한 향상된 속도 제한 (#11)
  • 포괄적인 Prometheus 메트릭 및 모니터링 (#10)
  • 설정 파일 마이그레이션 및 자동 수정 CLI 유틸리티 (#29)
  • 메트릭 엔드포인트에 대한 포괄적인 인증

수정됨

  • 심각: 토큰 리필에서 경쟁 조건 제거
  • 심각: SHA-256 해싱으로 API 키 보호
  • 심각: 무제한 버킷 증가를 통한 메모리 소진 방지
  • 심각: 헤더 인젝션 취약점 방지
  • 높음: X-Forwarded-For 조작을 통한 IP 스푸핑 방지
  • 높음: 메모리 누수 방지를 위한 메트릭 싱글톤 패턴 구현
  • 높음: 불필요한 문자열 할당 제거
  • 높음: 속도 제한을 위한 모델 추출 구현
  • 메트릭 폭발 DoS 공격 방지를 위한 포괄적인 카디널리티 제한 및 레이블 살균 추가
  • 패닉 조건 방지를 위한 오류 처리 개선
  • 설정 테스트에서 환경 변수 경쟁 조건 해결
  • 메트릭 RequestTimer의 통합 테스트 실패 수정
  • 메트릭 보안 모듈의 단위 테스트 실패 수정

변경됨

  • 리팩토링: 속도 제한에서 과도한 Arc 래핑 제거
  • 더 나은 유지보수성을 위한 문서 구조 재구성
  • 포괄적인 메트릭 문서 추가
  • 속도 제한 기능 문서 업데이트
  • 개발용 mock 서버 및 샘플 설정 파일 제거
  • 임시 테스트 파일 제거 및 gitignore 개선
  • 중복 man 페이지 제거 및 gitignore 업데이트
  • 올바른 레포지토리를 언급하도록 README.md 업데이트
  • 릴리스 워크플로우 업데이트

v0.8.0 - 2025-09-09

추가됨

  • 메타데이터 공유를 위한 모델 ID 별칭 지원 (#27)
  • 포괄적인 속도 제한 문서
  • 캐시 포이즈닝을 통한 DoS 방지를 위한 models 엔드포인트에 강력한 속도 제한

수정됨

  • 모든 백엔드가 비정상일 때 503 대신 빈 목록 반환 (#28)
  • 오류 처리 및 분류 개선
  • await 지점에서 보유된 MutexGuard에 대한 clippy 경고 해결

변경됨

  • 더 실용적인 /v1/models 엔드포인트 속도 제한 증가
  • configuration.md에 별칭 기능 문서 추가

v0.7.1 - 2025-09-08

수정됨

  • 홈 디렉토리 및 실행 파일 경로에 대한 설정 경로 검증 개선 (#26)

v0.7.0 - 2025-09-07

추가됨

  • 풍부한 메타데이터 지원으로 /v1/models 엔드포인트 확장 (#23) (#25)
  • 향상된 설정 관리 (#9) (#22)
  • 향상된 오류 처리를 포함한 고급 로드 밸런싱 전략 (#21)

수정됨

  • 하드코딩된 25초 제한 대신 config.yaml의 스트리밍 타임아웃 설정 사용

변경됨

  • 제외 목록에 yaml 추가

v0.6.0 - 2025-09-03

추가됨

  • GitHub Project 자동화 워크플로우
  • 포괄적인 타임아웃 설정 및 모델 문서 업데이트

수정됨

  • 하드코딩된 값 대신 config.yaml의 타임아웃 설정 사용 (#19)
  • clippy 경고 및 벤치마크 컴파일 문제 수정

변경됨

  • cargo fmt 적용

v0.5.0 - 2025-09-02

추가됨

  • 계층화된 설계를 갖춘 확장 가능한 아키텍처 (#16)
  • 포괄적인 통합 테스트 및 성능 최적화
  • 완전한 서비스 레이어 구현
  • 미들웨어 아키텍처 및 향상된 백엔드 추상화
  • CLI 및 설정 파일 지원을 포함한 설정 가능한 연결 풀 크기
  • YAML 지원을 포함한 포괄적인 설정 관리 (#7)
  • continuum-router용 Debian 패키징 및 man 페이지

수정됨

  • 테스트에서 Option 올바르게 처리
  • 모델 필드 없는 스트리밍 요청을 우아하게 처리하도록 테스트 업데이트
  • 테스트에서 부동 소수점 정밀도 및 타이밍 문제 해결
  • 객체 풀 및 SSE 파서에서 테스트 실패 및 교착 상태 해결
  • CI 테스트 실패 해결 및 테스트 성능 개선
  • CI 환경에서 설정 감시자 테스트 실패 해결
  • 초기 헬스 체크 경쟁 조건 해결
  • 오류 처리 및 재시도 로직의 심각한 보안 취약점
  • 타이밍 변동에 대한 타임아웃 테스트 허용 오차 조정

변경됨

  • 더 나은 가독성을 위해 복잡한 타입을 타입 별칭으로 추출
  • 모든 cargo fmt 및 clippy 경고 해결
  • 합리적인 기본값으로 재시도 설정을 선택적으로 변경
  • 설정 접근 최적화 및 포괄적인 타임아웃 관리 추가
  • 타임아웃 설정의 모델 이름을 최신 버전으로 업데이트
  • 완전한 문서 업데이트
  • 과대 모듈을 계층화된 아키텍처로 분할

성능

  • 설정 접근 최적화 및 포괄적인 타임아웃 관리 추가

v0.4.0 - 2025-08-25

추가됨

  • 헬스 모니터링을 포함한 모델 기반 라우팅 (#6)

수정됨

  • 더 나은 호환성을 위한 헬스 체크 통합 및 SSE 파싱 개선

변경됨

  • README.md 업데이트

v0.3.0 - 2025-08-25

추가됨

  • 실시간 채팅 완료를 위한 SSE 스트리밍 지원 (#5)

수정됨

  • 스트리밍 응답에서 비성공 상태 코드 처리
  • 백엔드가 404 또는 기타 오류 상태 코드를 반환해도 스트리밍 계속 허용
  • 클라이언트에 백엔드 오류 상태를 알리기 위해 SSE 오류 이벤트를 먼저 전송

v0.2.0 - 2025-08-25

추가됨

  • 여러 엔드포인트에서 모델 집계 (#4)

v0.1.0 - 2025-08-24

추가됨

  • OpenAI 호환 엔드포인트 및 프록시 기능
  • 사용 가능한 모델 목록을 위한 /v1/models 엔드포인트
  • 레거시 OpenAI completions API를 위한 /v1/completions 엔드포인트
  • 채팅 API를 위한 /v1/chat/completions 엔드포인트
  • 라운드 로빈 로드 밸런싱을 포함한 다중 백엔드 지원 (#1)
  • 적절한 오류 메시지를 포함한 정의되지 않은 라우트용 폴백 핸들러

수정됨

  • 모든 엔드포인트에서 오류 처리 일관성 개선

변경됨

  • 변경 내역 및 버전 정보로 README 업데이트