콘텐츠로 이동

변경 내역

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

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

Unreleased

Added

  • 백엔드별 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_optionsstop, 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 미지원 필드 코드로만 보고됩니다. 변경은 재시작 없이 다음 요청부터 적용되고, 본문 조각이 있는 백엔드는 조각의 지문으로 응답 캐시 항목의 키를 정하므로 조각을 고친 뒤에는 이전 조각으로 저장된 항목이 응답하지 않으며, 이 필드가 없는 설정은 이전과 똑같이 직렬화되고 디스패치됩니다.

Fixed

  • Continuum Hub 사용량 레코드가 캐시된 프롬프트 토큰을 두 번 청구하지 않도록 고쳤습니다 (#1642). 허브는 input_tokenscached_input_tokens를 겹치지 않는 버킷으로 과금하는데, 레코드는 백엔드의 prompt_tokens를 그대로 input_tokens로 보냈고 OpenAI 형식 응답에서는 이 값에 이미 prompt_tokens_details.cached_tokens가 들어 있어, 캐시된 토큰마다 입력 단가로 한 번, 캐시 단가로 또 한 번 청구되었습니다. 로컬 캐시 재생도 같은 방식으로 청구되었습니다. 이제 모든 생산자에서 prompt_tokens는 포함 방식의 프롬프트 수(캐시되지 않은 입력 + 캐시 읽기 + 캐시 쓰기)이고, 레코드에는 라우터 로컬 input_tpm과 월간 강제 적용이 이미 청구하던 것과 같은 과금 대상 입력인 input_tokens = prompt_tokens - 캐시 읽기가 실립니다. 제외 방식으로 보고하는 형식은 들어오는 지점에서 변환합니다. 사용량 파서는 Anthropic 네이티브 input_tokenscache_read_input_tokenscache_creation_input_tokens를 다시 더하고, 네이티브 /v1/messages 스트림 추적기는 캐시 쓰기도 수집하며, Anthropic과 Bedrock Converse의 /v1/chat/completions 변환은 포함 방식 prompt_tokensprompt_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_tokensinput_tokens_details.cached_tokens도 읽음), 클라이언트나, 브리지된 본문을 직접 다시 파싱하는 이 라우터 자신(비스트리밍 응답, 자체 로컬 캐시 재생, 또는 스스로 재구성한 브리지 스트림)은 캐시된 프롬프트를 두 번 세지 않습니다. 캐시 히트 시 이런 대상은 더 작은 input_tokens를 보게 됩니다. 네이티브 /v1/messages 스트리밍 추적기는 이제 message_delta에서도 input_tokens, cache_read_input_tokens, cache_creation_input_tokens를 읽습니다(Anthropic Messages API가 누적 값을 거기에 반복하고, 브리지된 스트림은 message_startinput_tokens: 0을 담기 때문에 그 값들을 거기로 미룹니다). 그래서 다른 라우터의 브리지된 스트림을 계측하는 라우터가 0 대신 실제 프롬프트를 기록합니다. Anthropic 백엔드가 서빙하는 /v1/responses는 비스트리밍과 스트리밍 경로 모두에서 Responses 규칙의 포함 방식 input_tokensinput_token_details.cached_tokens/cache_write_tokens를 보고합니다(스트리밍은 이전에 Anthropic의 제외 방식 수 또는 0을 기록하고 두 캐시 버킷을 버렸습니다). OpenAI 호환 chat 백엔드가 서빙하는 /v1/responses는 스트리밍 경로에서 prompt_tokens_details.cached_tokensinput_token_details.cached_tokens로 유지하고, 사용량 파서는 라우터 자신의 단수형 input_token_details.cached_tokens를 읽으므로, 다른 라우터의 /v1/responses 본문을 계측하거나 자체 캐시 본문을 재생하는 라우터가 캐시 버킷을 입력 단가로 청구하지 않고 유지합니다.

  • OpenAI 호환 /v1/models 카탈로그가 장식용 필드인 objectowned_by를 생략해도 전체 카탈로그를 ModelFetchError::ParseError로 실패시키지 않고 디코딩하도록 고쳤습니다 (#1629). ModelsResponse.object, Model.object, Model.owned_byserde(default)가 없었기 때문에, 셋 다 보내지 않는 OpenRouter 같은 애그리게이터는 영구 파싱 실패로 분류되어 해당 백엔드가 채팅 트래픽은 정상적으로 처리하는데도 GET /v1/models가 영원히 비어 있는 상태로 남았습니다. 이제 object"list"/"model"로 기본값이 지정되고, 없는 owned_by는 빈 문자열로 디코딩되어 거부되지 않고 process_models_responseowned_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/extendedGET /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_readcached_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 업스트림의 자체 목록은 tierdomains를 내보냅니다)를 담은 업스트림 항목은 하나의 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_responsemodels::aggregation의 열여덟 곳이 model.id/model.owned_bydebug!/trace! 메시지에 그대로 보간했고, 클라이언트나 운영자가 전부 통제하지 않는 카탈로그를 내보내는 백엔드라면 오늘도 그대로 도달합니다. core::text_utils에 새로 추가한 두 헬퍼, escape_upstream_text(제어 문자와 U+2028/U+2029를 경계 내로 잘라 이스케이프)와 describe_upstream_json_error(같은 처리를 serde_json::Error 메시지에 적용하되 줄/열 위치는 유지)를 이런 자리마다, 그리고 /v1/models·/props 파싱 오류 경로 세 곳에 적용해 위조 경로와 파싱 실패 시 지나치게 큰 업스트림 값이 만드는 무제한 로그 증가를 함께 막습니다.

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.mddocs/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_tierorg로 게이팅하세요. 등급은 항상 컴파일되는 새 KeyTierContext 요청 확장으로 핸들러에 도달하며 control-plane 강제 미들웨어만 이를 채웁니다(OptimizationDecision과 같은 패턴). 새 RequestIdentity는 HTTP 타입을 갖지 않고, 엔진이 로드된 정책이 읽는 헤더 이름을 미리 계산하므로 요청마다 그 외에는 아무것도 복사하지 않습니다. POST /admin/smart-routing/simulatepayload 옆에 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.keywordscode 키워드 범주가 기존 넷에 합류하고, EN_CODEKO_CODE가 내장으로 배포되며 다른 목록과 같은 가산 방식으로 병합됩니다. 덕분에 영어 기술 어휘가 섞인 한국어 요청은 어느 쪽에서든 신호에 도달합니다. 이 신호는 하나가 아니라 서로 다른 두 개의 일치를 요구하는데, 이는 기존 관행이며(detect_analysis_markers도 이미 둘을 요구합니다) function이나 수정 같은 평범한 단어를 목록에 담을 수 있게 해 주는 장치입니다. 임계에 닿으려면 같은 요청에서 독립적인 두 번째 증거가 필요하기 때문입니다. 대신 동작 동사를 요구하는 방식은 시도했다가 기각했습니다. write가 "Write a poem about autumn leaves"에서 발화해 모든 창작 사례를 domain = code로 뒤집습니다. 목록이 동작과 대상을 섞는 이유는 각각이 홀로는 실패하기 때문입니다. 동작만으로는 창작 글쓰기를 가져가고, 대상만으로는 "What is a REST API?"를 가져갑니다. 항목 둘은 의도적으로 빼 두었고 그 부재를 테스트가 고정합니다. 단어 하나짜리 키워드는 단순 부분 문자열 일치라, logicbiologicaltechnological 안에 있고 apicapitalrapid 안에 있습니다. 둘 중 어느 쪽이든 무해한 두 번째 일치와 함께 임계에 닿아 "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-servePOST /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_strategyprefix_routing.* 리프, 그리고 제한된 kv_cache_index.* 라우팅 리프를 전달할 수 있습니다. 플릿은 라우터마다 파일을 고치는 대신 KV 인지 라우팅을 중앙에서 켜고, 점수 가중치를 조정하고, 되돌릴 수 있습니다. 이 섹션은 자체 스키마 버전과 구조화된 capability를 지니고, 스냅샷 항목은 값 없이 존재 여부와 다이제스트만 보고하며, control_plane.config_sync.kv_routing_immutable: truerequest_params_immutable과 같은 방식으로 섹션 전체를 로컬에 고정할 수 있습니다. 전달된 값은 명시적인 로컬 핀 아래에서 내구성 있는 설정 동기화 저장소를 거치고, 알 수 없는 경로, 범위를 벗어난 값, 비밀을 담은 이벤트 소스 엔드포인트는 적용하지 않고 거부합니다. 적용에 성공하면 기존 핫 리로드 watch 채널로 게시되므로, 유효 스냅샷이 재시작을 기다리지 않고 라이브 리로드 소비자에게 도달합니다.

  • 함수 tools와 reasoning effort를 함께 보낸 Chat Completions 요청을, 그 조합을 거절하는 OpenAI 모델에 한해 /v1/responses로 우회합니다 (#1547). OpenAI는 요청에 함수 도구가 있고 실제 적용되는 reasoning_effortnone이 아니면 /v1/chat/completions400 invalid_request_errorparam: 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-1claude-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_reasoningchat_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-1claude-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.을 반환하며, autonone은 영향이 없고 disable_parallel_tool_useauto와 함께 그대로 동작합니다. 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의 anytool이 되는 경로), /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_usagewaiting_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: HITX-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_modelfind_matching_config_slice의 6단계 매칭 전체(날짜 접미사 제거, 양자화·포맷 벗기기, HuggingFace 접두사 제거, 와일드카드 별칭)를 돌렸고, 백엔드 model_configs보다 메타데이터 캐시를 먼저 확인했습니다. 문서화된 메타데이터 우선순위와 반대였습니다. 그래서 날짜나 양자화 접미사가 붙은 id 같은 퍼지 표기로 들어온 realtime 요청이 같은 표기로 채팅 경로에 도달했을 때와 다른 모델로 해석되거나, 채팅 경로였다면 거절됐을 표기가 해석되기도 했습니다. 이제는 정확한 id 또는 정확한 별칭, 그리고 그것의 models/ 접두사 표기만 해석되며, capability 조회도 canonical_catalog_id와 같은 순서로 메타데이터 캐시보다 백엔드 model_configs를 먼저 봅니다. 문자 그대로의 후보 조회가 비었을 때만 발화하던 요청별 재시도는 proxy::alias_dispatch의 카탈로그 기반 재작성(canonical_catalog_iddecide_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를 호출하고, 요청 이름과 디스패치 이름을 쌍으로 들고 다녀서 재작성 이후에 발생하는 404403도 클라이언트가 보낸 이름을 답합니다. 어떤 백엔드든 문자 그대로 제공하는 이름은 여전히 건드리지 않으며, 어떤 별칭이 해석되는지에 관한 규칙은 바뀌지 않았습니다. 알아둘 결과가 둘 있습니다. /v1/embeddings는 키별 모델 검사를 디스패치 퍼널에 맡기지 않고 ingress에서 직접 수행하므로, 요청된 이름이 먼저 허용된 뒤에만 정본 id가 비어 있지 않은 allowed_models 목록에 추가됩니다. 그리고 이미지 편집과 변형 엔드포인트는 재작성 전에 허용 모델 이름의 고정 목록을 검사하므로, 별칭은 그 목록에 들어 있을 때만 두 엔드포인트에 도달합니다. ACP는 사용법과 아키텍처 문서가 약속한 대로 문자 그대로의 매칭을 유지합니다.

  • 동작 변경: OpenAI 형식 오류 본문과 SSE 오류 이벤트의 error.code를 HTTP 상태 정수 대신 문자열 또는 null로 내보냅니다 (#1610). OpenAI Error 스키마는 codeanyOf [string, null]로 정의하고, openai-python은 이 필드를 construct_type으로 만듭니다. 이 함수는 타입이 맞지 않는 값을 그대로 돌려주므로 Optional[str] 주석 아래로 int가 흘러들어, err.code == "insufficient_quota"는 조용히 한 번도 맞지 않고 err.code.startswith(...)는 라우터가 정상으로 여기는 본문에서 예외를 냈습니다. 이제 ErrorDetail.codeOption<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가 함께 실리고 paramcode는 생략 대신 null로 직렬화됩니다. Responses error 스트림 이벤트도 명세가 정의한 평평한 ResponseErrorEvent 형태로 바뀌어 code, message, param, sequence_numbererror 객체 안이 아니라 type 옆에 놓입니다. error.code를 숫자로 파싱하던 클라이언트는 HTTP 상태 줄을 읽어야 합니다.

  • 호환성 변경(동작): /anthropic/v1/messages 인그레스에서 output_config.effortxhigh를 알 수 없는 값으로 처리하지 않고 그대로 받습니다. xhigh는 Anthropic Messages API가 정의하는 다섯 수준 중 하나이며 Claude Code가 기본으로 보내는 값인데, 두 인그레스 변환 어디에도 그 항목이 없었습니다. src/http/handlers/anthropic/transform.rstransform_thinking_with_effortsrc/http/handlers/anthropic/responses_transform.rsmap_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

항목을 추가하면 같은 요청이 xhighxhigh로 매핑하고 200을 반환합니다. 이제 두 변환 모두 xhighxhigh로 매핑합니다. 다른 수준들이 이미 쓰는 것과 같은 표기입니다. _ 갈래와 경고는 그대로여서 실제로 인식할 수 없는 문자열은 여전히 high로 떨어지며, maxxhigh로 올리는 처리도 바뀌지 않았습니다. 마이그레이션: 어휘에 xhigh가 없는 백엔드는 이제 기존의 high 대신 xhigh를 받고, 그 필드를 검증한다면 거부합니다. 예를 들어 low/high/max만 받는 vLLM 채팅 템플릿은 400을 냅니다. 대체 처리가 가려 왔던 어휘 불일치가 드러나는 것이므로, 해당 백엔드가 받는 수준을 보내거나 라우터가 이미 맞춤 처리하는 모델 항목을 주어야 합니다. 모델별로 맞춰지는 대상은 영향이 없습니다. Gemini는 validate_reasoning_effort로 여전히 xhighhigh로 낮추고, 교차 공급자 폴백 홉도 #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.example1.5로 실려 있었고, 시도 자체의 타임아웃을 스케일한다고 문서화되어 있었지만, 어떤 요청 경로도 이 값을 읽지 않았습니다. calculate_timeout에는 테스트 밖 호출자가 없었고, 그래서 모든 홉이 기본 시도의 타임아웃 그대로 동작했으며 값을 올린 운영자는 아무 효과도 얻지 못했습니다. 이제 폴백 실행기가 이미 유지하던 시도 번호가 네 개의 드라이버로 전달됩니다. 실행기 클로저가 세 번째 인자로 받고, IngressAttempt가 실어 나르며, 스트림 시작 전 연결 루프는 자신의 홉 카운터에서 유도합니다. 각 드라이버는 공유 헬퍼 하나로 기준값을 스케일합니다. 기본 시도는 attempt 1로 변경되지 않고, 홉 nbase * 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에 contenttool_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 키가 있으며, nulltool_calls가 함께 있을 때뿐이고 그 외에는 빈 문자열입니다.

  • Gemini 채팅 완성 변환을 stream: true일 때만이 아니라 비스트리밍 경로에서도 실행합니다 (#1591). 요청 변환이 스트리밍 디스패치에만 있었기 때문에, 같은 /v1/chat/completions 요청이 스트림을 요청하면 extra_body.google.thinking_config.include_thoughts: truemax_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_disablereasoning_effort: "none" 여부를 contains 검사 다섯 개로 결정했고, 이는 양방향으로 틀렸습니다. gemini-3.5-flash-litegemini-3.5-flash 부분 문자열에 걸려 거절하는 업스트림으로 전달됐고, gemini-3.7-flashgemini-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-progemini-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-previewgemini-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가 비어 있거나 없으면 기존 호환성을 위해 제한 없음으로 남고, writeread를 포함하며, 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_generationadd_backend, remove_backend, drain_backend의 쓰기 임계 구역 안에서 성공 경로에서만 증가하며, 핫 리로드, Admin API, AppProxy 레지스트리, 컨트롤 플레인 백엔드 동기화가 모두 이 세 곳을 거치므로 전부 포함됩니다. ModelCache::invalidation_epochclear_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_deltacontent_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은 모든 DomainTagComplexityLevel 조합에 걸쳐 요청에 라벨을 붙이되, 분류기 출력을 베끼지 않고 src/services/smart_routing/types.rs의 doc 주석 정의로부터 독립적으로 라벨링했습니다. 그래서 측정된 정확도가 동어반복이 아니라 실제 회귀 신호가 됩니다. tests/smart_routing_accuracy_test.rs는 모든 사례를 rule_defaultrule_primary_ko 두 배포 모드로 classify_only에 통과시키고, 모드별·감지 언어별로 정확도와 도메인·복잡도 혼동 행렬을 출력하며, 모드마다 게이트를 단언합니다. LLM과 하이브리드 모드는 빠른 결정적 검사가 아니라 실제 또는 모의 백엔드 왕복이 필요하므로 범위 밖입니다. docs/en/development.md와 한국어 대응 문서가 데이터셋 확장 방법을 설명합니다. CI 설정은 바뀌지 않았습니다. .github/workflows/ci.ymlscripts/local-ci.sh가 이미 cargo test --tests -- --skip integration_test를 실행하며, 이는 기본 기능 아래 모든 최상위 tests/*.rs 타깃을 컴파일하고 실행합니다.

  • realtime 메타데이터 조회를 백엔드 후보 필터 감사에서 면제했습니다 (#1602). every_backend_candidate_set_is_filtered_or_explicitly_exemptsrc/proxy/realtime.rsmodel_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: trueenabled: false 백엔드만 제공하는 모델은 여전히 거절됩니다.

Documentation

  • 시맨틱 응답 캐시를 구현된 대로 서술합니다 (#1616). docs/en/architecture.md와 한국어 대응 문서의 Scope 설명은 시맨틱 캐시 경로가 임베딩 클라이언트도 테넌트 격리 벡터 유사도 인덱스도 없는 의도적 no-op이며 CacheHitType::Semantic은 예약돼 있을 뿐 제공 경로가 없다고 여전히 적고 있었습니다. #1571이 그 경로를 만들었으므로 이 문장은 병합된 시점부터 틀린 상태였고, 사용 기록 문단도 cache_hit_typesemantic 값에 대해 같은 주장을 반복하고 있었습니다. 이제 두 문단 모두 배포되는 내용을 서술하며, 기억에서 되쓰지 않고 docs/en/architecture/kv-cache.mdconfig.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)이든 항상 우선하며 절대 덮어쓰지 않습니다. 이 변환은 클라이언트가 보낸 값을 검사하는 추론 검증 이후 전송 지점에서 실행되므로, 방출되는 수준을 선택된 대상이 문서화한 어휘에 맞춥니다. openaiazure는 OpenAI 모델 표를 참조해 reasoning_effort를 지원하지 않는 모델(gpt-4o, chat/instant 계열, 표가 인식하지 못하는 Azure 배포 이름)에는 아무것도 넣지 않고, 지원하는 경우 low < medium < high < xhigh 사다리에서 가장 가까운 허용 수준을 택하되 거리가 같으면 더 높은 쪽을 고릅니다. 그래서 gpt-5-pro로 가는 lowhigh, gpt-5.2-pro로 가는 lowmedium이 됩니다. 그 밖의 모든 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에 인라인으로 넣던 자격 증명이 옮겨갈 공식 위치입니다. reqwestRequestBuilder::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.rsbuild_count_tokens_request, 그리고 src/proxy/image_gen.rshandle_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-tokenx-access-token을 통과시켜 둘 다 프로바이더에 도달했고, 좁은 로컬 contains("api-key") 휴리스틱은 공유 거부 목록으로 대체했습니다. 둘째는 /v1/embeddings 뒤의 Gemini 멀티모달 임베딩 네이티브 디스패치(src/proxy/handlers.rshandle_multimodal_embedding)입니다. 이 루프는 authorizationx-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_tokensreasoning_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::basicfor_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_conditionsmax_fallback_attempts를 따르고, 같은 X-Fallback-* 헤더(항목이 하나인 체인이면 x-fallback-reason: connection_errorx-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_totaldial_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 백엔드가 지금도 이 경로에 도달하고, genericauth.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)은 모두 제거하며, 포괄 분기 없이 전부 나열해 백엔드 타입이 새로 생기면 반드시 결정하게 했습니다. anthropicbedrock은 이 필드들을 실제로 읽는 변환기가 자기 자신이므로 유지하고, 그쪽 speed는 변경되지 않은 anthropic_fast_mode 옵트인이 계속 관장합니다. continuumrouter도 유지하는데, 하위 Continuum Router가 동일한 표준 확장 필드를 이해하고 자기 백엔드에 대해 이 규칙을 적용하기 때문입니다. 타입이 설정되지 않은 백엔드는 추측하지 않고 패스스루를 그대로 유지합니다. reasoning_effortextra_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-keyanthropic-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_conditionsfallback.fallback_policy.max_fallback_attempts를 따르며, 라우터가 아직 연결 중인 동안에는 SSE keep-alive 주석이 전송되지 않습니다. 마지막 항목은 pre-stream 전용 경로와 체인이 없는 경로가 원래부터 하던 동작과 같습니다. 이 침묵하는 연결 구간에는 릴레이와 공유하는 시도 간 예산(timeouts.request.streaming.totaltimeouts.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로 이름이 바뀐 stopstop을 읽는 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_accountsigv4는 계속 거부됩니다. 각각 임시 후보가 적재할 수 없는 수명 관리 자료를 요구하기 때문입니다. 후보 검증은 여전히 단일 백엔드 설정에 대해 전체 설정 검증기를 돌리므로 백엔드 타입 제한과 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: basicapi_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_helperstrategy_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::newextract_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_usagekv_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.mddocs/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는 /propsendpoint_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/backendsengine_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: falsePOST /admin/config/import, config 후보가 있고 hot_reload: truePOST /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_windowlimits.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 백엔드 구조체로 서빙), mlxcelllamacpp로, bedrockanthropic(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.rssrc/appproxy/router/reconcile.rs)를 공유 리졸버 하나로 합쳐, 레거시 워커 경로와 ROUTER 프런트엔드 모드 경로가 같은 구현을 사용합니다. vLLM이 아닌 엔진으로 AppProxy 서킷을 운영하는 운영자를 위한 마이그레이션 안내: 백엔드 이름은 그대로(appproxy-<circuit_id>-r<route_key>)라서 reconcile은 여전히 멱등하고 업그레이드로 백엔드 집합이 요동치지 않으며, 타입 필드만 달라집니다. 새로 타입이 붙은 리플리카가 얻는 것은 제네릭 기본값을 대신하는 엔진 고유의 헬스 체크 계약(sglang/health를 프로브하고 /v1/models로 폴백하며 엔진 시작·종료 중의 503은 워밍업으로 읽습니다. llamacppmlxcel/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_weightmin_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.levelinfo이므로 운영자 실수 없이도 노출이 켜져 있었습니다. 27개 파일 67개 지점 전부가 로그에 넘기는 값을 redact_endpoint로 감싸며, reqwest에 넘기는 값은 모든 지점에서 그대로입니다. 각 지점에서 조합된 URL이 곧 실제 요청 대상이자 로그 값이기 때문에 리다이렉션은 compose_backend_url과 여섯 형제 함수로 옮길 수 없고 호출 지점에 남습니다. 68번째 지점은 소스 스캔 감사 테스트가 막습니다.

  • 미지원 스킴 전송 오류가 반향하던 백엔드 URL을 가립니다 (#1467, PR #1471). 비밀번호가 담긴 ftp:// 백엔드 URL로 실제 재현했을 때 부팅 한 번과 비스트리밍·스트리밍 요청 각 한 번에서 그 비밀번호가 담긴 로그 줄이 7개 모듈에 걸쳐 9줄 나왔습니다. 클라이언트에 반환되는 본문은 이미 고정 문자열이었으므로 노출 대상은 로그뿐이었습니다. 이제 TransportError::UnsupportedSchemeDisplay 속성 안에서 가리는 대신 생성 시점에 가려진 값을 보관합니다. 이 타입은 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_FIELDSurl을 넣는 방식은 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). RedisCacheBackendConfigRedisConfig는 같은 파일에서 비밀을 지우려고 Debug를 직접 구현한 형제 설정 타입 다섯 개와 달리 평범한 Debug를 파생해 자격증명이 담긴 url을 그대로 출력했고, 두 타입 모두 Config 자신의 파생을 통해 도달할 수 있습니다. 현재 두 타입을 Debug로 포매팅하는 프로덕션 호출 지점은 없으므로 이는 활성 유출이 아니라 잠재 구멍을 닫는 변경입니다. 위험은 다음에 추가될 debug!(?config)나 새 #[derive(Debug)] 래퍼가 Redis 비밀번호를 통째로 출력하는데 코드에도 CI에도 이를 막을 장치가 없다는 점이었습니다.

  • Admin 백엔드 프로브 응답의 백엔드 URL 자격증명을 가리고, 로그 리다이렉션 감사 스캔이 인라인 포맷 캡처를 보도록 넓힙니다 (#1498, PR #1501). POST /admin/backends/{name}/checkchecked_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/completionsmodel_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.tomlstyle_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_tokensmax_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 스냅숏으로 옮겨졌는데 픽스처는 FallbackConfigFallbackService에만 붙였고, 이는 프로덕션이 읽는 경로가 아닙니다.

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.ymlscripts/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 형태로 갱신하고, 실제로 제공되는 RedisCacheStoreredis-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.requestmodel_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.tomlstyle_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-musllinux-aarch64-musl 아카이브 없이 발행되었습니다. 호스트 타깃 잡 네 개는 계속 통과했는데, 툴체인은 자기 호스트용 std를 항상 가지고 있기 때문입니다. ci.ymlperf.yml에는 크로스 컴파일이 없어서 릴리스가 돌기 전까지 어떤 게이트도 이 경로를 밟지 않았습니다. 이제 워크플로가 rust-toolchain.toml에서 channel을 읽어 그 툴체인에 타깃을 설치하며, 설치와 빌드 사이의 검사가 활성 툴체인에 타깃이 없으면 컴파일 몇 분 뒤가 아니라 즉시 실패합니다.
  • 툴체인 고정 파일이나 clippy 설정이 바뀌면 Rust CI 잡을 돌리도록 했습니다(PR #1405). .github/workflows/ci.ymlrust 경로 필터에 deny.tomlrustfmt.toml은 있었지만 rust-toolchain.tomlclippy.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-commonaxum이 그 버전을 exact-pin하기 때문입니다.

v1.24.0 - 2026-08-22

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

추가됨

  • 타임아웃 검증 상한을 새 timeouts.limits 블록으로 노출하고, max_standard_timeout 기본값을 180초에서 540초로 올렸습니다(#1401, PR #1402). TimeoutLimitstimeouts: 섹션의 모든 값을 제한하는 방어선인데 설정 표면이 전혀 없었습니다. 컴파일된 상한보다 긴 예산이 필요한 운영자는 소스를 고쳐 다시 빌드하는 수밖에 없었고, 상한 두 개는 기본값과 정확히 같은 값에 붙어 있어 조정 여지가 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-4claude-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_bytetimeouts.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_bytetimeouts.request.image_generation.first_byte는 동작하지 않으며 다음 메이저 릴리스에서 제거됩니다(#1401, PR #1402). OpenAI 호환 비스트리밍 완성 응답은 Content-Length로 구분되는 버퍼링된 본문입니다. 업스트림이 완성 전체를 생성한 다음에야 헤더와 본문을 함께 쓰므로, 이 경로에서 첫 바이트까지의 시간은 곧 전체 생성 시간이지 따로 떼어낼 수 있는 이른 신호가 아닙니다. 여기에 기한을 두는 것은 별개의 방어선이 전혀 아니고 두 번째의, 더 짧은 total일 뿐입니다. 기본값 30초를 그대로 강제했다면 모든 비스트리밍 완성 응답에 30초 상한을 씌우는 셈이 되었을 것입니다. 두 필드는 하위 호환을 위해 계속 파싱되고 first_byte <= total 검사도 유지되며, 기본값과 다른 값을 쓰면 이제 로드 시점에 해당 필드가 동작하지 않는다는 경고가 나옵니다. continuum-router config validate도 같은 내용을 보고합니다. 실제 예산은 standard.totalimage_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 warningsmain에서 실패하기 시작했고, 손대지 않은 커밋 6b0f6e0f에서 main의 마지막 성공 CI 실행을 다시 돌려 이를 확인했습니다. CI는 dtolnay/rust-toolchain@stablestable을 설치하며 이 값이 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.retryrouter.circuit_breaker 이벤트입니다. 각각 같은 것을 이미 세고 있는 seam에서 나오므로 트레이스와 /metrics가 서로 다른 말을 할 수 없습니다. taxonomy는 생성자에서 한 번, 내보내기 경계의 스팬 프로세서에서 한 번 더 강제하며, 후자가 목록 밖의 스팬·속성·스팬 이벤트를 버립니다. 평범한 로그 줄과 공급자 오류 본문이 수집기로 새어 나가지 않는 이유가 이것입니다. 인증 헤더 값의 ${ENV_VAR}는 exporter 생성 시점에만 치환되므로 설정 덤프에 비밀이 담기지 않고, 헤더 이름은 RFC 7230 토큰이어야 하며 값에 제어 문자가 없어야 합니다. 내보내기가 꺼져 있으면 모든 스팬 생성자는 relaxed 원자적 로드 한 번 뒤에 Span::none()을 돌려줍니다.
  • 공식 릴리스 바이너리에 redis-cache를 담고 공유 상태 배포 계약을 정의했습니다(#1249, PR #1390). 공유 상태 코드는 있었지만 어떤 공식 아티팩트에도 들어 있지 않아서, 배포 프로파일은 복제본별 rate limit을 피할 수 없는 한계로 설명해야 했습니다. 기능을 컴파일해도 활성화되는 것은 없습니다. 런타임 옵트인은 여전히 rate_limiting.storage: redisresponse_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: trueenabled: 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_servicessrc/ 이력에 커밋이 하나도 없고, HttpClient::new(&config.http_client)?는 어떤 버전의 코드에서도 컴파일될 수 없었습니다. 이 절은 라우터가 실제로 쓰는 패턴, 즉 유일한 프로덕션 구현이 BackendManagerBackendService 트레이트 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.backendsfind_backends_for_model에서 가시성 필터 없이 후보를 만들었습니다. 넷 다 숨긴 백엔드로 디스패치할 수는 없었습니다. 공유 seam이 키별 allow-list보다 먼저 필터를 적용하고, allow-list 항목으로 들어온 숨긴 이름은 교집합을 비우기 때문입니다. 대신 호출자가 받은 것은 틀린 답이었습니다. 문제의 원인이 아니었던 키를 탓하는 403, 숨긴 백엔드만 그 모델을 서빙할 때의 404, disaggregated 경로의 503, 또는 아무 오류도 없이 조용히 잘못 결정된 치환 규칙입니다. 넷 다 안전했던 유일한 이유는 지금의 모든 소비자가 allowed_backends를 가시성 필터 뒤에 적용되는 필터로 다룬다는 것뿐이었고, 타입 시스템에는 그런 말이 없었습니다.
  • smart-routing LLM 분류기가 숨긴 백엔드로 프롬프트를 보내지 않게 했습니다(#1356, PR #1363). classifier.methodllm이나 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_modelconfig.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/*.rstests/<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: trueenabled: 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/capabilitiesbackend_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: trueenabled: 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_methodhas_llm_classifier를 표시합니다. LLM 분류기의 고급 필드(타임아웃, temperature, 토큰 한도, 구조화 출력 전략, 프롬프트)는 일반 Configuration 페이지에 남겨두고 새 탭에서 링크로 연결했습니다.
  • 모델 집계 타이밍을 프로덕션 메트릭 레지스트리로 내보냅니다 (#1326, PR #1329). 지금까지 느린 모델 목록을 진단하려면 소스 코드를 읽어야 했습니다. model_backend_fetch_duration_secondsbackend로 라벨링된 새 히스토그램이고, 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). 리프레시는 주기마다 풀의 모든 백엔드를 프로브했고 401403을 뺀 모든 비2xx를 재시도했습니다. 팬아웃이 동시 실행이라 가장 느린 무의미한 프로브가 집계 전체의 지연을 정했습니다. Bedrock 백엔드는 존재하지도 않는 /v1/models를 상대로 5초짜리 시도 세 번에 500ms 대기 두 번, 곧 매 리프레시마다 약 16초를 썼습니다. 이제 Anthropic과 Bedrock 백엔드는 HTTP 요청 없이 설정된 models:에서 바로 제공되며, 이는 관리 디스커버리 경로가 이미 하던 방식과 같습니다. 비활성 백엔드는 집합 원소 검사로 조회 대상에서 빠지므로 AppProxy 동적 레플리카 같은 풀 전용 백엔드는 영향받지 않습니다. 404405401, 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.backenddata.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-Blockblock에서 멈춰 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_modelfilter_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 하나뿐입니다.
  • metricsrealtime을 함께 켰을 때 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-11bnemotronlabs-voicechat-11b 별칭을 명시적으로 등록했습니다. 벤더와 랩 세그먼트는 접미사로 벗겨지는 토큰이 아니어서 짧은 형태가 정규화만으로는 도달하지 않기 때문입니다. context_window는 문서화된 0입니다. 저장소가 transformers 설정이 아니라 NeMo 학습 설정을 싣고 있고 모델 카드도 토큰 한도를 밝히지 않았기 때문이며, 백본 자체의 128K 윈도는 이 체크포인트가 주장한 적이 없으므로 의도적으로 단언하지 않았습니다.
  • muse-glimmer-30b를 추가했습니다 (#1302). Apache-2.0 라이선스의 Meta 30B 밀집 멀티모달 에이전트 모델이며, context_window: 131072는 공개된 config.jsonmax_position_embeddings에서 가져왔고 code 능력은 가정이 아니라 SWE-Bench 점수를 근거로 부여했습니다. 별칭은 필요 없습니다. HuggingFace 저장소 형태와 공식 GGUF 양자화 꼬리표가 모두 저장소 접두사 제거와 기존 peel로 해석되며, id 형태 7종 프로브로 확인했습니다.

변경됨

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

수정됨

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

제거됨

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

보안

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

문서

  • realtime 운영자 문서 docs/en/configuration/realtime.mddocs/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 번들이고, realtimefull에 들여온 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.ymldorny/paths-filter rust 필터에 model-metadata.yaml이 없어서, 메타데이터만 바꾼 PR은 그 파일을 파싱하고 단언하는 통합 테스트가 여럿 있는데도 Rust 잡을 전부 건너뜁니다. 이번 릴리스의 메타데이터 변경 3건은 대신 로컬에서 게이트를 통과시켰습니다. 필터에 model-metadata.yamlmodel-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-planeadmin을 켜지 않으므로 도메인은 항상 컴파일되는 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_tokenscost_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_windowmax_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을 반환합니다. 같은 검증 경로를 타면서, modelmodel_pattern의 200자 제한도 설정 로드에서 처음으로 실제로 걸리게 되었습니다.
  • smart_routing.load_management.recovery.hysteresis_factorload_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_effectiveenabled 투영을 추가하고, 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 ... shkubectl exec ... -- sh는 더 이상 동작하지 않습니다. 이를 대신하는 임시 컨테이너 방식은 배포 가이드에 실었고, 라우터의 config 서브커맨드와 --health-check는 이미지 엔트리포인트로 셸 없이 실행됩니다. 기존 Debian 베이스에는 재빌드로 해소할 수 없는 CRITICAL·HIGH 22건이 있었는데, 전부 라우터가 쓰지 않는 패키지의 미수정 취약점이었습니다.
  • Dockerfile.alpineDockerfile.alpine.ci를 삭제했습니다. DockerfileDockerfile.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/capabilitiestransient_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: writepackages: 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/statuspolicy_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개입니다. 라우트 범위에서 다루는 것은 modecategory_thresholds뿐입니다. 라우트 범위의 stages, inspect_reasoning, block_behavior는 로컬 라우트 스키마에 담을 자리가 없고, 전역 설정으로 접어 넣으면 라우트 하나에 대한 결정이 모든 라우트로 번지므로, 해당 라우트와 항목 이름을 담은 경고와 함께 적용하지 않습니다. 배포 배선은 허브에서 오지 않습니다. providers 목록 자체, 프로바이더의 endpoint / backend: / api_key_env, 프로바이더별 및 전역 timeout_mson_error, 모든 streaming_* 항목, bypass_api_keys, audit 블록, 라우트별 enabledproviders 부분집합, 전역 및 라우트별 allow / deny 매치 리스트는 전부 로컬이 주권을 가집니다. 정책 타입에는 자유 형식 페이로드 필드가 어디에도 없으므로, 프롬프트, 완성 텍스트, 매치된 구간, 마스킹된 값은 어느 방향으로도 실릴 수 없습니다. 합성은 항목별로 더 엄격한 쪽이 이깁니다. 어느 쪽에서 오든 enforcemonitor를 이기고, 카테고리별로 더 낮은 임계값 하한이 이기며 로컬에 없는 카테고리는 추가되고, 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의 기본값은 설정하지 않음이라 기존 배포가 업그레이드만으로 요청을 흘려보내기 시작하는 일은 없습니다. 값을 설정하면 라우터는 그만큼만 동시에 받아들이고 나머지는 503Retry-After, 상한을 명시한 JSON 본문으로 답합니다. 대기열에 넣지 않고 흘려보내는 이유는, 대기열이 메모리 부족 종료를 무한정 늘어나는 지연으로 바꿀 뿐 대기 중인 것들의 메모리는 그대로 붙잡고 있기 때문입니다. 구현은 towerConcurrencyLimit / LoadShed가 아니라 try_acquire_owned로 획득하는 tokio::sync::Semaphore입니다. 전자는 기본이 대기열이고, 배경 작업을 가진 Buffer가 필요하며, 거절 본문이나 재시도 힌트를 제어할 수 없고, iOS에 부적합한 의존성을 일부러 배제한 embed 기능 그래프에 새 tower 기능을 끌어들였을 것입니다. 이번 구현은 의존성을 전혀 추가하지 않습니다. 퍼밋은 응답 본문에 실려 다니므로 스트리밍 완성은 마지막 프레임까지 퍼밋을 쥐고 있습니다. 즉 짧은 비스트리밍 트래픽에 맞춰 잡은 상한은 스트리밍 트래픽을 굶기므로, 가장 긴 요청을 기준으로 잡아야 합니다. 이 레이어는 애플리케이션 전체에 장착되지만 CORS와 레이트 리미터보다는 안쪽입니다. 그래서 흘려보낸 503을 브라우저가 읽을 수 있고, 어차피 리미터가 거절할 요청이 퍼밋을 차지하지 않습니다. 요청당 항목이 가장 큰 /v1/files 그룹은 별도로 중첩되어 있지만 같은 상한의 적용을 받으며, /health, /healthz, 설정된 metrics.path는 면제됩니다. 활성 상태 프로브를 차단하는 것은 정상적으로 성능을 낮추고 있는 레플리카를 과부하 한복판에서 재시작시키는 길이기 때문입니다. SupplySummary::max_concurrencyshed_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을 보고합니다. 읽기 경합 경계, 시작 시 정리 의미론, 설정 예시는 영문·국문 가이드, 관리자 설정 스키마, 설정 어시스턴트 스킬에 문서화했습니다.
  • BackendConfigBuilderBackendConfig를 완전히 덮도록 채웠습니다 (#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 세터를 추가했고, timeoutmax_retries는 명시적 세터와 같은 retry_override 필드를 변경하며, backend_id 형식은 BackendConfigBuilder::build에서, 유일성은 ConfigBuilder::build에서 검증합니다. rest 패턴을 쓰지 않는 필드 인벤토리 가드가 있어 BackendConfig에 새 필드를 추가하면서 빌더 쪽 결정을 빠뜨리면 컴파일이 실패합니다.

변경됨

  • Claude Opus 5에서 사고 비활성화 설정과 high를 넘는 effort의 조합을 라우터 경계에서 거부합니다 (#1103). Anthropic은 Opus 5에서 output_config.efforthigh 이하일 때만 thinking: {"type": "disabled"}를 받아들이고 xhighmax에서는 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_streamretrieve_stream이 되고 슬라이스 기반 store/retrieve는 기본 구현 래퍼로 남았습니다. 그 결과 전송 1건의 최대 상주 메모리가 파일 크기의 약 1.5배에서 64KB 청크 버퍼로 줄었습니다. LocalFileStorage는 업로드를 프로세스별·업로드별 임시 파일로 스트리밍한 뒤 모든 검사를 통과한 다음에만 fsync하고 제자리로 rename합니다. 따라서 읽는 쪽이 절반만 쓰인 .bin을 보는 일이 없고, 거부되거나 오류가 났거나 도중에 취소된 업로드는 부분 파일도 유출된 임시 파일도 남기지 않습니다. 콘텐츠 검증은 판정 결과를 그대로 유지한 채 점진적 방식으로 바뀌었습니다. 앞쪽 13바이트가 이미지·실행 파일 매직 바이트 검사를 모두 감당하고, 새로 추가한 할당 없는 Utf8Validator가 부분 멀티바이트 시퀀스를 청크 경계 너머로 이어받으므로, 네트워크 청크 두 개로 쪼개진 문자도 단일 버퍼일 때와 똑같이 판정되고 시퀀스 도중에 끝나는 본문은 여전히 거부됩니다. GET /v1/files/{id}/contentGET /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_guardverdict_yes_no는 모델 답변의 첫 토큰만 보고 자신의 단일 양성 토큰(unsafe, yes)이 아닌 모든 값에 GuardrailVerdict::Allow를 반환했습니다. 그래서 오류 문자열, 응답 거부, 채팅 템플릿 잔여물, 32토큰 상한에 잘린 생성, 예상 밖 언어로 나온 답이 안전한 것으로 서빙됐습니다. 게다가 조용했습니다. 파서가 평범한 Ok(Allow)를 내놓으니 ProviderOutcome::failureNone으로 남고, mode: enforce에서도 Prometheus 오류 계열과 하트비트의 errors_total / fail_open_total / fail_closed_total, 감사 기록이 모두 검사를 마친 깨끗한 요청이라고 보고했습니다. 관대한 파서 쪽이 기본 경로이기도 했습니다. template 생략, 알 수 없는 template, granite_guardian, shieldgemma가 모두 verdict_yes_no로 귀결되므로, 오타 하나로 Llama Guard 배포가 unsafeyes로 볼 수 없는 파서를 쓰게 되고 모든 검사가 허용으로 통과했습니다. 이제 두 파서 모두 양쪽 극을 명시적으로 인식해 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-guardshield_gemma 같은 오타를 완전한 조용한 우회로 바꿔놓았고, 실패 방향이 항상 허용 쪽이라 운영자가 알아챌 만한 잘못된 차단은 결코 나오지 않았습니다. 따라서 알 수 없는 template 값이 들어간 설정은 이제 로드되지 않고 continuum-router config validate가 이를 보고합니다. 값을 고치거나 키를 지우면 됩니다. template 생략은 여전히 유효하며 granite_guardian을 뜻합니다. 프로바이더 쪽 폴백은 검증을 거치지 않은 생성 경로를 위한 방어선으로 남겨두었습니다. 허용되는 값과 판정을 읽는 규칙은 영문·국문 가드레일 가이드에 문서화했습니다.

  • 라우터의 파일시스템 구조가 Files API와 두 개의 file_id 해결 경로에서 클라이언트가 보는 오류 본문으로 새어 나가던 문제를 막았습니다 (#1191). FileError::Storage(_)가 담고 있는 문구는 라우터가 직접 만든 것으로, src/services/files/storage.rssrc/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_errorio_error) 이 필드로 분기하는 클라이언트는 아무 변화를 겪지 않고, 사람이 읽는 메시지만 고정 문구인 storage error: the file operation could not be completed로 바뀝니다. 클라이언트 요청이 원인인 변형들은 호출자가 보낸 file_id, purpose, 한도를 가리키므로 문구를 그대로 둡니다. 같은 문자열을 실어 나르던 클라이언트 대면 표면은 세 개였고 셋 다 처리했습니다. 공개 /v1/files* 핸들러, 챗 컴플리션 file_id 경로(해결 경고들이 FileResolutionError::PartialResolutionFailed로 합쳐져 404 본문에 실리므로, 각 경고를 만드는 지점에서 치환하고 전체 오류는 그 자리에서 로그로 남깁니다), 그리고 Anthropic /v1/messages 경로(FileResolverError::ReadErrorFile service error: ... 형태로 502에 실립니다)입니다. Responses API는 손댈 필요가 없어 그대로 뒀습니다. try_resolve_files가 한도 위반이 아닌 모든 오류를 원본 요청으로 되돌리기 때문에 클라이언트에 닿는 것은 LimitExceededTimeout뿐입니다. 변형별 주장을 가정하지 않고 실제로 확인하는 과정에서 찾은 인접 결함 두 가지도 같은 변경에서 고쳤습니다. 스토리지 백엔드가 올리는 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::IoErrorInvalidPath에도 같은 클라이언트/로그 분리를 적용했습니다.
  • 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_failurefailure_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_backendfilter_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에 명시적인 실패 표식이 필요합니다. 라우터 전용 verdictsstream_buffer_cap_trips 내역은 부가 선택 필드로 남습니다. 지금 허브는 deny_unknown_fields를 쓰지 않으므로 그냥 무시합니다. by_category에 키가 들어가는 유일한 경로가 GuardrailCategoryId 리터럴이므로 카테고리 id는 개수·바이트 길이·문자 집합 모두 구조적으로 한정되며, is_bounded()가 허브의 수집 규칙에 맞춰 셋을 모두 검사합니다. 허브가 커밋한 guardrail_summary.json을 바이트 단위로 이 저장소에 고정했고, 가드레일 정책 픽스처와 같은 "허브가 바뀌면 다시 복사할 것, 로컬 타입에서 재생성하지 말 것" 규칙을 함께 적었습니다. 전에는 두 저장소 모두 자기 타입만 왕복 검사했고, 양쪽 다 초록불인 채로 모양이 어긋난 것이 바로 그 때문입니다.
  • guardrails 설정 섹션을 재시작 필요가 아니라 즉시 적용 핫 리로드로 보고합니다 (#1127). ConfigSection::GuardrailsHotReloadCapability::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_logFileId::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.rstruncate_on_char_boundarytruncate_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_boundaryCLIENT_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 경고로 알려줍니다. 배열 항목은 위치가 아니라 idname으로 대응하므로 backends 순서를 바꿔도 제대로 복원되지만, guardrails.bypass_api_keysrate_limiting.bypass_keys처럼 idname도 없는 목록은 위치가 유일한 단서라서 항목을 추가하거나 빼면 추측 대신 거부합니다. 같은 공급자가 발급한 키는 접두사와 길이가 같을 수 있어서 한 칸 밀린 위치가 엉뚱한 키로 대응해 버리기 때문입니다. 대응할 값을 찾지 못한 자리표시자는 옆에 있던 비밀 값을 빌려 쓰는 대신 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-afterx-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-afterx-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_sourceresolve_document_source가 참조마다 BASE64_STANDARD.encode를 호출하므로, 바이트를 한 번 읽었든 800번 읽었든 참조마다 인코딩된 사본이 따로 상주합니다. 그래서 이번에 추가한 합계 예산은 고유 파일을 합산하지 않고 중복을 포함한 모든 참조를 계산합니다. 이제 서로 다른 항을 각각 막는 세 가지 상한이 적용됩니다. MAX_TOTAL_RESOLVED_BYTES(요청당 원본 32 MiB, base64 확장 후 약 43 MiB)는 콘텐츠를 읽기 전에 load_file에서 metadata.bytes로 청구되며, 리졸버가 소유한 AtomicUsizefetch_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::filescrate::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.yamlgpt-5.3-codex-spark와 이전 codex 등급 세 개를 추가했습니다. gpt-5.1은 두 카탈로그에서 서로 다른 방식으로 틀려 있었고, 이제 2024-09 컷오프에 400K/128K, \(1.25/\)10이며 애초에 없던 audio 능력도 뗐습니다. gpt-5.1-codexgpt-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-pro3solar-pro3-260323은 어디에도 해석되지 않았습니다. 4자리도 6자리도 8자리 날짜 접미사 정규화 대상이 아니기 때문이며, 이제 둘 다 명시적으로 등재했습니다. 같은 이유로 solar-pro-2solar-pro2solar-pro2-251215 별칭을 얻었습니다 (#1099, #1098 해결).
  • 설정 쓰기가 떨어뜨리던 런타임 상태를 다시 만들어, 관리자 API 변경이 백엔드 조회와 모델 메타데이터가 깨진 설정을 게시하지 않도록 했습니다 (#1100은 #1089 해결, #1136은 #1116 해결). put_config_sectionpatch_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_revisionConfig 전체를 대입하므로 model_metadata_cacheresponse_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.ymlscripts/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에서 합쳐지고 나서야 드러났습니다.
  • 가드레일 하트비트의 verdictserrors_total이 무엇을 세는지 분명히 했습니다 (#1227). 프로바이더가 여럿인 단계와 fail-closed 대체 차단에서 올바른 단위로 읽을 수 있도록, continuum-protocol rustdoc과 영문·국문 가드레일 가이드가 이제 실패한 검사를 명시적으로 설명하고, fail-open 해석을 검사되지 않은 허용 트래픽의 상한으로 한정하며, fail-closed에서 verdictsblocks_total이 갈리는 지점을 문서화합니다.

의존성

  • 스트리밍 Files API 경로를 위해 tokio-utilio 기능을 켜고 (#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에서 이뤄지는데 이는 우연이 아니라 핵심입니다. 매핑은 키 단위로 재귀 병합하므로 오버라이드 파일이 바꾸는 필드만 담으면 되고, 시퀀스와 스칼라는 통째로 교체하므로 물려받은 aliasescapabilities 항목을 지울 수 있으며, 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_filemodel_metadata_dirs가 빠진 탐색 경로를 아무 말 없이 보여주던 예전과 다릅니다. 파일은 최대 64개, 합계 최대 8 MiB까지 조립하고 한도를 넘으면 잘라내지 않고 오류로 처리합니다. 오버라이드 집합을 일부만 조용히 적용하는 쪽이 조립을 거부하는 쪽보다 나쁘기 때문입니다. 건너뛴 파일은 클라이언트 쪽에서 보이지 않으므로, 새 명령 continuum-router metadata show [--resolved] [--json]이 탐색 경로, 적용 순서대로의 레이어 목록(나중 레이어가 가져간 모델 id와 그 이전 파일 포함), 건너뛴 파일과 사유를 출력합니다. 기본 모드는 원본 병합 결과를 그대로 출력해 오타가 보이게 하고, --resolvedresponse_defaults 검증·정제를 거친 실제 적용 타입 문서를 출력하며, 사람이 읽는 머리말은 YAML 주석으로 쓰므로 출력을 그대로 저장해도 읽을 수 있는 파일이 됩니다. 적용된 파일 목록과 모델 id 오버라이드는 시작 시 로그로도 남습니다. 레이어를 읽을 때는 기본 파일을 포함해 경로를 유닉스에서 O_NONBLOCK으로 한 번만 열고, 그 동일한 디스크립터를 fstat으로 검사해 일반 파일인지 확인한 다음 남은 바이트 한도만큼만 take로 읽습니다. 그래서 타입 검사와 한도 계산과 실제로 읽는 바이트가 모두 같은 대상을 가리키며, 같은 경로를 세 번 따로 조회하던 예전 방식은 사라졌습니다. 나열된 드롭인 디렉터리 안에 fifo나 디바이스 노드가 끼어들거나 기본 파일 자리에 fifo가 바꿔 들어가도 시간 제한 없이 open이 멈춰버리는 일이 이제는 없고, fstat 이후에 파일이 커지거나 실제 길이를 속여도 크기 한도를 피해갈 수 없습니다. models 목록 병합은 레이어마다 누적된 항목을 id로 한 번만 인덱싱하므로, 오버레이 항목마다 전체 목록을 다시 훑던 이차 시간 대신 모델 수에 선형으로 걸립니다. ConfigSection::ModelMetadataDirsFromStr에 연결해, 섹션 목록과 admin JSON 스키마에는 광고되면서 섹션별 admin GET/PUT 핸들러에서는 알 수 없는 섹션으로 거부당하던 틈을 막았습니다. metadata download는 여전히 기본 파일만 쓰고 드롭인 디렉터리는 읽지도 쓰지도 않으므로 반복 실행해도 안전하며, --model-metadata 플래그의 의미도 그대로입니다. 이 플래그는 기본 파일을 지정할 뿐 드롭인을 끄지 않습니다. model_metadata_filemodel_metadata_dirs는 모두 requires_restart로 남습니다. libcO_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 인증 정보도 같은 방식으로 소스를 출력하는 모든 지점에서 제거하므로, 비공개 미러의 비밀번호가 사람이 읽는 보고서나 --jsonsource_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.doneresponse.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 해결). finalizerun_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_modePassthrough로 접고(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_loadENV_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)가 평범한 idstatus를 베타 필드 phase·agent·caller와 함께 네이티브 전용 메타데이터로 취급했기 때문에, 모든 OpenAI 호환 클라이언트가 (모델이 발급한 항목 id까지 포함해) 그대로 되돌려 보내는 message·function_call·function_call_output 항목이 불투명한 passthrough 값이 되었고, convert_to_anthropicconvert_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.addeddone 이벤트에서만 제공할 때 함수 호출 인자를 보존해, 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-prohigh만 받습니다. 라우터는 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 --resolvedconfig diff는 보간하며(diff는 secret 이름 필드를 여전히 마스킹), config show와 MCP 표면은 참조를 리터럴로 유지하고 환경 값을 읽지 않습니다.
  • 런타임에 연결된 검증형 model_aggregation config 섹션을 추가했습니다(#1042, closes #1022). 이 섹션은 집계 서비스, 캐시 TTL, fetcher·엔트리 한계, 중복 제거 전략, force-refresh 게이트, 백그라운드 새로고침 작업에 매핑되어 allow_force_refreshbackground_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_strategyprefix_routing.load_factor_epsilon 변경을 실행 중인 BackendPool에 적용합니다(#1038, closes #1025). watcher가 감지한 전략 변경이 적용됐다고 광고됐지만 HotReloadService가 라이브 풀에 밀어넣지 않아 라우팅이 시작 전략을 유지했습니다. 이제 둘 다 lock-free 셀로서 백엔드 멤버십, 백엔드별 통계, in-flight 회계, 해시 링 안전성을 보존하며 원자적으로 교체되고, Admin·MCP·문서 표면이 이를 즉시(immediate)로 보고합니다.
  • 받아들여지지만 무동작이던 prefix_routing.virtual_nodesanthropic_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: BearerX-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: conversePOST /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_hashprevious_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-planeappproxy-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_mson_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}/modelsGET /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(wavaudio/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). StatsCollectorper_api_keyper_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 필드, permissiveblocking api_keys.mode 동작, persistence_file을 통한 런타임 키 영속과 핫 리로드 설명이 들어갑니다. 통계 절에는 네 개의 새 통계 엔드포인트, window 에코, anonymousunknown 버킷, 차원당 상한, 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_bodyreasoning_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:truestore: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 레벨을 지원하고(xhighmax로 매핑), 대화 중간 system 메시지를 보존합니다. 두 모델 모두 명시적 thinking.type == "disabled"를 거부하므로, explicit_thinking_for_model이 이제 Option을 반환해 400을 유발할 값을 전달하는 대신 thinking 파라미터를 아예 생략합니다. max effort 레벨이 더 이상 Opus 전용이 아니므로 opus_supports_max_effortsupports_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-AfterDuration 덧셈을 오버플로시켜 요청 태스크를 중단시키던 원격 트리거 가능 패닉을 막습니다.
  • 스트리밍 변환 경로(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_idtext가 비어 있는 꼬리 조각만 남겼습니다. 이제 "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 -> OpenAIAnthropic -> 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_detailsrefusal 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_urlfile_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_imagefile_id 지원을 문서화. image_urlfile_id 중 정확히 하나가 필요하고, file_idinput_file 경로와 같은 소유권 및 10MB 크기 제한 아래 백엔드에 도달하기 전에 인라인 base64 데이터 URL로 해석된다는 점을 명시합니다 (#686, refs #681).

테스트

  • claude_family_versionsupports_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_imagefile_id 단독 및 image_url 단독 역직렬화, 회귀용으로 실패하던 전체 요청 페이로드, 소유권과 크기 제한을 준수하는 FileResolver 해석, 세 백엔드 전체의 변환기 출력, 두 필드 모두 없는 검증 사례 (#686).

v1.8.0 - 2026-05-28

추가됨

  • 클라이언트 API 키의 선택적 allowed_backends 허용 목록을 통한 API 키별 백엔드 접근 제어 (#677, closes #674). 목록이 비어 있지 않으면 해당 키로 인증된 요청은 명시된 백엔드로만 라우팅될 수 있고, 빈 목록이나 누락된 목록은 기존의 무제한 동작을 유지합니다. 이 필드는 엔드 투 엔드로 통합됩니다: 설정 파일과 핫 리로드, 런타임 ApiKeyAuthContext, 백엔드 선택 chokepoint와 Responses / Anthropic 선택 경로, 교차 공급자 폴백, Admin REST API(create/update/get/list), 런타임 키 영속화, 모델 목록 엔드포인트.
  • select_backend_with_retry와 Responses(StreamService), Anthropic 네이티브, count_tokens, 이미지 핸들러가 키의 허용 목록으로 후보를 필터링합니다. 모델은 존재하지만 허용 목록이 모든 후보를 거부하면, 403error_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으로 라우팅하며(StreamAccumulatorMidStreamFallbackContext 할당 없음, mid-stream 실패는 일반 스트림 오류로 표면화), 그 외에는 표준 무폴백 경로가 실행됩니다.
  • 이전에 dead code였던 handle_streaming_with_pre_stream_fallbackadvance_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_modelresolve_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였습니다. 이제 레이어가 ServiceHandlesContinuumRouterBuilder::build_routerRouter::layer를 거쳐 조립된 Axum 앱에 부착되므로, 설정된 예산이 실제로 429를 반환합니다.
  • 다섯 개 rate_limiting 차원이 모두 선택 사항이 되었습니다: 이미 선택 사항이던 per_api_key/per_modelper_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_chainsfallback.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를, 아니면 anonymousINFO 레벨로 로깅해 감사 상관관계를 지원합니다.
  • 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.rssrc/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/models200을 반환하고, 그 외 매칭되지 않은 모든 라우트는 이제 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-flash3.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와 인메모리 ApiKeyannotations: 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로 끕니다. redbduckdb 변형은 YAML 스키마의 예약 키워드로, 구현이 생기기 전까지 시작 시 NotImplemented를 반환합니다.
  • 디스크 사용량: 합성 100-series × 10-snapshot 워크로드에서 샘플당 약 119바이트 측정(tests/metrics_persistence_test::disk_usage_smoke_check_under_synthetic_load 참조). 공식은 docs/en/persistent-metrics.mdconfig.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 항목으로 남습니다.
  • 문자열 기반 인식 사이트 전반에서 developersystem을 동등하게 취급합니다: prefix-cache 키 추출, 교차 공급자 폴백 번역, OpenAI-to-Anthropic 변환의 시스템 콘텐츠 추출, global-prompt injector의 기존 시스템 메시지 lookup, smart-routing 분류기 / LLM 프롬프트 빌더.

문서

  • 문서 사이트를 MkDocs에서 Zensical로 마이그레이션하고 브랜드 스타일 복원 (#602)
  • mkdocs.ymlmkdocs.ko.yml을 제거하고 네이티브 zensical.tomlzensical.ko.toml로 대체. 둘 다 Zensical의 TOML 스키마에 따라 [project] 네임스페이스 아래에 루트를 두고, 확장별 옵션은 dict로 [project.markdown_extensions] 안에 둡니다(Zensical 설정 로더는 별도의 mdx_configs 테이블을 무시).
  • Zensical이 에셋 디렉터리의 심볼릭 링크를 따라가지 않으므로, docs/en/shareddocs/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_thinkingmodel_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-7claude-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 디바이스 플로우는 향후 다른 공급자가 구현할 경우 그대로 쓸 수 있도록 남겨두었고, 새 OpenAICodexDeviceFlowClientprovider: 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_completeuser_code에서 C0/C1 제어 문자를 출력 전에 제거해, 적대적인 공급자 응답이 ANSI 이스케이프로 터미널을 다시 그리지 못하게 막습니다.
  • auth.openai.comchatgpt.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_idscope는 공개된 Codex CLI 값으로 기본 설정되어, ChatGPT 구독 사용 시 token_store만 지정하면 됩니다.
  • Anthropic Messages와 Chat Completions 양쪽 surface 모두 ChatGPT Codex 백엔드로 투명하게 라우팅됩니다 (#592)
  • auth.typeoauth이고 공급자가 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을 반환합니다.
  • ResponsesRequeststore 필드(기본값 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::Reasoningcontentstatus를 필수로 요구하던 탓에 serde가 missing field 'content'로 거절했습니다. Anthropic Messages surface는 다른 변환 경로로 우회하고 있어서 직접 테스트하기 전까지 버그가 가려져 있었습니다. 이제 OutputItem::Reasoningcontentstatus가 모두 선택 사항이며, 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) 봉투로 다시 변환됩니다. 클라이언트에는 투명합니다. 스트림 usagestream_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_useparallel_tool_calls: false 포함), max_tokensmax_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.incompletestop_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-chatdeepseek-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-previewgemini-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-previewgemini-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-previewgemini-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.rstests/mlxcel_passthrough_test.rs가 4개 passthrough 호출 사이트를 모두 다룸: 직접 백엔드 execute_chat_completion, factory로 빌드된 백엔드(BackendFactory -> LlamaCppBackend), proxy/backend.rs HTTP 핸들러, 스트리밍 핸들러
  • test_mlxcel_factory_backend_passthrough_nonstandard_fieldsBackendFactory -> 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.rstests/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=256MAX_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() == peelstr::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.exampleweb_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_tokensbudget_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.effortreasoning_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_tokensOption<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_requesttransform_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 형식으로 자동 변환
    • 역직렬화 시 잘못된 노력 값은 명확한 오류 메시지와 함께 거부
    • ResponsesRequestwith_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 등과 매칭
    • 접미사 패턴: *-previewgpt-4o-preview, o1-preview 등과 매칭
    • 중위 패턴: gpt-*-turbogpt-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)
    • BackendPromptConfigModelPromptConfig에 새 prompt_file 필드
    • GlobalPromptConfig에 새 default_fileprompts_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_tokensmax_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 업데이트