내부 생성 계약을
Swift HTTP API로 번역한다
이 파일만 TurboFieldfare의 endpoint와 JSON 모양을 안다. 요청을 보내는 것보다 중요한 일은 응답을 검증하고, 불안정한 외부 실패를 안정된 내부 오류로 바꾸는 것이다.
이 파일을 만든 이유
TurboFieldfareServer는 /health, /v1/models, /v1/chat/completions를 제공한다. 이 경로와 choices[0].message.content 같은 구조를 route마다 반복하면 upstream 변경 때 수정 지점이 늘어난다. Adapter는 모든 TurboFieldfare 전용 지식을 한 경계에 모은다.
import와 반환 type을 먼저 읽기
import asyncio
from typing import Literal, TypeVar
import httpx2
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
from app.engines.base import (
GenerationRequest, GenerationResult, InferenceError, InferenceErrorCode,
ModelInfo, TokenUsage,
)
WireResponse = TypeVar("WireResponse", bound=_WireModel)읽기 순서를 위해 import와 TypeVar 선언을 모아 표시했다. 실제 파일의 WireResponse 선언은 아래의 응답 model class 정의 뒤에 있다. asyncio는 비동기 시간 제한, httpx2는 HTTP 전송, Pydantic import는 JSON 구조 검증을 맡는다. base.py에서 가져온 type은 서비스 내부의 요청·결과·오류 계약이다.
TypeVar("WireResponse", bound=_WireModel)는 “_WireModel 계열 중 호출자가 선택한 type”을 나타낸다. schema: type[WireResponse]에 _Health class를 넣으면 _request의 반환 type도 _Health로 이어지도록 정적 검사기에 알려 준다. TypeVar가 JSON을 검증하거나 객체를 만드는 것은 아니며 실제 검증은 schema.model_validate_json(...)이 한다.
응답용 private Pydantic model
class _WireModel(BaseModel):
"""Validate the supported upstream subset; tolerate unrelated new fields."""
model_config = ConfigDict(strict=True, extra="ignore")
class _Health(_WireModel):
status: Literal["ok"]
class _Model(_WireModel):
id: str = Field(min_length=1)
object: Literal["model"]
class _Models(_WireModel):
object: Literal["list"]
data: list[_Model]
class _Message(_WireModel):
role: Literal["assistant"]
content: str
tool_calls: list[dict] = Field(default_factory=list, max_length=0)
class _Choice(_WireModel):
index: Literal[0]
message: _Message
finish_reason: Literal["stop", "length"]
class _PromptDetails(_WireModel):
cached_tokens: int = Field(ge=0)
class _Usage(_WireModel):
prompt_tokens: int = Field(ge=0)
completion_tokens: int = Field(ge=0)
total_tokens: int = Field(ge=0)
prompt_tokens_details: _PromptDetails
@model_validator(mode="after")
def check_counts(self) -> "_Usage":
if self.total_tokens != self.prompt_tokens + self.completion_tokens:
raise ValueError("Inconsistent total token count")
if self.prompt_tokens_details.cached_tokens > self.prompt_tokens:
raise ValueError("Cached token count exceeds prompt token count")
return self
class _Completion(_WireModel):
id: str = Field(min_length=1)
object: Literal["chat.completion"]
model: str
choices: list[_Choice] = Field(min_length=1, max_length=1)
usage: _Usage
strict=True와 extra="ignore"가 함께 있는 이유
strict=True는 문자열 "12"를 정수 12로 자동 보정하지 않는다. 필수 field가 잘못된 type이면 upstream 계약 오류로 본다. 반면 extra="ignore"는 우리가 쓰지 않는 새 field가 추가됐다는 이유만으로 전체 서비스를 깨지 않게 한다. 즉, 필요한 값에는 엄격하고 무관한 확장에는 관대하다.
왜 tool call을 조용히 버리지 않는가?
Tool을 요청한 응답은 content만 읽으면 의미가 사라질 수 있다. 현재 내부 계약에는 tool call이 없으므로 max_length=0으로 명시적으로 거절한다. 기능이 없는 것보다 더 위험한 것은 있는 척하며 데이터를 잃는 것이다.
생성자: 두 client를 목적별로 보관한다
def __init__(
self, generation_client: httpx2.AsyncClient, *,
probe_client: httpx2.AsyncClient, model: str,
probe_timeout: float, generation_timeout: float,
) -> None:
self._generation_client = generation_client
self._probe_client = probe_client
self._model = model
self._probe_timeout = probe_timeout
self._generation_timeout = generation_timeoutgeneration_client는 답변 생성용, probe_client는 상태·모델 목록 확인용이다. * 뒤의 인수는 이름을 붙여 전달해야 하므로 조립 지점에서 probe_client=...가 드러난다. 이 함수는 client를 만들거나 연결을 열지 않고 참조와 설정값을 보관한다.
_request(..., client=...)는 사용할 client를 명시적으로 받는다. 따라서 공통 JSON 검증과 오류 처리 코드를 유지하면서도 두 연결 풀이 섞이지 않는다. 구체적인 생성·종료 책임은 main.py의 lifespan에 있다.
오류 JSON에서 안전한 종류만 뽑는다
class _ErrorDetail(_WireModel):
# Deliberately exclude message/param: they can contain user content.
code: str
class _ErrorEnvelope(_WireModel):
error: _ErrorDetail
_KNOWN_REQUEST_ERRORS: dict[tuple[int, str], InferenceErrorCode] = {
(400, "context_length_exceeded"): "context_too_long",
(400, "invalid_message"): "invalid_message",
(404, "model_not_found"): "model_mismatch",
}
def _error_code(response: httpx2.Response) -> InferenceErrorCode:
"""Keep only allowlisted categories; unknown/malformed bodies use status."""
if response.status_code == 429:
return "busy"
if 400 <= response.status_code < 500:
try:
error = _ErrorEnvelope.model_validate_json(response.content)
except ValidationError:
return "request_rejected"
return _KNOWN_REQUEST_ERRORS.get(
(response.status_code, error.error.code), "request_rejected",
)
return "upstream_error"
| 실제 upstream 응답 | 내부 오류 | 호출자가 구분할 수 있는 대응 |
|---|---|---|
| 400 + context_length_exceeded | context_too_long | 대화 길이·생성 길이 제한 검토, 대화 축소 또는 요약 |
| 400 + invalid_message | invalid_message | 내용·역할·메시지 순서 확인 |
| 404 + model_not_found | model_mismatch | 서버 주소와 model ID 설정 확인 |
| 429 | busy | 수용 여력 부족 표시, 호출자 정책에 따른 재요청 판단 |
| 그 밖의 4xx (429 제외), 해당 4xx의 알 수 없거나 깨진 오류 JSON | request_rejected | HTTP status로만 안전하게 분류 |
| 5xx·3xx·예상하지 않은 2xx 등 그 밖의 비-200 status | upstream_error | upstream 장애 또는 예상한 API 계약과의 불일치 확인 |
왜 status와 code를 함께 확인하는가?
(404, "model_not_found")처럼 두 값을 묶어 allowlist의 key로 사용한다. 예를 들어 HTTP 500 본문에 context_length_exceeded가 있어도 서버 실패를 사용자 입력 문제로 오해하지 않고 upstream_error로 둔다. 429는 본문이 깨져도 busy다. 이 매핑은 현재 Swift의 OpenAIModels.swift, HTTPServer.swift, ServerInference.swift에서 확인한 계약에 맞춘다.
원본 message를 읽지 않아도 되는가?
알려진 종류만 구분하면 호출자는 다음 행동을 결정할 수 있다. _ErrorDetail에는 code만 선언하고 상속받은 extra="ignore"로 message·param은 보관하지 않는다. code도 임의 문자열을 그대로 반환하지 않고 고정된 내부 범주로만 바꾼다. 검증 자체가 실패해도 Pydantic 오류를 전파하지 않고 fallback을 쓴다.
문맥을 자동 삭제하거나 잘못된 메시지를 자동 수정하는 코드는 없다. 이번 단계는 적절한 대응을 가능하게 하는 분류까지만 담당한다.
공통 HTTP 경로 _request()
async def _request(
self, method: str, path: str, schema: type[WireResponse], *,
client: httpx2.AsyncClient, deadline: float, payload: dict | None = None,
) -> WireResponse:
try:
# A total deadline, including pool wait and upstream queue time.
async with asyncio.timeout(deadline):
response = await client.request(method, path, json=payload)
except httpx2.PoolTimeout:
raise InferenceError("busy") from None
except (httpx2.TimeoutException, TimeoutError):
raise InferenceError("timeout") from None
except httpx2.RequestError:
raise InferenceError("unavailable") from None
if response.status_code != 200:
raise InferenceError(
_error_code(response), upstream_status=response.status_code,
)
try:
return schema.model_validate_json(response.content)
except ValidationError:
# Do not leak validation errors: they may contain generated text.
raise InferenceError("invalid_response") from None
선택된 생성용·확인용 client의 base URL 뒤에 /health 같은 상대 경로가 붙는다.
asyncio.timeout이 client.request 구간의 pool 대기·연결·본문 수신을 제한한다. 그 뒤의 동기 JSON 검증은 포함하지 않는다.
알려진 status·code 조합은 세부 범주로, 나머지는 status 기반 공통 범주로 바꾼다.
200 응답도 schema에 맞아야만 Python object로 반환한다.
raise ... from None은 왜 쓰는가?
기본 traceback 출력에서 앞서 발생한 예외의 연결 표시를 생략한다. 원본 예외·HTTP body를 메모리에서 지우거나 __context__ 접근까지 막는 보안 기능은 아니다. 따라서 raw exception이나 response body를 따로 로그에 남기지 않는 정책도 필요하다. Adapter를 호출하는 route는 안정된 InferenceError만 처리한다. 그렇다고 원인을 무시하는 것은 아니다. code와 upstream status를 별도로 남기며, 향후 구조화 log에서 요청 ID와 함께 관찰한다.
전체 deadline이라는 말의 정확한 범위
기본 5초는 readiness 전체가 아니라 각 HTTP 검사 요청에 적용된다. health가 4초, models가 4초 걸리면 각각은 제한 안이지만 두 요청을 합친 readiness는 약 8초가 될 수 있다. 또한 asyncio의 취소는 event loop가 실행 기회를 얻어야 전달되므로 CPU를 오래 점유하는 동기 코드를 강제로 끊는 hard real-time 제한은 아니다. 현재 테스트는 응답을 보류한 실제 socket에서 timer가 작동하는지를 확인한다.
timeout과 사용자의 취소는 같은 오류인가?
아니다. 실제 전체 deadline을 넘으면 asyncio.timeout이 만든 TimeoutError를 InferenceError("timeout")으로 바꾼다. 연결 풀의 자리만 기다리다 제한을 넘으면 PoolTimeout을 먼저 잡아 busy로 구분한다.
호출자가 작업을 취소해서 발생한 CancelledError는 잡지 않으므로 그대로 전파된다. 실제 socket 테스트에서 양쪽 요청의 취소·시간 초과 후 연결이 닫히고 다음 요청이 가능한지 확인했다. 이것만으로 원격 Gemma GPU 작업의 즉각적인 중단까지 증명한 것은 아니며, browser disconnect 연동은 streaming 단계다.
Readiness: 생존과 model ID를 함께 확인한다
async def list_models(self) -> tuple[ModelInfo, ...]:
result = await self._request(
"GET", "/v1/models", _Models,
client=self._probe_client, deadline=self._probe_timeout,
)
return tuple(ModelInfo(id=model.id) for model in result.data)
async def check_ready(self) -> ModelInfo:
await self._request(
"GET", "/health", _Health,
client=self._probe_client, deadline=self._probe_timeout,
)
models = await self.list_models()
for model in models:
if model.id == self._model:
return model
raise InferenceError("model_mismatch")/health만 보면 다른 설정의 TurboFieldfare가 같은 port에 떠 있어도 ready로 오판할 수 있다. 그래서 model 목록에 설정한 ID가 실제로 있는지까지 확인한다. 현재 설정의 기본값은 gemma-4-26b-a4b-it이며 이 method에 고정된 문자열은 아니다. 반대로 readiness에서 token 생성을 하지 않는 이유는 probe 한 번마다 수초의 GPU·SSD 작업과 queue 점유가 생기기 때문이다.
Generation: 내부 요청을 전송 JSON으로 바꾼다
async def generate(self, request: GenerationRequest) -> GenerationResult:
payload = {
"model": self._model,
"messages": [message.model_dump() for message in request.messages],
"max_completion_tokens": request.max_output_tokens,
"temperature": request.temperature,
"stream": False,
}
result = await self._request(
"POST", "/v1/chat/completions", _Completion,
client=self._generation_client,
deadline=self._generation_timeout, payload=payload,
)
if result.model != self._model:
raise InferenceError("model_mismatch")
choice = result.choices[0]
return GenerationResult(
id=result.id, model=result.model, text=choice.message.content,
finish_reason=choice.finish_reason,
usage=TokenUsage(
input_tokens=result.usage.prompt_tokens,
output_tokens=result.usage.completion_tokens,
total_tokens=result.usage.total_tokens,
cached_input_tokens=result.usage.prompt_tokens_details.cached_tokens,
),
)
실제 값의 변환
| 내부 값 | wire JSON | 응답 후 내부 값 |
|---|---|---|
max_output_tokens=16 | "max_completion_tokens":16 | output_tokens=3 |
temperature=0 | "temperature":0.0 | 샘플링 결과 자체는 text로 전달 |
Message(role="user", ...) | messages[0] | choices[0].message.content |
| 없음 | "stream":false | GenerationResult 하나 |
stream=false는 이번 단계의 의도적인 경계다. SSE는 chunk를 읽으면서 즉시 browser로 넘기고 client disconnect 때 upstream을 취소해야 하므로 별도의 lifecycle을 갖는다. 다음 streaming 단계에서 버퍼링 없이 추가한다.
초기 실제 서버 검증 결과와 해석
MockTransport test는 정확한 outgoing JSON, 400·429·500 mapping, 연결 거절, token 합계 불일치를 검사했다. Live test는 같은 코드가 macOS host와 Docker container에서 실제 Swift server까지 도달함을 확인했다.
보완 후 회귀 검증
새 검증은 실제 모델 대신 통제된 loopback 서버를 사용한다. 생성 연결 4개 포화 중 readiness 정상 동작, 모델 불일치 유지, 실제 시간 제한·취소 후 연결 정리, 안전한 오류 분류를 확인했다.