3d60008965
B안(사용자 2026-06-11): Gemma 26B-A4B → Qwen3.6-27B-6bit 풀교체. - config.yaml triage/primary model 교체 + dense 감속 반영 timeout 상향(30→120/180→300) - held_stages [] (홀드 해제 — 적체 자연 드레인, deep_summary 는 primary 복귀) - eid deep 모드 = mac-mini-default 재지정(맥북 백지화). llm_gate '예외 없이 gate' invariant 에 따라 deep 도 alias 조건으로 자동 게이트 (구 무게이트 = 맥북 별 endpoint 예외였음) - deep probe 실패 reason = router_unreachable 로 정정 + 테스트 동기화 잔여(별 PR): ask 표면 qwen-macbook 옵션/백엔드 클래스/처리보드 맥북 카드 정리 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
238 lines
12 KiB
Python
238 lines
12 KiB
Python
"""이드 실행 컨텍스트 LLM 클라이언트 — egress 코드층 박탈 (W4-1).
|
|
|
|
설계 0-4 / project_eid_persona_substrate 불변식 #5: 이드 LLM = call_primary(:8801 Mac mini MLX) 만.
|
|
공인 Claude(ai.fallback) 경로를 *구조적으로* 차단 — 같은 fastapi 컨테이너에 합법 egress 워커
|
|
(daily_digest SMTP·law_monitor CalDAV 등)가 import 돼 있어도 이드는 이 클라이언트라 fallback/외부
|
|
endpoint 를 못 부른다(silent fallback 0, rules no-silent-fallback).
|
|
|
|
차단 3중 (코드층 = 1차·확정 가드. 네트워크 default-deny = W4-2 belt, 조건부):
|
|
- call_fallback() → raise (공인 Claude 직접 호출 봉쇄)
|
|
- _call_chat() → 자동 fallback 분기 제거(primary 실패 = re-raise → caller 503)
|
|
- _request() → endpoint 에 anthropic.com 있으면 raise(primary 오결선 방어, 이중보증)
|
|
call_primary / call_triage / embed / rerank 는 그대로(내부 inference·임베딩 허용).
|
|
egress 워커·시스템 경로는 기존 AIClient 유지 — fallback 은 시스템만, 이드만 박탈(분리).
|
|
|
|
eid-chat (D-5): 이드 채팅 SSE 스트리밍도 이 클래스의 call_stream() 한 곳 — RouterBackend
|
|
직접 호출 금지, mode 어휘는 _CHAT_ALIAS 닫힌 매핑(daily/deep)만, 미지 mode = EidEgressBlocked.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import re
|
|
from collections.abc import AsyncIterator
|
|
from contextlib import AsyncExitStack
|
|
|
|
import httpx
|
|
|
|
from ai.client import AIClient
|
|
from services.llm.backends import (
|
|
MAC_MINI_DEFAULT,
|
|
BackendUnavailable,
|
|
_router_url, # router URL 단일 출처 재사용 (settings → env LLM_ROUTER_URL → MVP default)
|
|
)
|
|
from services.search.llm_gate import Priority, acquire_mlx_gate
|
|
|
|
# 이드 채팅 mode → router alias 닫힌 매핑 (D-2). 클라는 mode 만 보냄 — claude-cloud/auto 금지.
|
|
# 2026-06-11 맥북 백지화: deep 도 mac-mini-default (맥미니 Qwen 27B 단일 호스트).
|
|
# mode 구분은 유지 — deep = ReAct 자동검색 경로(모델이 아니라 동작이 다름).
|
|
# 게이트는 alias==MAC_MINI_DEFAULT 조건이라 deep 도 자동으로 mlx gate 적용
|
|
# (llm_gate "예외 없이 gate 획득 필수" invariant 충족 — 구 무게이트는 맥북 예외였음).
|
|
_CHAT_ALIAS: dict[str, str] = {
|
|
"daily": MAC_MINI_DEFAULT, # router tier_b → Mac mini :8801
|
|
"deep": MAC_MINI_DEFAULT, # 맥북 폐기로 동일 upstream — ReAct 검색 모드 구분만 유지
|
|
}
|
|
|
|
# read 는 per-chunk 적용이라 MacBook wake(24s)+토큰 생성 간격 커버. connect 는 내부 router 라 짧게.
|
|
_STREAM_TIMEOUT = httpx.Timeout(connect=5.0, read=120.0, write=30.0, pool=5.0)
|
|
|
|
# 스트림 중계 전체(업스트림 진입~종료) wall-clock 상한. per-chunk read timeout 만으로는
|
|
# 토큰이 계속 흐르는 한 무한 점유 가능 → daily 는 mlx gate 를 물고 있어 deadline 필수.
|
|
# deep 도 동일 적용(단순·일관). 정상 스트림(max_tokens 2048, ~90tps ≈ 23s)은 여유 통과.
|
|
_STREAM_DEADLINE_S = 300.0
|
|
|
|
# error_reason allowlist — 이 밖(대문자/공백/JSON 직렬화 파편)은 일반화해 비노출
|
|
_REASON_ALLOWED = re.compile(r"[a-z0-9_]{1,64}")
|
|
|
|
# 스트림 시작 전 transport 계열 실패 → BackendUnavailable 매핑 대상 (RouterBackend._post 와 동일 목록)
|
|
_TRANSPORT_ERRORS = (
|
|
httpx.ConnectError,
|
|
httpx.ConnectTimeout,
|
|
httpx.ReadTimeout,
|
|
httpx.PoolTimeout,
|
|
httpx.WriteTimeout,
|
|
httpx.RemoteProtocolError,
|
|
)
|
|
|
|
|
|
def _stream_error_reason(status_code: int, body: bytes) -> str:
|
|
"""스트림 시작 전 4xx/5xx 응답 본문 → error_reason 추출.
|
|
|
|
어휘는 /api/search/ask(RouterBackend._post)와 일치 — router 가 주는 error.type /
|
|
error.error_reason (macbook_unavailable / warming / editor_busy / upstream_cold /
|
|
provider_not_configured 등) 우선, 없으면 status 기반 router_503 / upstream_502 /
|
|
router_http_<status>.
|
|
|
|
최종 reason 은 [a-z0-9_]{1,64} allowlist 검사 — 불일치(대문자/공백/dict 직렬화
|
|
파편)는 upstream_502(502 계열) / router_error(그 외) 로 일반화해 외부 비노출.
|
|
"""
|
|
try:
|
|
data = json.loads(body.decode("utf-8", errors="replace"))
|
|
except Exception:
|
|
data = {}
|
|
err = data.get("error", {}) if isinstance(data, dict) else {}
|
|
reason: str | None = None
|
|
if isinstance(err, dict):
|
|
raw = err.get("type") or err.get("error_reason")
|
|
if raw:
|
|
reason = str(raw)
|
|
if reason is None and isinstance(data, dict) and data.get("error_reason"):
|
|
reason = str(data["error_reason"])
|
|
if reason is None:
|
|
if status_code == 502:
|
|
reason = "upstream_502"
|
|
elif status_code == 503:
|
|
reason = "router_503"
|
|
else:
|
|
reason = f"router_http_{status_code}"
|
|
if _REASON_ALLOWED.fullmatch(reason):
|
|
return reason
|
|
return "upstream_502" if status_code == 502 else "router_error"
|
|
|
|
|
|
def _rewrite_sse_line(line: bytes, mode: str) -> bytes:
|
|
"""SSE 라인 1건 정화 — data: JSON 의 model 을 mode 어휘로 치환 + usage 제거.
|
|
|
|
fixture 실측: 27B chunk 의 model 필드가 맥북 파일시스템 절대경로
|
|
("/Users/.../mlx-models/Qwen3.6-27B-8bit")를 노출 — 표면 문법 '모델·머신명
|
|
비노출'과 충돌해 라인 단위로 재작성한다. usage(tps/peak_memory 등 머신
|
|
텔레메트리)도 함께 제거. [DONE]·비-data 라인(빈 줄 포함)·파싱 실패 라인은
|
|
원문 그대로(방어적) — SSE 프레이밍(data: 라인 + 빈 줄) 보존.
|
|
"""
|
|
if not line.startswith(b"data: "):
|
|
return line
|
|
payload = line[len(b"data: "):]
|
|
if payload.strip() == b"[DONE]":
|
|
return line
|
|
try:
|
|
obj = json.loads(payload)
|
|
except Exception:
|
|
return line
|
|
if not isinstance(obj, dict):
|
|
return line
|
|
obj["model"] = mode
|
|
obj.pop("usage", None)
|
|
return b"data: " + json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
|
|
|
|
|
class EidEgressBlocked(RuntimeError):
|
|
"""이드 컨텍스트에서 외부 egress(공인 Claude 등) 시도 — 코드층 박탈로 차단."""
|
|
|
|
|
|
class EidAIClient(AIClient):
|
|
"""이드 전용 — call_primary only. fallback/외부 endpoint 구조적 봉쇄. AIClient drop-in."""
|
|
|
|
async def call_fallback(self, prompt: str) -> str:
|
|
raise EidEgressBlocked(
|
|
"이드: 공인 Claude fallback 금지(egress 코드층 박탈). call_primary(:8801) 만 허용."
|
|
)
|
|
|
|
async def _call_chat(self, model_config, prompt: str) -> str:
|
|
# 자동 fallback 분기 제거 — primary 실패는 그대로 raise(caller 가 503 매핑, silent fallback 0).
|
|
return await self._request(model_config, prompt)
|
|
|
|
async def _request(self, model_config, prompt: str, system: str | None = None) -> str:
|
|
endpoint = getattr(model_config, "endpoint", "") or ""
|
|
if "anthropic.com" in endpoint:
|
|
raise EidEgressBlocked(f"이드: 외부 endpoint 차단 ({endpoint}). 내부 inference 만.")
|
|
return await super()._request(model_config, prompt, system=system)
|
|
|
|
async def call_stream(
|
|
self, mode: str, messages: list[dict], system: str
|
|
) -> AsyncIterator[bytes]:
|
|
"""이드 채팅 SSE 스트림 — router /v1/chat/completions stream=true 라인 단위 중계 (D-5).
|
|
|
|
mode : "daily" | "deep" — _CHAT_ALIAS 닫힌 매핑. 미지 mode = EidEgressBlocked
|
|
(이드 LLM 호출 봉쇄는 이 클래스 한 곳, 불변식 #5).
|
|
messages : user/assistant 턴 목록 (system role 금지 — system 인자로만 주입).
|
|
system : compose("eid_chat", ...) 합본. messages 맨 앞에 system role 로 끼움.
|
|
|
|
스트림 시작 전 실패(연결 실패·5xx 응답) = BackendUnavailable(reason 어휘는 ask
|
|
와 동일). router 400 = 닫힌 매핑에서 alias drift 코드 버그 → ValueError fail-loud
|
|
(RouterBackend._post 컨벤션 미러). 스트림 시작 후엔 bytes 를 라인 버퍼링해
|
|
_rewrite_sse_line 으로 model 치환(mode 어휘)·usage 제거만 하고 프레이밍은 보존.
|
|
취소/disconnect 시 AsyncExitStack 이 response·client 정리(upstream 닫힘 보장).
|
|
|
|
daily/deep 모두 mac-mini-default(2026-06-11 맥북 백지화) → Mac mini MLX 단일
|
|
inference 영구 룰(llm_gate docstring "예외 없이 gate 획득 필수")에 따라
|
|
acquire_mlx_gate(FOREGROUND) 안에서 스트리밍 — 게이트 조건이 alias 기준이라
|
|
deep 도 자동 적용 (구 무게이트는 맥북 별 endpoint 시절 예외였음).
|
|
|
|
중계 전체(업스트림 진입~종료)는 asyncio.timeout(_STREAM_DEADLINE_S) wall-clock
|
|
deadline 안 — llm_gate 계약 "timeout 은 gate 안쪽" 준수(gate 대기엔 미적용).
|
|
초과 시 BackendUnavailable(alias, "stream_deadline_exceeded") 로 수렴.
|
|
"""
|
|
alias = _CHAT_ALIAS.get(mode)
|
|
if alias is None:
|
|
raise EidEgressBlocked(
|
|
f"이드: 미지 chat mode {mode!r} — 닫힌 매핑(daily/deep) 외 호출 차단."
|
|
)
|
|
router_url = _router_url()
|
|
if "anthropic.com" in router_url:
|
|
# 기존 _request 패턴 미러 — router URL 오결선 시 외부 egress 방어 (이중보증)
|
|
raise EidEgressBlocked(f"이드: 외부 endpoint 차단 ({router_url}). 내부 router 만.")
|
|
url = f"{router_url.rstrip('/')}/v1/chat/completions"
|
|
payload = {
|
|
"model": alias,
|
|
"messages": [{"role": "system", "content": system}] + messages,
|
|
"stream": True,
|
|
"max_tokens": 2048,
|
|
"temperature": 0.4,
|
|
}
|
|
async with AsyncExitStack() as stack:
|
|
if alias == MAC_MINI_DEFAULT:
|
|
await stack.enter_async_context(acquire_mlx_gate(Priority.FOREGROUND))
|
|
client = await stack.enter_async_context(httpx.AsyncClient(timeout=_STREAM_TIMEOUT))
|
|
try:
|
|
# wall-clock deadline — gate 획득 *후* 진입 (llm_gate "timeout 은 gate 안쪽")
|
|
async with asyncio.timeout(_STREAM_DEADLINE_S):
|
|
try:
|
|
resp = await stack.enter_async_context(
|
|
client.stream("POST", url, json=payload)
|
|
)
|
|
except _TRANSPORT_ERRORS as exc:
|
|
# 스트림 시작 전 연결 계열 실패 — reason 어휘 = RouterBackend(router_*) 와 일치
|
|
raise BackendUnavailable(alias, f"router_{type(exc).__name__}") from exc
|
|
if resp.status_code == 400:
|
|
# 닫힌 매핑에서 400 = alias drift 코드 버그 — RouterBackend._post 미러,
|
|
# BackendUnavailable(일시 비가용) 아님 → fail-loud
|
|
body = await resp.aread()
|
|
try:
|
|
data = json.loads(body.decode("utf-8", errors="replace"))
|
|
except Exception:
|
|
data = {}
|
|
raise ValueError(f"router rejected alias={alias!r} body={data!r}")
|
|
if resp.status_code >= 400:
|
|
body = await resp.aread()
|
|
raise BackendUnavailable(
|
|
alias, _stream_error_reason(resp.status_code, body)
|
|
)
|
|
buf = b""
|
|
try:
|
|
async for chunk in resp.aiter_bytes():
|
|
buf += chunk
|
|
# 라인 버퍼링 — 청크 경계에서 b"\n" 분리, 잔여 버퍼 유지
|
|
while (nl := buf.find(b"\n")) != -1:
|
|
line, buf = buf[:nl], buf[nl + 1:]
|
|
yield _rewrite_sse_line(line, mode) + b"\n"
|
|
except _TRANSPORT_ERRORS as exc:
|
|
# 시작 후 중단 — 이미 보낸 chunk 는 전송됨. typed 예외로 수렴(caller 가 끊고 정리).
|
|
raise BackendUnavailable(alias, f"router_{type(exc).__name__}") from exc
|
|
if buf:
|
|
# 스트림 끝 잔여분 flush (개행 없는 마지막 라인 — 원문에 없던 \n 추가 안 함)
|
|
yield _rewrite_sse_line(buf, mode)
|
|
except TimeoutError as exc:
|
|
# asyncio.timeout 초과 — 게이트 점유 무한화 차단, typed 예외로 수렴
|
|
raise BackendUnavailable(alias, "stream_deadline_exceeded") from exc
|