관리자 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):

View file

@ -98,6 +98,7 @@
"codex_cli",
"agy_cli",
"openai",
"openrouter",
"solar"
],
"type": "string"

View file

@ -47,6 +47,9 @@ ANTHROPIC_API_BASE = os.environ.get(
OPENAI_API_BASE = os.environ.get(
"OPENAI_BASE_URL", "https://api.openai.com/v1"
).rstrip("/")
OPENROUTER_API_BASE = os.environ.get(
"OPENROUTER_API_BASE", "https://openrouter.ai/api/v1"
).rstrip("/")
_OPENAI_DEFAULT_ENGINE_MODELS = (
"gpt-5.6-terra",
"gpt-5.6-luna",
@ -80,6 +83,34 @@ class ProviderStreamEvent:
_CAPABILITY_CACHE: dict[EngineProvider, tuple[float, EngineCapabilitiesResponse]] = {}
_CAPABILITY_LOCK = asyncio.Lock()
# 관리자 연결 UI가 push한 자격증명의 종류(api_key | oauth_token). 토큰 값 자체는
# os.environ으로만 들어가고 여기엔 어떤 헤더로 보낼지 판정하는 힌트만 둔다.
_PROVIDER_AUTH_KINDS: dict[str, str] = {}
def set_provider_auth_kind(provider: str, auth_kind: str) -> None:
if auth_kind in {"api_key", "oauth_token"}:
_PROVIDER_AUTH_KINDS[provider] = auth_kind
else:
_PROVIDER_AUTH_KINDS.pop(provider, None)
def get_provider_auth_kind(provider: str) -> str:
return _PROVIDER_AUTH_KINDS.get(provider, "api_key")
def _anthropic_headers(api_key: str) -> dict[str, str]:
"""Anthropic 인증 헤더. OAuth 액세스 토큰은 x-api-key 대신 Bearer를 쓴다."""
if get_provider_auth_kind("claude") == "oauth_token":
return {
"Authorization": f"Bearer {api_key}",
"anthropic-version": "2023-06-01",
}
return {
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
}
def clear_capability_cache() -> None:
_CAPABILITY_CACHE.clear()
@ -490,7 +521,9 @@ async def _discover_agy_cli() -> EngineCapabilitiesResponse:
async def _discover_claude_api() -> EngineCapabilitiesResponse:
api_key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
api_key = os.environ.get("ANTHROPIC_API_KEY", "").strip() or os.environ.get(
"ANTHROPIC_AUTH_TOKEN", ""
).strip()
if not api_key:
return _unavailable("claude_api", "ANTHROPIC_API_KEY가 설정되지 않았습니다.")
try:
@ -498,10 +531,7 @@ async def _discover_claude_api() -> EngineCapabilitiesResponse:
response = await client.get(
f"{ANTHROPIC_API_BASE}/v1/models",
params={"limit": 100},
headers={
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
},
headers={**_anthropic_headers(api_key)},
)
response.raise_for_status()
payload = response.json()
@ -623,6 +653,170 @@ async def _discover_openai() -> EngineCapabilitiesResponse:
)
def _configured_openrouter_models() -> list[str]:
"""OpenRouter allowlist. 미설정이면 계정이 노출한 전체 모델을 그대로 쓴다."""
raw = os.environ.get("OPENROUTER_ENGINE_MODELS", "").strip()
if not raw:
return []
result: list[str] = []
for model in (item.strip() for item in raw.split(",")):
if not model or model in result:
continue
if any(character.isspace() for character in model) or "/" not in model:
raise ProviderError(
"OPENROUTER_ENGINE_MODELS의 모델 식별자는 vendor/model 형태여야 합니다."
)
result.append(model)
return result
async def _discover_openrouter() -> EngineCapabilitiesResponse:
api_key = os.environ.get("OPENROUTER_API_KEY", "").strip()
if not api_key:
return _unavailable("openrouter", "OPENROUTER_API_KEY가 설정되지 않았습니다.")
try:
allowed_models = _configured_openrouter_models()
async with httpx.AsyncClient(timeout=20) as client:
response = await client.get(
f"{OPENROUTER_API_BASE}/models",
headers={"Authorization": f"Bearer {api_key}"},
)
response.raise_for_status()
payload = response.json()
except ProviderError as exc:
return _unavailable("openrouter", str(exc))
except (httpx.HTTPError, ValueError) as exc:
return _unavailable("openrouter", f"OpenRouter 모델 조회 실패: {exc}")
data = payload.get("data", []) if isinstance(payload, dict) else []
live: dict[str, dict[str, Any]] = {}
for item in data if isinstance(data, list) else []:
if not isinstance(item, dict):
continue
model_id = str(item.get("id") or "").strip()
if model_id:
live[model_id] = item
if allowed_models:
selected_ids = [model for model in allowed_models if model in live]
else:
selected_ids = list(live)
if not selected_ids:
return _unavailable(
"openrouter",
"OpenRouter가 사용 가능한 모델을 반환하지 않았습니다.",
)
configured_default = os.environ.get("OPENROUTER_ENGINE_MODEL", "").strip()
default_model = (
configured_default
if configured_default in selected_ids
else selected_ids[0]
)
models: list[EngineModelOption] = []
for model_id in selected_ids:
entry = live[model_id]
pricing = entry.get("pricing") or {}
is_free = str(pricing.get("prompt") or "") == "0" and str(
pricing.get("completion") or ""
) == "0"
models.append(
EngineModelOption(
id=model_id,
label=str(entry.get("name") or model_id),
description=(
"OpenRouter 무료 모델입니다."
if is_free
else "OpenRouter 계정에서 현재 사용할 수 있는 모델입니다."
),
reasoning_efforts=[],
default_reasoning_effort=None,
is_default=model_id == default_model,
)
)
return EngineCapabilitiesResponse(
provider="openrouter",
available=True,
source="live_api",
models=models,
default_model=default_model,
default_reasoning_effort=None,
detail="OpenRouter /models에서 실시간 조회했습니다.",
fetched_at=_now(),
)
async def _generate_openrouter(
req: GenerateRequest,
system_prompt: str,
user_payload: str,
) -> ProviderGenerateResult:
pricing_started_at = _utcnow()
api_key = os.environ.get("OPENROUTER_API_KEY", "").strip()
if not api_key:
raise ProviderError("OPENROUTER_API_KEY가 설정되지 않았습니다.")
model, effort = await _resolve_selection(req, "openrouter")
if req.ai_role == "client":
messages: list[dict[str, str]] = [
{"role": "user", "content": user_payload}
]
else:
messages = [
{"role": message.role, "content": message.content}
for message in req.messages
if message.role != "system"
]
if system_prompt:
messages = [{"role": "system", "content": system_prompt}, *messages]
payload: dict[str, Any] = {
"model": model,
"messages": messages,
"max_tokens": req.max_tokens,
"temperature": req.temperature,
}
if effort:
payload["reasoning"] = {"effort": effort}
try:
async with httpx.AsyncClient(timeout=CLI_TIMEOUT_SECONDS) as client:
response = await client.post(
f"{OPENROUTER_API_BASE}/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json=payload,
)
response.raise_for_status()
body = response.json()
except (httpx.HTTPError, ValueError) as exc:
raise ProviderError(f"OpenRouter Chat Completions 호출 실패: {exc}") from exc
choices = body.get("choices", []) if isinstance(body, dict) else []
text = ""
if isinstance(choices, list) and choices and isinstance(choices[0], dict):
message = choices[0].get("message") or {}
if isinstance(message, dict):
text = str(message.get("content") or "").strip()
if not text:
raise ProviderError("OpenRouter가 텍스트 응답을 반환하지 않았습니다.")
usage = body.get("usage") or {}
detail = usage.get("prompt_tokens_details") or {}
estimate = estimate_reference_cost(
provider="openrouter",
model=str(body.get("model") or model),
tokens_in=int(usage.get("prompt_tokens") or 0),
tokens_out=int(usage.get("completion_tokens") or 0),
priced_at=pricing_started_at,
cached_input_tokens=int(detail.get("cached_tokens") or 0),
)
return ProviderGenerateResult(
text=text,
model=str(body.get("model") or model),
provider="openrouter",
tokens_in=int(usage.get("prompt_tokens") or 0),
tokens_out=int(usage.get("completion_tokens") or 0),
cost_usd=estimate.cost_usd if estimate is not None else 0.0,
structured=_structured_or_none(text, req),
)
async def _discover(provider: EngineProvider) -> EngineCapabilitiesResponse:
if provider == "claude_cli":
return await _discover_claude_cli()
@ -630,6 +824,8 @@ async def _discover(provider: EngineProvider) -> EngineCapabilitiesResponse:
return await _discover_claude_api()
if provider == "openai":
return await _discover_openai()
if provider == "openrouter":
return await _discover_openrouter()
if provider == "codex_cli":
return await _discover_codex_cli()
if provider == "agy_cli":
@ -942,7 +1138,9 @@ async def _generate_claude_api(
req: GenerateRequest, system_prompt: str
) -> ProviderGenerateResult:
pricing_started_at = _utcnow()
api_key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
api_key = os.environ.get("ANTHROPIC_API_KEY", "").strip() or os.environ.get(
"ANTHROPIC_AUTH_TOKEN", ""
).strip()
if not api_key:
raise ProviderError("ANTHROPIC_API_KEY가 설정되지 않았습니다.")
model, effort = await _resolve_selection(req, "claude_api")
@ -965,10 +1163,7 @@ async def _generate_claude_api(
async with httpx.AsyncClient(timeout=CLI_TIMEOUT_SECONDS) as client:
response = await client.post(
f"{ANTHROPIC_API_BASE}/v1/messages",
headers={
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
},
headers=_anthropic_headers(api_key),
json=payload,
)
response.raise_for_status()
@ -1117,6 +1312,8 @@ async def generate_with_provider(
return await _generate_claude_api(req, system_prompt)
if provider == "openai":
return await _generate_openai(req, system_prompt, user_payload)
if provider == "openrouter":
return await _generate_openrouter(req, system_prompt, user_payload)
raise ProviderError(f"이 게이트웨이에서 실행할 수 없는 provider입니다: {provider}")

View file

@ -0,0 +1,90 @@
"""게이트웨이 내부 provider 자격증명 push 엔드포인트 테스트."""
import os
import unittest
from unittest.mock import patch
from fastapi.testclient import TestClient
from engine_gateway import gateway
from engine_gateway import provider_registry
class ProviderCredentialsPushTest(unittest.TestCase):
def setUp(self):
self.client = TestClient(gateway.app)
def tearDown(self):
# 테스트가 흘려넣은 토큰을 프로세스 환경에서 회수한다.
for name in (
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"OPENAI_API_KEY",
"AGY_API_KEY",
"OPENROUTER_API_KEY",
):
os.environ.pop(name, None)
provider_registry._PROVIDER_AUTH_KINDS.clear()
def test_status_reports_boot_id_and_loaded_providers(self):
with patch.dict(os.environ, {"OPENROUTER_API_KEY": "sk-or-test-token"}, clear=False):
response = self.client.get("/internal/provider-credentials")
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertTrue(payload["ok"])
self.assertTrue(payload["boot_id"])
self.assertIn("openrouter", payload["loaded_providers"])
def test_push_claude_oauth_token_routes_to_auth_token_env(self):
response = self.client.post(
"/internal/provider-credentials",
json={
"providers": {
"claude": {"token": "oauth-token-abc", "auth_kind": "oauth_token"}
}
},
)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["applied"], ["claude"])
self.assertEqual(os.environ.get("ANTHROPIC_AUTH_TOKEN"), "oauth-token-abc")
self.assertIsNone(os.environ.get("ANTHROPIC_API_KEY"))
self.assertEqual(provider_registry.get_provider_auth_kind("claude"), "oauth_token")
def test_push_claude_api_key_removes_auth_token_env(self):
os.environ["ANTHROPIC_AUTH_TOKEN"] = "stale-oauth"
response = self.client.post(
"/internal/provider-credentials",
json={
"providers": {
"claude": {"token": "sk-ant-api03-xyz", "auth_kind": "api_key"}
}
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(os.environ.get("ANTHROPIC_API_KEY"), "sk-ant-api03-xyz")
self.assertIsNone(os.environ.get("ANTHROPIC_AUTH_TOKEN"))
def test_push_openrouter_token(self):
response = self.client.post(
"/internal/provider-credentials",
json={
"providers": {
"openrouter": {"token": "sk-or-v1-xyz", "auth_kind": "api_key"}
}
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(os.environ.get("OPENROUTER_API_KEY"), "sk-or-v1-xyz")
def test_push_unknown_provider_is_ignored(self):
response = self.client.post(
"/internal/provider-credentials",
json={"providers": {"nope": {"token": "x" * 16, "auth_kind": "api_key"}}},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["applied"], [])
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,142 @@
"""OpenRouter 어댑터와 provider 인증 헤더 판정 테스트."""
import os
import unittest
from unittest.mock import AsyncMock, patch
from app.contracts.engine_gateway import GenerateRequest, EngineMessage
from engine_gateway import provider_registry
class _FakeResponse:
def __init__(self, payload, status_code=200):
self._payload = payload
self.status_code = status_code
def raise_for_status(self):
return None
def json(self):
return self._payload
class _FakeAsyncClient:
def __init__(self, payload):
self._payload = payload
self.captured = {}
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return None
async def get(self, url, **kwargs):
self.captured = {"method": "get", "url": url, **kwargs}
return _FakeResponse(self._payload)
async def post(self, url, **kwargs):
self.captured = {"method": "post", "url": url, **kwargs}
return _FakeResponse(self._payload)
class AnthropicHeaderTest(unittest.TestCase):
def tearDown(self):
provider_registry._PROVIDER_AUTH_KINDS.clear()
def test_api_key_uses_x_api_key(self):
headers = provider_registry._anthropic_headers("sk-ant-api03-x")
self.assertEqual(headers["x-api-key"], "sk-ant-api03-x")
self.assertNotIn("Authorization", headers)
def test_oauth_token_uses_bearer(self):
provider_registry.set_provider_auth_kind("claude", "oauth_token")
headers = provider_registry._anthropic_headers("oauth-token")
self.assertEqual(headers["Authorization"], "Bearer oauth-token")
self.assertNotIn("x-api-key", headers)
class DiscoverOpenRouterTest(unittest.IsolatedAsyncioTestCase):
async def test_live_models_without_allowlist(self):
fake = _FakeAsyncClient(
{
"data": [
{
"id": "anthropic/claude-sonnet-4.5",
"name": "Claude Sonnet 4.5",
"pricing": {"prompt": "0.000003", "completion": "0.000015"},
},
{
"id": "meta-llama/free-8b",
"name": "Free 8B",
"pricing": {"prompt": "0", "completion": "0"},
},
]
}
)
with patch.dict(os.environ, {"OPENROUTER_API_KEY": "sk-or-key"}, clear=False):
with patch.object(provider_registry.httpx, "AsyncClient", return_value=fake):
result = await provider_registry._discover_openrouter()
self.assertTrue(result.available)
self.assertEqual(result.provider, "openrouter")
self.assertEqual(result.source, "live_api")
self.assertEqual(result.default_model, "anthropic/claude-sonnet-4.5")
self.assertIn("Bearer sk-or-key", fake.captured["headers"]["Authorization"])
free = next(m for m in result.models if m.id == "meta-llama/free-8b")
self.assertIn("무료", free.description)
async def test_missing_key_is_unavailable(self):
with patch.dict(os.environ, {"OPENROUTER_API_KEY": ""}, clear=False):
result = await provider_registry._discover_openrouter()
self.assertFalse(result.available)
async def test_allowlist_must_use_vendor_slash_model(self):
with patch.dict(
os.environ,
{"OPENROUTER_API_KEY": "sk-or-key", "OPENROUTER_ENGINE_MODELS": "gpt-4o"},
clear=False,
):
result = await provider_registry._discover_openrouter()
self.assertFalse(result.available)
self.assertIn("vendor/model", result.detail)
class GenerateOpenRouterTest(unittest.IsolatedAsyncioTestCase):
async def test_chat_completions_roundtrip(self):
fake = _FakeAsyncClient(
{
"model": "anthropic/claude-sonnet-4.5",
"choices": [
{"message": {"role": "assistant", "content": "안녕하세요."}}
],
"usage": {"prompt_tokens": 12, "completion_tokens": 5},
}
)
req = GenerateRequest(
ai_role="client",
messages=[EngineMessage(role="user", content="hi")],
provider="openrouter",
model="anthropic/claude-sonnet-4.5",
)
with patch.dict(os.environ, {"OPENROUTER_API_KEY": "sk-or-key"}, clear=False):
with patch.object(provider_registry.httpx, "AsyncClient", return_value=fake):
with patch.object(
provider_registry,
"_resolve_selection",
AsyncMock(return_value=("anthropic/claude-sonnet-4.5", None)),
):
result = await provider_registry.generate_with_provider(
req, system_prompt="sys", user_payload="사용자 입력"
)
self.assertEqual(result.provider, "openrouter")
self.assertEqual(result.text, "안녕하세요.")
self.assertEqual(result.tokens_in, 12)
self.assertEqual(result.tokens_out, 5)
self.assertTrue(fake.captured["url"].endswith("/chat/completions"))
payload = fake.captured["json"]
self.assertEqual(payload["messages"][0]["role"], "system")
self.assertEqual(payload["messages"][1]["content"], "사용자 입력")
if __name__ == "__main__":
unittest.main()