관리자 AI 제공자 연결 기능 추가 (claude·codex·agy·openrouter 토큰 붙여넣기 연결)
Some checks failed
API contract / OpenAPI type drift (push) Failing after 12m57s

관리자 /admin/ai 화면에서 provider 토큰(OAuth 액세스 토큰 또는 API 키)을
발급받아 붙여넣으면 DB에 암호화 저장되고, shared-secret으로 보호되는
게이트웨이 내부 엔드포인트로 push되어 실행 중인 엔진 컨테이너에 즉시
적용된다. NAS처럼 게이트웨이가 컨테이너로 도는 환경에서 CLI·로컬 PC
의존 없이 claude_api·openai·openrouter를 연결할 수 있다.

- 게이트웨이: openrouter 어댑터 신설(chat/completions), OAuth 토큰이면
  Anthropic Bearer 헤더, /internal/provider-credentials GET/POST와
  boot_id 기반 재동기화
- API: app.admin_provider_credential 테이블(idempotent DDL), stdlib
  HMAC-CTR+MAC 암호화 서비스(PROVIDER_CREDENTIAL_SECRET, 폴백
  SESSION_SECRET), /admin/providers CRUD·검증 라우트
- 웹: 제공자 연결 패널(저장·검증·해제, 토큰 힌트만 표시), 엔진 선택에
  OpenRouter 추가, api.gen.ts 재생성
- compose: api 서비스에 PROVIDER_CREDENTIAL_SECRET 전달(로컬·NAS)
This commit is contained in:
Yun Chan 2026-09-11 14:06:15 +09:00
parent 6831a79ffb
commit 4b45d33142
22 changed files with 2088 additions and 13 deletions

View file

@ -42,8 +42,10 @@ from app.contracts.engine_gateway import (
from engine_gateway.provider_registry import (
ProviderError,
_cli_subprocess_env,
clear_capability_cache,
discover_capabilities,
generate_with_provider,
set_provider_auth_kind,
stream_with_provider,
)
@ -434,6 +436,76 @@ _READY_LOCK = asyncio.Lock()
app = FastAPI(title="Vignette Engine Gateway")
_AUTH_EXEMPT_PATHS = frozenset({"/health"})
# 게이트웨이는 DB가 없다. 관리자 API가 저장한 자격증명을 shared-secret 미들웨어 뒤
# 내부 엔드포인트로 push받아 이 프로세스 환경(os.environ)에 주입한다. boot_id는
# API가 재시작된 게이트웨이를 감지해 자격증명을 재-push하게 하는 무결성 표식이다.
GATEWAY_BOOT_ID = uuid.uuid4().hex
# provider 연결 코드(os.environ에 주입할 키). claude는 api_key와 OAuth 토큰이
# 환경 변수가 다르므로 둘 다 둔다.
_PROVIDER_CREDENTIAL_ENV_VARS: dict[str, tuple[str, ...]] = {
"claude": ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"),
"codex": ("OPENAI_API_KEY",),
"agy": ("AGY_API_KEY",),
"openrouter": ("OPENROUTER_API_KEY",),
}
class ProviderCredentialEntry(BaseModel):
token: str
auth_kind: str = "api_key"
class ProviderCredentialsPush(BaseModel):
providers: dict[str, ProviderCredentialEntry]
def _credential_loaded(provider: str) -> bool:
for name in _PROVIDER_CREDENTIAL_ENV_VARS.get(provider, ()):
if os.environ.get(name, "").strip():
return True
return False
@app.get("/internal/provider-credentials")
async def internal_provider_credentials_status():
return {
"ok": True,
"boot_id": GATEWAY_BOOT_ID,
"loaded_providers": [
provider for provider in _PROVIDER_CREDENTIAL_ENV_VARS if _credential_loaded(provider)
],
}
@app.post("/internal/provider-credentials")
async def internal_push_provider_credentials(payload: ProviderCredentialsPush):
applied: list[str] = []
for provider, entry in payload.providers.items():
env_names = _PROVIDER_CREDENTIAL_ENV_VARS.get(provider)
if env_names is None:
continue
token = entry.token.strip()
if not token:
continue
if provider == "claude":
if entry.auth_kind == "oauth_token":
os.environ.pop("ANTHROPIC_API_KEY", None)
os.environ["ANTHROPIC_AUTH_TOKEN"] = token
else:
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
os.environ["ANTHROPIC_API_KEY"] = token
else:
os.environ[env_names[0]] = token
set_provider_auth_kind(provider, entry.auth_kind)
applied.append(provider)
clear_capability_cache()
return {
"ok": True,
"boot_id": GATEWAY_BOOT_ID,
"applied": applied,
}
@app.middleware("http")
async def require_gateway_shared_secret(request: Request, call_next):