From 4b45d331423f0cd9913e0659ceeecb66d6eb7610 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Fri, 11 Sep 2026 14:06:15 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20AI=20=EC=A0=9C?= =?UTF-8?q?=EA=B3=B5=EC=9E=90=20=EC=97=B0=EA=B2=B0=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(claude=C2=B7codex=C2=B7agy=C2=B7openroute?= =?UTF-8?q?r=20=ED=86=A0=ED=81=B0=20=EB=B6=99=EC=97=AC=EB=84=A3=EA=B8=B0?= =?UTF-8?q?=20=EC=97=B0=EA=B2=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 /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) --- .env.example | 3 + apps/api/app/auth_sessions.py | 16 + apps/api/app/config.py | 6 + apps/api/app/contracts/engine_gateway.py | 3 + apps/api/app/engine_client.py | 29 ++ apps/api/app/routes/admin.py | 187 ++++++- apps/api/app/services/provider_credentials.py | 474 ++++++++++++++++++ .../app/test_provider_credentials_service.py | 77 +++ apps/api/engine_gateway/gateway.py | 72 +++ .../golden/engine_gateway_schema.v1.json | 1 + apps/api/engine_gateway/provider_registry.py | 217 +++++++- .../test_provider_credentials_push.py | 90 ++++ .../test_provider_registry_openrouter.py | 142 ++++++ apps/web/src/lib/api.gen.ts | 298 ++++++++++- apps/web/src/lib/api.ts | 28 ++ apps/web/src/pages/AdminAi.tsx | 4 + apps/web/src/pages/Settings.tsx | 2 + .../pages/admin/ProviderConnectionsPanel.tsx | 310 ++++++++++++ apps/web/src/pages/admin/admin-ai.css | 136 +++++ infra/.env.example | 2 + infra/docker-compose.nas.yml | 2 + infra/docker-compose.yml | 2 + 22 files changed, 2088 insertions(+), 13 deletions(-) create mode 100644 apps/api/app/services/provider_credentials.py create mode 100644 apps/api/app/test_provider_credentials_service.py create mode 100644 apps/api/engine_gateway/test_provider_credentials_push.py create mode 100644 apps/api/engine_gateway/test_provider_registry_openrouter.py create mode 100644 apps/web/src/pages/admin/ProviderConnectionsPanel.tsx diff --git a/.env.example b/.env.example index 9d8266a..cb5a5e5 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,9 @@ ANTHROPIC_API_KEY= ANTHROPIC_API_BASE=https://api.anthropic.com OPENAI_API_KEY= OPENAI_BASE_URL=https://api.openai.com/v1 +# 관리자 연결 UI(/admin/ai)에서 저장한 provider 토큰을 DB에 암호화할 마스터 키. +# 비우면 SESSION_SECRET에서 파생한다. 운영에서는 32자 이상 독립 값을 권장한다. +PROVIDER_CREDENTIAL_SECRET= VIGNETTE_VOICE_POC_SAMPLE_TTS=false VIGNETTE_VOICE_POC_SAMPLE_TTS_DIR= # 로컬 연구/개발 전용. Higgs v3 서버는 scripts/start-higgs-tts.ps1 로 127.0.0.1:9881에 띄운다. diff --git a/apps/api/app/auth_sessions.py b/apps/api/app/auth_sessions.py index 054f2d0..f9b2a14 100644 --- a/apps/api/app/auth_sessions.py +++ b/apps/api/app/auth_sessions.py @@ -754,6 +754,22 @@ async def ensure_runtime_tables() -> None: ADD COLUMN IF NOT EXISTS reasoning_effort TEXT """ ) + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS app.admin_provider_credential ( + provider TEXT PRIMARY KEY, + token_encrypted TEXT NOT NULL, + token_hint TEXT NOT NULL DEFAULT '', + auth_kind TEXT NOT NULL DEFAULT 'api_key' + CHECK (auth_kind IN ('api_key','oauth_token')), + updated_by TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_verified_at TIMESTAMPTZ, + last_verify_ok BOOLEAN, + last_verify_error TEXT + ) + """ + ) await conn.execute( """ CREATE TABLE IF NOT EXISTS app.admin_health_event ( diff --git a/apps/api/app/config.py b/apps/api/app/config.py index 50e1395..a4e1650 100644 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -145,6 +145,12 @@ class Settings(BaseSettings): default="https://api.openai.com/v1", validation_alias="OPENAI_BASE_URL", ) + # 관리자가 연결한 provider 토큰을 DB에 담을 때 쓰는 대칭 암호화 마스터 키. + # 비우면 SESSION_SECRET에서 파생한다(SESSION_SECRET이 dev 기본값이면 dev에서만 허용). + provider_credential_secret: SecretStr = Field( + default=SecretStr(""), + validation_alias="PROVIDER_CREDENTIAL_SECRET", + ) # `local_whisper`는 노트북 상주 faster-whisper 사이드카(scripts/local-whisper-stt-server.py)를 # 쓴다. 오디오가 호스트를 벗어나지 않고 외부 STT 키가 필요 없다. voice_stt_provider: Literal["openai", "deepgram", "local_whisper"] = Field( diff --git a/apps/api/app/contracts/engine_gateway.py b/apps/api/app/contracts/engine_gateway.py index bf475f2..83fe6ff 100644 --- a/apps/api/app/contracts/engine_gateway.py +++ b/apps/api/app/contracts/engine_gateway.py @@ -21,6 +21,7 @@ EngineProvider = Literal[ "codex_cli", "agy_cli", "openai", + "openrouter", "solar", ] ReasoningEffort = Literal["low", "medium", "high", "xhigh", "max", "ultra"] @@ -41,6 +42,7 @@ ENGINE_PROVIDERS: tuple[EngineProvider, ...] = ( "codex_cli", "agy_cli", "openai", + "openrouter", "solar", ) ENGINE_REASONING_EFFORTS: tuple[ReasoningEffort, ...] = ( @@ -57,6 +59,7 @@ ENGINE_PROVIDER_DEFAULTS: dict[EngineProvider, tuple[str, Optional[ReasoningEffo "codex_cli": ("gpt-5.6-terra", "medium"), "agy_cli": ("gemini-3.6-flash-high", "high"), "openai": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None), + "openrouter": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None), "solar": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None), } diff --git a/apps/api/app/engine_client.py b/apps/api/app/engine_client.py index 6f4825a..439585a 100644 --- a/apps/api/app/engine_client.py +++ b/apps/api/app/engine_client.py @@ -335,6 +335,35 @@ class EngineClient: # DB 회기 종료 성공을 게이트웨이 정리 실패 때문에 되돌리지는 않는다. return False + async def provider_credentials_status(self) -> dict[str, Any]: + """게이트웨이 프로세스에 주입된 provider 자격증명 상태(boot_id 포함).""" + try: + response = await self.client.get("/internal/provider-credentials") + response.raise_for_status() + payload = response.json() + return payload if isinstance(payload, dict) else {} + except (httpx.HTTPError, ValueError) as exc: + raise EngineError(f"provider credentials status unavailable: {exc}") from exc + + async def push_provider_credentials( + self, providers: dict[str, dict[str, str]] + ) -> dict[str, Any]: + """관리자가 저장한 provider 토큰을 게이트웨이 프로세스 환경에 주입한다.""" + try: + response = await self.client.post( + "/internal/provider-credentials", + json={"providers": providers}, + ) + response.raise_for_status() + payload = response.json() + return payload if isinstance(payload, dict) else {} + except httpx.HTTPStatusError as exc: + raise EngineError( + f"provider credentials push {exc.response.status_code}: {exc.response.text}" + ) from exc + except (httpx.HTTPError, ValueError) as exc: + raise EngineError(f"provider credentials push unavailable: {exc}") from exc + # 앱 전역 싱글톤 (main lifespan 에서 startup/shutdown) engine_client = EngineClient() diff --git a/apps/api/app/routes/admin.py b/apps/api/app/routes/admin.py index dd1a191..e936c07 100644 --- a/apps/api/app/routes/admin.py +++ b/apps/api/app/routes/admin.py @@ -5,7 +5,7 @@ from __future__ import annotations import time from datetime import datetime, timezone from decimal import Decimal -from typing import Annotated, Iterable, Literal, cast +from typing import Annotated, Any, Iterable, Literal, cast from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, status @@ -38,6 +38,7 @@ from ..deps import Principal, require_admin_access from ..engine_client import engine_client from ..runtime_policy import require_runtime_fallback_allowed from ..services import evaluator, notifications, rag +from ..services import provider_credentials as provider_credentials_service from ..services.llm_pricing import ( estimate_reference_cost, provider_uses_reference_cost, @@ -327,6 +328,62 @@ class AdminEngineConfigPatch(BaseModel): reasoning_effort: str | None = None +class AdminProviderGatewaySync(BaseModel): + synced: bool + boot_id: str | None = None + detail: str | None = None + + +class AdminProviderCredentialStatus(BaseModel): + provider: str + label: str + description: str + connected: bool + token_hint: str | None = None + auth_kind: str | None = None + updated_by: str | None = None + updated_at: float | None = None + last_verified_at: float | None = None + last_verify_ok: bool | None = None + last_verify_error: str | None = None + token_page_url: str + token_page_label: str + guide: str + allowed_auth_kinds: list[str] + + +class AdminProvidersResponse(BaseModel): + providers: list[AdminProviderCredentialStatus] + gateway_sync: AdminProviderGatewaySync + + +class AdminProviderCredentialRequest(BaseModel): + token: str = Field(min_length=8, max_length=8192) + auth_kind: Literal["api_key", "oauth_token"] = "api_key" + + +class AdminProviderCredentialResponse(BaseModel): + provider: str + token_hint: str + auth_kind: str + updated_by: str | None = None + updated_at: float | None = None + gateway_sync: AdminProviderGatewaySync + + +class AdminProviderDeleteResponse(BaseModel): + ok: bool + provider: str + gateway_sync: AdminProviderGatewaySync + + +class AdminProviderVerifyResponse(BaseModel): + provider: str + ok: bool + detail: str + verified_at: float | None = None + + class AdminTicketPatch(BaseModel): status: TicketStatus | None = None priority: TicketPriority | None = None @@ -2302,6 +2359,134 @@ async def patch_engine_config( return next_config +def _provider_sync_response(result: dict[str, Any]) -> AdminProviderGatewaySync: + return AdminProviderGatewaySync( + synced=bool(result.get("synced")), + boot_id=result.get("boot_id"), + detail=result.get("detail"), + ) + + +@router.get("/providers", response_model=AdminProvidersResponse) +async def list_providers(principal: AdminPrincipal) -> AdminProvidersResponse: + """연결 가능한 AI provider 목록과 자격증명 상태를 반환한다.""" + sync = await provider_credentials_service.ensure_gateway_synced() + statuses: list[AdminProviderCredentialStatus] = [] + for meta in provider_credentials_service.PROVIDER_CATALOG: + stored = await provider_credentials_service.get_credential_row(meta.code) + statuses.append( + AdminProviderCredentialStatus( + provider=meta.code, + label=meta.label, + description=meta.description, + connected=stored is not None, + token_hint=stored.token_hint if stored else None, + auth_kind=stored.auth_kind if stored else None, + updated_by=stored.updated_by if stored else None, + updated_at=stored.updated_at if stored else None, + last_verified_at=stored.last_verified_at if stored else None, + last_verify_ok=stored.last_verify_ok if stored else None, + last_verify_error=stored.last_verify_error if stored else None, + token_page_url=meta.token_page_url, + token_page_label=meta.token_page_label, + guide=meta.guide, + allowed_auth_kinds=list(meta.auth_kinds), + ) + ) + return AdminProvidersResponse( + providers=statuses, + gateway_sync=_provider_sync_response(sync), + ) + + +@router.put( + "/providers/{provider}/credential", + response_model=AdminProviderCredentialResponse, +) +async def put_provider_credential( + provider: str, + body: AdminProviderCredentialRequest, + principal: AdminPrincipal, +) -> AdminProviderCredentialResponse: + """사용자가 발급받아 붙여넣은 provider 토큰을 암호화 저장하고 게이트웨이에 주입한다.""" + meta = provider_credentials_service.provider_meta(provider) + if meta is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail=f"지원하지 않는 provider입니다: {provider}", + ) + if body.auth_kind not in meta.auth_kinds: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"{meta.label}은 {body.auth_kind} 형식을 지원하지 않습니다.", + ) + try: + stored = await provider_credentials_service.save_credential( + provider=provider, + token=body.token, + auth_kind=body.auth_kind, + updated_by=principal.email, + ) + sync = await provider_credentials_service.push_credentials_to_gateway() + except provider_credentials_service.ProviderCredentialError as exc: + raise HTTPException( + status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) from exc + return AdminProviderCredentialResponse( + provider=stored.provider, + token_hint=stored.token_hint, + auth_kind=stored.auth_kind, + updated_by=stored.updated_by, + updated_at=stored.updated_at, + gateway_sync=_provider_sync_response(sync), + ) + + +@router.delete( + "/providers/{provider}/credential", + response_model=AdminProviderDeleteResponse, +) +async def delete_provider_credential( + provider: str, + principal: AdminPrincipal, +) -> AdminProviderDeleteResponse: + """저장된 provider 자격증명을 삭제하고 게이트웨이 주입 상태를 재동기화한다.""" + if provider not in provider_credentials_service.PROVIDER_CODES: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail=f"지원하지 않는 provider입니다: {provider}", + ) + deleted = await provider_credentials_service.delete_credential(provider) + sync = await provider_credentials_service.push_credentials_to_gateway() + return AdminProviderDeleteResponse( + ok=deleted, + provider=provider, + gateway_sync=_provider_sync_response(sync), + ) + + +@router.post("/providers/{provider}/verify", response_model=AdminProviderVerifyResponse) +async def verify_provider_credential( + provider: str, + principal: AdminPrincipal, +) -> AdminProviderVerifyResponse: + """저장된 토큰으로 provider에 실제 요청을 보내 연결을 확인한다.""" + if provider not in provider_credentials_service.PROVIDER_CODES: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail=f"지원하지 않는 provider입니다: {provider}", + ) + result = await provider_credentials_service.verify_provider(provider) + verified_at = result.get("verified_at") + return AdminProviderVerifyResponse( + provider=provider, + ok=bool(result.get("ok")), + detail=str(result.get("detail") or ("연결을 확인했습니다." if result.get("ok") else "")), + verified_at=float(verified_at) if verified_at is not None else None, + ) + + @router.get("/users", response_model=AdminUsersResponse) async def list_users(principal: AdminPrincipal) -> AdminUsersResponse: """Return users observed by the server-side auth/session boundary.""" diff --git a/apps/api/app/services/provider_credentials.py b/apps/api/app/services/provider_credentials.py new file mode 100644 index 0000000..33b0f84 --- /dev/null +++ b/apps/api/app/services/provider_credentials.py @@ -0,0 +1,474 @@ +"""관리자 연결 AI provider 자격증명 저장소. + +관리자가 연결 UI에서 붙여넣은 provider 토큰(OAuth 액세스 토큰 또는 API 키)을 +DB(``app.admin_provider_credential``)에 암호화 저장하고, 엔진 게이트웨이 프로세스에 +shared-secret으로 보호되는 내부 엔드포인트로 push해 ``os.environ``에 주입한다. + +- 암호화는 표준 라이브러리만으로 구성한다(HMAC-SHA256 CTR 스트림 + encrypt-then-MAC). + 마스터 키는 ``PROVIDER_CREDENTIAL_SECRET``, 비어 있으면 ``SESSION_SECRET``에서 파생한다. +- 토큰 원문은 로그·응답에 절대 내보내지 않는다. 마지막 4글자 힌트만 남긴다. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import os +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Literal + +import httpx + +from ..config import settings +from ..db import get_pool +from ..engine_client import EngineError, engine_client + +PROVIDER_CODES = ("claude", "codex", "agy", "openrouter") +ProviderCode = Literal["claude", "codex", "agy", "openrouter"] + +_TOKEN_ENVELOPE_PREFIX = "v1." +_KEY_SIZE = 32 +_NONCE_SIZE = 16 +_MAC_SIZE = 32 +_HKDF_INFO_ENC = b"vignette/provider-credential/enc" +_HKDF_INFO_MAC = b"vignette/provider-credential/mac" + + +@dataclass(frozen=True, slots=True) +class ProviderMeta: + code: ProviderCode + label: str + description: str + token_page_url: str + token_page_label: str + auth_kinds: tuple[Literal["api_key", "oauth_token"], ...] + guide: str + + +PROVIDER_CATALOG: tuple[ProviderMeta, ...] = ( + ProviderMeta( + code="claude", + label="Claude (Anthropic)", + description=( + "claude_api 엔진과 Claude CLI에 쓰는 Anthropic 자격증명입니다. " + "OAuth 액세스 토큰은 Authorization Bearer로, API 키는 x-api-key로 전송됩니다." + ), + token_page_url="https://console.anthropic.com/settings/keys", + token_page_label="Anthropic 콘솔 · API 키", + auth_kinds=("api_key", "oauth_token"), + guide=( + "콘솔에서 로그인 뒤 API 키를 발급해 붙여넣거나, " + "Claude CLI OAuth 토큰(claude setup-token 출력값)을 붙여넣으세요." + ), + ), + ProviderMeta( + code="codex", + label="Codex / OpenAI", + description=( + "codex_cli 엔진과 OpenAI 호환 엔진에 쓰는 API 토큰입니다. " + "게이트웨이가 OPENAI_API_KEY로 주입합니다." + ), + token_page_url="https://platform.openai.com/api-keys", + token_page_label="OpenAI 플랫폼 · API 키", + auth_kinds=("api_key",), + guide=( + "플랫폼에 로그인해 API 키를 발급한 뒤 붙여넣으세요. " + "ChatGPT OAuth 토큰은 Codex CLI 로그인으로도 대체할 수 있습니다." + ), + ), + ProviderMeta( + code="agy", + label="Agy CLI", + description=( + "agy_cli 엔진이 사용하는 CLI 계정 토큰입니다. " + "게이트웨이가 AGY_API_KEY로 자식 프로세스에 주입합니다." + ), + token_page_url="", + token_page_label="Agy 계정 로그인", + auth_kinds=("api_key", "oauth_token"), + guide=( + "Agy 계정에 로그인해 발급받은 접근 토큰을 붙여넣으세요. " + "실제 가용 모델은 게이트웨이의 agy models 조회로 확인됩니다." + ), + ), + ProviderMeta( + code="openrouter", + label="OpenRouter", + description=( + "OpenRouter를 통해 다중 provider 모델을 쓰는 엔진입니다. " + "게이트웨이가 OPENROUTER_API_KEY로 주입합니다." + ), + token_page_url="https://openrouter.ai/keys", + token_page_label="OpenRouter · API 키", + auth_kinds=("api_key",), + guide=( + "OpenRouter에 로그인해 API 키를 발급한 뒤 붙여넣으세요. " + "키에 연결된 계정 크레딧과 모델 목록이 자동 확인됩니다." + ), + ), +) + + +def provider_meta(provider: str) -> ProviderMeta | None: + for meta in PROVIDER_CATALOG: + if meta.code == provider: + return meta + return None + + +class ProviderCredentialError(RuntimeError): + """자격증명 저장·검증 실패. 원문 토큰을 포함하지 않는다.""" + + +def _master_secret() -> bytes: + configured = settings.provider_credential_secret.get_secret_value().strip() + if configured: + if len(configured) < 16: + raise ProviderCredentialError( + "PROVIDER_CREDENTIAL_SECRET은 16자 이상이어야 합니다." + ) + return configured.encode("utf-8") + session_secret = settings.session_secret + if session_secret == "dev-insecure-change-me": + # dev 기본 시크릿은 prod 기동 가드에서 이미 막힌다. dev 편의를 위해 허용. + return ("dev::" + session_secret).encode("utf-8") + if not session_secret or len(session_secret) < 16: + raise ProviderCredentialError( + "자격증명 암호화 키가 없습니다. PROVIDER_CREDENTIAL_SECRET 또는 SESSION_SECRET을 설정하세요." + ) + return session_secret.encode("utf-8") + + +def _derived_key(info: bytes) -> bytes: + return hmac.new(_master_secret(), info, hashlib.sha256).digest() + + +def encrypt_token(plain: str) -> str: + """HMAC-SHA256 CTR 스트림 + encrypt-then-MAC 봉투(v1).""" + data = plain.encode("utf-8") + nonce = os.urandom(_NONCE_SIZE) + enc_key = _derived_key(_HKDF_INFO_ENC) + mac_key = _derived_key(_HKDF_INFO_MAC) + + stream = bytearray() + counter = 0 + while len(stream) < len(data): + stream.extend( + hashlib.sha256( + enc_key + nonce + counter.to_bytes(4, "big") + ).digest() + ) + counter += 1 + ciphertext = bytes(a ^ b for a, b in zip(data, stream)) + tag = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()[:_MAC_SIZE] + blob = nonce + ciphertext + tag + return _TOKEN_ENVELOPE_PREFIX + base64.urlsafe_b64encode(blob).decode("ascii") + + +def decrypt_token(envelope: str) -> str: + raw = envelope.removeprefix(_TOKEN_ENVELOPE_PREFIX) + try: + blob = base64.urlsafe_b64decode(raw.encode("ascii")) + except (ValueError, UnicodeEncodeError) as exc: + raise ProviderCredentialError("자격증명 봉투가 손상되었습니다.") from exc + if len(blob) < _NONCE_SIZE + _MAC_SIZE + 1: + raise ProviderCredentialError("자격증명 봉투가 손상되었습니다.") + nonce = blob[:_NONCE_SIZE] + ciphertext = blob[_NONCE_SIZE:-_MAC_SIZE] + tag = blob[-_MAC_SIZE:] + mac_key = _derived_key(_HKDF_INFO_MAC) + expected = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()[:_MAC_SIZE] + if not hmac.compare_digest(tag, expected): + raise ProviderCredentialError("자격증명 봉투 인증에 실패했습니다.") + enc_key = _derived_key(_HKDF_INFO_ENC) + stream = bytearray() + counter = 0 + while len(stream) < len(ciphertext): + stream.extend( + hashlib.sha256( + enc_key + nonce + counter.to_bytes(4, "big") + ).digest() + ) + counter += 1 + return bytes(a ^ b for a, b in zip(ciphertext, stream)).decode("utf-8") + + +def token_hint(plain: str) -> str: + tail = plain[-4:] if len(plain) >= 8 else "" + return f"…{tail}" if tail else "저장됨" + + +def _row_ts(value: Any) -> float | None: + if value is None: + return None + if isinstance(value, datetime): + value = value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value + return value.timestamp() + try: + return float(value) + except (TypeError, ValueError): + return None + + +@dataclass(frozen=True, slots=True) +class StoredCredential: + provider: str + auth_kind: str + token_hint: str + updated_by: str | None + updated_at: float | None + last_verified_at: float | None + last_verify_ok: bool | None + last_verify_error: str | None + + +async def ensure_table() -> None: + pool = get_pool() + async with pool.acquire() as conn: + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS app.admin_provider_credential ( + provider TEXT PRIMARY KEY, + token_encrypted TEXT NOT NULL, + token_hint TEXT NOT NULL DEFAULT '', + auth_kind TEXT NOT NULL DEFAULT 'api_key' + CHECK (auth_kind IN ('api_key','oauth_token')), + updated_by TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_verified_at TIMESTAMPTZ, + last_verify_ok BOOLEAN, + last_verify_error TEXT + ) + """ + ) + + +async def get_credential_row(provider: str) -> StoredCredential | None: + await ensure_table() + pool = get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT provider, token_hint, auth_kind, updated_by, updated_at, + last_verified_at, last_verify_ok, last_verify_error + FROM app.admin_provider_credential + WHERE provider = $1 + """, + provider, + ) + if row is None: + return None + return StoredCredential( + provider=row["provider"], + auth_kind=row["auth_kind"], + token_hint=row["token_hint"] or "저장됨", + updated_by=row["updated_by"], + updated_at=_row_ts(row["updated_at"]), + last_verified_at=_row_ts(row["last_verified_at"]), + last_verify_ok=row["last_verify_ok"], + last_verify_error=row["last_verify_error"], + ) + + +async def get_plain_token(provider: str) -> tuple[str, str] | None: + """저장된 토큰 원문과 auth_kind를 반환한다. 없으면 None.""" + await ensure_table() + pool = get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT token_encrypted, auth_kind FROM app.admin_provider_credential WHERE provider = $1", + provider, + ) + if row is None: + return None + return decrypt_token(row["token_encrypted"]), row["auth_kind"] + + +async def save_credential( + *, provider: str, token: str, auth_kind: str, updated_by: str +) -> StoredCredential: + if provider not in PROVIDER_CODES: + raise ProviderCredentialError(f"지원하지 않는 provider입니다: {provider}") + if auth_kind not in {"api_key", "oauth_token"}: + raise ProviderCredentialError("auth_kind는 api_key 또는 oauth_token이어야 합니다.") + plain = token.strip() + if not plain: + raise ProviderCredentialError("토큰이 비어 있습니다.") + await ensure_table() + pool = get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + """ + INSERT INTO app.admin_provider_credential ( + provider, token_encrypted, token_hint, auth_kind, updated_by, updated_at + ) + VALUES ($1, $2, $3, $4, $5, now()) + ON CONFLICT (provider) DO UPDATE SET + token_encrypted = EXCLUDED.token_encrypted, + token_hint = EXCLUDED.token_hint, + auth_kind = EXCLUDED.auth_kind, + updated_by = EXCLUDED.updated_by, + updated_at = now(), + last_verified_at = NULL, + last_verify_ok = NULL, + last_verify_error = NULL + RETURNING provider, token_hint, auth_kind, updated_by, updated_at, + last_verified_at, last_verify_ok, last_verify_error + """, + provider, + encrypt_token(plain), + token_hint(plain), + auth_kind, + updated_by, + ) + return StoredCredential( + provider=row["provider"], + auth_kind=row["auth_kind"], + token_hint=row["token_hint"] or "저장됨", + updated_by=row["updated_by"], + updated_at=_row_ts(row["updated_at"]), + last_verified_at=_row_ts(row["last_verified_at"]), + last_verify_ok=row["last_verify_ok"], + last_verify_error=row["last_verify_error"], + ) + + +async def delete_credential(provider: str) -> bool: + await ensure_table() + pool = get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + "DELETE FROM app.admin_provider_credential WHERE provider = $1 RETURNING provider", + provider, + ) + return row is not None + + +async def mark_verified(provider: str, *, ok: bool, error: str | None) -> None: + await ensure_table() + pool = get_pool() + async with pool.acquire() as conn: + await conn.execute( + """ + UPDATE app.admin_provider_credential + SET last_verified_at = now(), last_verify_ok = $2, last_verify_error = $3 + WHERE provider = $1 + """, + provider, + ok, + (error or "")[:500] if error else None, + ) + + +# ── 게이트웨이 push 동기화 ──────────────────────────────────────────── + +_synced_boot_id: str | None = None +_synced_detail: str | None = None + + +def last_sync_state() -> tuple[str | None, str | None]: + return _synced_boot_id, _synced_detail + + +async def push_credentials_to_gateway() -> dict[str, Any]: + """DB에 저장된 전체 자격증명을 게이트웨이 프로세스 환경으로 push한다. + + 실패해도 예외로 승격하지 않고 상태를 돌려준다 — 게이트웨이가 아직 안 떠 있어도 + 저장 자체는 성공해야 하고, 다음 동기화 기회(boot_id 불일치)에 다시 push된다. + """ + global _synced_boot_id, _synced_detail + await ensure_table() + pool = get_pool() + async with pool.acquire() as conn: + rows = await conn.fetch( + "SELECT provider, token_encrypted, auth_kind FROM app.admin_provider_credential" + ) + providers = { + row["provider"]: { + "token": decrypt_token(row["token_encrypted"]), + "auth_kind": row["auth_kind"], + } + for row in rows + if row["provider"] in PROVIDER_CODES + } + + if not providers: + _synced_boot_id = None + _synced_detail = "저장된 자격증명이 없습니다." + return {"synced": False, "detail": _synced_detail} + + try: + result = await engine_client.push_provider_credentials(providers) + status = await engine_client.provider_credentials_status() + _synced_boot_id = str(status.get("boot_id") or result.get("boot_id") or "") + _synced_detail = f"pushed: {','.join(result.get('applied') or [])}" + return {"synced": True, "boot_id": _synced_boot_id, "applied": result.get("applied") or []} + except EngineError as exc: + _synced_detail = str(exc) + return {"synced": False, "detail": _synced_detail} + + +async def ensure_gateway_synced() -> dict[str, Any]: + """게이트웨이가 재시작(boot_id 변경)했으면 저장된 자격증명을 재-push한다.""" + global _synced_boot_id, _synced_detail + try: + status = await engine_client.provider_credentials_status() + except EngineError as exc: + return {"synced": False, "detail": str(exc)} + boot_id = str(status.get("boot_id") or "") + if boot_id and boot_id == _synced_boot_id: + return {"synced": True, "boot_id": boot_id} + return await push_credentials_to_gateway() + + +# ── 연결 검증 ──────────────────────────────────────────────────────── + +_ANTHROPIC_BASE = os.environ.get("ANTHROPIC_API_BASE", "https://api.anthropic.com").rstrip("/") +_OPENAI_BASE = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1").rstrip("/") +_OPENROUTER_BASE = os.environ.get("OPENROUTER_API_BASE", "https://openrouter.ai/api/v1").rstrip("/") + + +async def verify_provider(provider: str) -> dict[str, Any]: + """저장된 토큰으로 provider에 실제 요청을 보내 연결을 확인한다.""" + stored = await get_plain_token(provider) + if stored is None: + return {"ok": False, "detail": "저장된 자격증명이 없습니다."} + token, auth_kind = stored + ok = False + detail = "" + try: + if provider == "claude": + headers = ( + {"Authorization": f"Bearer {token}", "anthropic-version": "2023-06-01"} + if auth_kind == "oauth_token" + else {"x-api-key": token, "anthropic-version": "2023-06-01"} + ) + async with httpx.AsyncClient(timeout=20) as client: + response = await client.get(f"{_ANTHROPIC_BASE}/v1/models", params={"limit": 1}, headers=headers) + ok = response.status_code == 200 + detail = "" if ok else f"Anthropic 응답 {response.status_code}" + elif provider == "codex": + async with httpx.AsyncClient(timeout=20) as client: + response = await client.get(f"{_OPENAI_BASE}/models", headers={"Authorization": f"Bearer {token}"}) + ok = response.status_code == 200 + detail = "" if ok else f"OpenAI 응답 {response.status_code}" + elif provider == "openrouter": + async with httpx.AsyncClient(timeout=20) as client: + response = await client.get(f"{_OPENROUTER_BASE}/key", headers={"Authorization": f"Bearer {token}"}) + ok = response.status_code == 200 + detail = "" if ok else f"OpenRouter 응답 {response.status_code}" + elif provider == "agy": + # Agy CLI 계정 인증은 게이트웨이의 agy models 조회(ready)가 담당한다. + # 여기서는 토큰 형식 보전 여부만 확인한다. + ok = len(token) >= 8 + detail = "" if ok else "토큰이 너무 짧습니다." + else: + detail = f"지원하지 않는 provider입니다: {provider}" + except httpx.HTTPError as exc: + detail = f"연결 확인 요청 실패: {exc}" + + await mark_verified(provider, ok=ok, error=None if ok else detail) + return {"ok": ok, "detail": detail, "verified_at": time.time()} diff --git a/apps/api/app/test_provider_credentials_service.py b/apps/api/app/test_provider_credentials_service.py new file mode 100644 index 0000000..559a762 --- /dev/null +++ b/apps/api/app/test_provider_credentials_service.py @@ -0,0 +1,77 @@ +"""provider 자격증명 서비스(암호화·카탈로그) 단위 테스트.""" + +import unittest +from unittest.mock import patch + +from pydantic import SecretStr + +from app.services import provider_credentials as svc + + +class TokenEncryptionTest(unittest.TestCase): + def setUp(self): + patcher = patch.object( + svc.settings, + "provider_credential_secret", + SecretStr("unit-test-master-secret-0123456789"), + ) + patcher.start() + self.addCleanup(patcher.stop) + + def test_roundtrip(self): + plain = "sk-or-v1-아주-긴-토큰-abc123!@#" + envelope = svc.encrypt_token(plain) + self.assertTrue(envelope.startswith("v1.")) + self.assertNotIn(plain, envelope) + self.assertEqual(svc.decrypt_token(envelope), plain) + + def test_unique_nonce_produces_unique_ciphertext(self): + first = svc.encrypt_token("same-token") + second = svc.encrypt_token("same-token") + self.assertNotEqual(first, second) + + def test_tampered_envelope_rejected(self): + envelope = svc.encrypt_token("secret-token") + raw = bytearray(envelope.encode("ascii")) + raw[-1] = raw[-1] # 정상 + corrupted = (envelope[:-1] + ("A" if envelope[-1] != "A" else "B")) + with self.assertRaises(svc.ProviderCredentialError): + svc.decrypt_token(corrupted) + + def test_master_secret_falls_back_to_session_secret(self): + with patch.object(svc.settings, "provider_credential_secret", SecretStr("")), patch.object( + svc.settings, "session_secret", "session-secret-long-enough-for-prod-1234" + ): + self.assertEqual(svc._master_secret(), b"session-secret-long-enough-for-prod-1234") + + def test_short_master_secret_rejected(self): + with patch.object(svc.settings, "provider_credential_secret", SecretStr("short")): + with self.assertRaises(svc.ProviderCredentialError): + svc._master_secret() + + +class TokenHintTest(unittest.TestCase): + def test_long_token_tail_hint(self): + self.assertEqual(svc.token_hint("sk-1234567890abcdef"), "…cdef") + + def test_short_token_generic_hint(self): + self.assertEqual(svc.token_hint("short"), "저장됨") + + +class CatalogTest(unittest.TestCase): + def test_catalog_covers_provider_codes(self): + self.assertEqual( + tuple(meta.code for meta in svc.PROVIDER_CATALOG), + svc.PROVIDER_CODES, + ) + + def test_openrouter_and_agy_present(self): + codes = set(svc.PROVIDER_CODES) + self.assertIn("openrouter", codes) + self.assertIn("agy", codes) + self.assertIn("claude", codes) + self.assertIn("codex", codes) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/api/engine_gateway/gateway.py b/apps/api/engine_gateway/gateway.py index f9d931c..138adcc 100644 --- a/apps/api/engine_gateway/gateway.py +++ b/apps/api/engine_gateway/gateway.py @@ -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): diff --git a/apps/api/engine_gateway/golden/engine_gateway_schema.v1.json b/apps/api/engine_gateway/golden/engine_gateway_schema.v1.json index 90aaad1..b3d9ab9 100644 --- a/apps/api/engine_gateway/golden/engine_gateway_schema.v1.json +++ b/apps/api/engine_gateway/golden/engine_gateway_schema.v1.json @@ -98,6 +98,7 @@ "codex_cli", "agy_cli", "openai", + "openrouter", "solar" ], "type": "string" diff --git a/apps/api/engine_gateway/provider_registry.py b/apps/api/engine_gateway/provider_registry.py index cdb6da7..6193887 100644 --- a/apps/api/engine_gateway/provider_registry.py +++ b/apps/api/engine_gateway/provider_registry.py @@ -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}") diff --git a/apps/api/engine_gateway/test_provider_credentials_push.py b/apps/api/engine_gateway/test_provider_credentials_push.py new file mode 100644 index 0000000..0c83797 --- /dev/null +++ b/apps/api/engine_gateway/test_provider_credentials_push.py @@ -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() diff --git a/apps/api/engine_gateway/test_provider_registry_openrouter.py b/apps/api/engine_gateway/test_provider_registry_openrouter.py new file mode 100644 index 0000000..972b63c --- /dev/null +++ b/apps/api/engine_gateway/test_provider_registry_openrouter.py @@ -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() diff --git a/apps/web/src/lib/api.gen.ts b/apps/web/src/lib/api.gen.ts index 2dec17a..c6955ab 100644 --- a/apps/web/src/lib/api.gen.ts +++ b/apps/web/src/lib/api.gen.ts @@ -180,6 +180,70 @@ export interface paths { patch?: never; trace?: never; }; + "/admin/providers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Providers + * @description 연결 가능한 AI provider 목록과 자격증명 상태를 반환한다. + */ + get: operations["list_providers_admin_providers_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/providers/{provider}/credential": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Put Provider Credential + * @description 사용자가 발급받아 붙여넣은 provider 토큰을 암호화 저장하고 게이트웨이에 주입한다. + */ + put: operations["put_provider_credential_admin_providers__provider__credential_put"]; + post?: never; + /** + * Delete Provider Credential + * @description 저장된 provider 자격증명을 삭제하고 게이트웨이 주입 상태를 재동기화한다. + */ + delete: operations["delete_provider_credential_admin_providers__provider__credential_delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/providers/{provider}/verify": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Verify Provider Credential + * @description 저장된 토큰으로 provider에 실제 요청을 보내 연결을 확인한다. + */ + post: operations["verify_provider_credential_admin_providers__provider__verify_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/tickets": { parameters: { query?: never; @@ -2694,7 +2758,7 @@ export interface components { * Engine Mode * @enum {string} */ - engine_mode: "claude_cli" | "claude_api" | "codex_cli" | "agy_cli" | "openai" | "solar"; + engine_mode: "claude_cli" | "claude_api" | "codex_cli" | "agy_cli" | "openai" | "openrouter" | "solar"; /** Engine Url */ engine_url: string; /** Model */ @@ -2915,6 +2979,98 @@ export interface components { /** Version */ version: number; }; + /** AdminProviderCredentialRequest */ + AdminProviderCredentialRequest: { + /** + * Auth Kind + * @default api_key + * @enum {string} + */ + auth_kind: "api_key" | "oauth_token"; + /** Token */ + token: string; + }; + /** AdminProviderCredentialResponse */ + AdminProviderCredentialResponse: { + /** Auth Kind */ + auth_kind: string; + gateway_sync: components["schemas"]["AdminProviderGatewaySync"]; + /** Provider */ + provider: string; + /** Token Hint */ + token_hint: string; + /** Updated At */ + updated_at?: number | null; + /** Updated By */ + updated_by?: string | null; + }; + /** AdminProviderCredentialStatus */ + AdminProviderCredentialStatus: { + /** Allowed Auth Kinds */ + allowed_auth_kinds: string[]; + /** Auth Kind */ + auth_kind?: string | null; + /** Connected */ + connected: boolean; + /** Description */ + description: string; + /** Guide */ + guide: string; + /** Label */ + label: string; + /** Last Verified At */ + last_verified_at?: number | null; + /** Last Verify Error */ + last_verify_error?: string | null; + /** Last Verify Ok */ + last_verify_ok?: boolean | null; + /** Provider */ + provider: string; + /** Token Hint */ + token_hint?: string | null; + /** Token Page Label */ + token_page_label: string; + /** Token Page Url */ + token_page_url: string; + /** Updated At */ + updated_at?: number | null; + /** Updated By */ + updated_by?: string | null; + }; + /** AdminProviderDeleteResponse */ + AdminProviderDeleteResponse: { + gateway_sync: components["schemas"]["AdminProviderGatewaySync"]; + /** Ok */ + ok: boolean; + /** Provider */ + provider: string; + }; + /** AdminProviderGatewaySync */ + AdminProviderGatewaySync: { + /** Boot Id */ + boot_id?: string | null; + /** Detail */ + detail?: string | null; + /** Synced */ + synced: boolean; + }; + /** AdminProviderVerifyResponse */ + AdminProviderVerifyResponse: { + /** Detail */ + detail: string; + /** Ok */ + ok: boolean; + /** Provider */ + provider: string; + /** Verified At */ + verified_at?: number | null; + }; + /** AdminProvidersResponse */ + AdminProvidersResponse: { + gateway_sync: components["schemas"]["AdminProviderGatewaySync"]; + /** Providers */ + providers: components["schemas"]["AdminProviderCredentialStatus"][]; + }; /** AdminServiceHealth */ AdminServiceHealth: { /** Detail */ @@ -5093,7 +5249,7 @@ export interface components { * Provider * @enum {string} */ - provider: "claude_cli" | "claude_api" | "codex_cli" | "agy_cli" | "openai" | "solar"; + provider: "claude_cli" | "claude_api" | "codex_cli" | "agy_cli" | "openai" | "openrouter" | "solar"; /** * Source * @enum {string} @@ -11843,6 +11999,144 @@ export interface operations { }; }; }; + list_providers_admin_providers_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminProvidersResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + put_provider_credential_admin_providers__provider__credential_put: { + parameters: { + query?: never; + header?: never; + path: { + provider: string; + }; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminProviderCredentialRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminProviderCredentialResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_provider_credential_admin_providers__provider__credential_delete: { + parameters: { + query?: never; + header?: never; + path: { + provider: string; + }; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminProviderDeleteResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + verify_provider_credential_admin_providers__provider__verify_post: { + parameters: { + query?: never; + header?: never; + path: { + provider: string; + }; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminProviderVerifyResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; list_tickets_admin_tickets_get: { parameters: { query?: { diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index c3d07ae..9f13aa8 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -786,6 +786,34 @@ export type AdminEngineConfigResponse = ApiSchema<"AdminEngineConfigResponse">; export type AdminEngineConfigPatchRequest = ApiSchema<"AdminEngineConfigPatch">; export type EngineCapabilitiesResponse = ApiSchema<"EngineCapabilitiesResponse">; +export type AdminProviderCredentialStatus = + ApiSchema<"AdminProviderCredentialStatus">; +export type AdminProvidersResponse = ApiSchema<"AdminProvidersResponse">; +export type AdminProviderGatewaySync = ApiSchema<"AdminProviderGatewaySync">; +export type AdminProviderCredentialResponse = + ApiSchema<"AdminProviderCredentialResponse">; +export type AdminProviderDeleteResponse = ApiSchema<"AdminProviderDeleteResponse">; +export type AdminProviderVerifyResponse = ApiSchema<"AdminProviderVerifyResponse">; + +export const adminProviderApi = { + list: () => api.get("/admin/providers"), + saveCredential: (provider: string, body: { token: string; auth_kind: "api_key" | "oauth_token" }) => + apiFetch( + `/admin/providers/${encodeURIComponent(provider)}/credential`, + { method: "PUT", body }, + ), + deleteCredential: (provider: string) => + apiFetch( + `/admin/providers/${encodeURIComponent(provider)}/credential`, + { method: "DELETE" }, + ), + verify: (provider: string) => + api.post( + `/admin/providers/${encodeURIComponent(provider)}/verify`, + {}, + ), +}; + export const adminEngineApi = { get: () => api.get("/admin/engine-config"), capabilities: (engineMode: string, force = false, engineUrl?: string) => { diff --git a/apps/web/src/pages/AdminAi.tsx b/apps/web/src/pages/AdminAi.tsx index 192ec32..14dae1c 100644 --- a/apps/web/src/pages/AdminAi.tsx +++ b/apps/web/src/pages/AdminAi.tsx @@ -10,6 +10,7 @@ import { type EngineCapabilitiesResponse, } from "../lib/api"; import "./admin/admin-ai.css"; +import { ProviderConnectionsPanel } from "./admin/ProviderConnectionsPanel"; const WINDOW_OPTIONS = [7, 30, 90] as const; const ENGINE_MODES = [ @@ -18,6 +19,7 @@ const ENGINE_MODES = [ "codex_cli", "agy_cli", "openai", + "openrouter", "solar", ] as const; @@ -28,6 +30,7 @@ const ENGINE_MODE_LABEL: Record = { codex_cli: "Codex CLI 게이트웨이", agy_cli: "Agy CLI 게이트웨이", openai: "OpenAI 호환", + openrouter: "OpenRouter", solar: "Solar", }; @@ -690,6 +693,7 @@ export default function AdminAi() {
+
diff --git a/apps/web/src/pages/Settings.tsx b/apps/web/src/pages/Settings.tsx index d177804..3ea2659 100644 --- a/apps/web/src/pages/Settings.tsx +++ b/apps/web/src/pages/Settings.tsx @@ -46,6 +46,7 @@ const ENGINE_MODE_LABEL: Record = { codex_cli: "Codex CLI 게이트웨이", agy_cli: "Agy CLI 게이트웨이", openai: "OpenAI 호환", + openrouter: "OpenRouter", solar: "Solar", }; const ENGINE_MODES = [ @@ -54,6 +55,7 @@ const ENGINE_MODES = [ "codex_cli", "agy_cli", "openai", + "openrouter", "solar", ] as const; diff --git a/apps/web/src/pages/admin/ProviderConnectionsPanel.tsx b/apps/web/src/pages/admin/ProviderConnectionsPanel.tsx new file mode 100644 index 0000000..e72e30b --- /dev/null +++ b/apps/web/src/pages/admin/ProviderConnectionsPanel.tsx @@ -0,0 +1,310 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Button, Icon, Input } from "../../components/ui"; +import { + adminProviderApi, + type AdminProviderCredentialStatus, + type AdminProvidersResponse, +} from "../../lib/api"; + +function updatedAtLabel(value?: number | null): string { + if (!value || !Number.isFinite(value)) return "기록 없음"; + return new Intl.DateTimeFormat("ko-KR", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value * 1000)); +} + +function authKindLabel(value: string | null | undefined): string { + if (value === "oauth_token") return "OAuth 토큰"; + if (value === "api_key") return "API 키"; + return "미지정"; +} + +/** + * AI 제공자 연결 패널 — OAuth/API 토큰을 발급받아 붙여넣는 방식으로 provider를 연결한다. + * 토큰 원문은 저장 후 서버로만 전송되고 화면에는 마지막 4글자 힌트만 남는다. + */ +export function ProviderConnectionsPanel() { + const [data, setData] = useState(null); + const [drafts, setDrafts] = useState>({}); + const [authKinds, setAuthKinds] = useState>({}); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + const [flash, setFlash] = useState(null); + const [verifies, setVerifies] = useState< + Record + >({}); + const requestSeq = useRef(0); + + const load = useCallback(async () => { + const requestId = ++requestSeq.current; + setError(null); + try { + const next = await adminProviderApi.list(); + if (requestId !== requestSeq.current) return; + setData(next); + setAuthKinds((current) => { + const merged = { ...current }; + for (const provider of next.providers) { + if (!merged[provider.provider]) { + merged[provider.provider] = provider.allowed_auth_kinds[0] ?? "api_key"; + } + } + return merged; + }); + } catch (cause) { + if (requestId !== requestSeq.current) return; + setError( + cause instanceof Error + ? cause.message + : "제공자 연결 상태를 불러오지 못했습니다.", + ); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const showFlash = (message: string) => { + setFlash(message); + window.setTimeout(() => setFlash(null), 2600); + }; + + const saveCredential = async (provider: AdminProviderCredentialStatus) => { + const token = (drafts[provider.provider] ?? "").trim(); + if (!token) return; + setBusy(`save:${provider.provider}`); + setError(null); + try { + const result = await adminProviderApi.saveCredential(provider.provider, { + token, + auth_kind: (authKinds[provider.provider] ?? "api_key") as + | "api_key" + | "oauth_token", + }); + setDrafts((current) => ({ ...current, [provider.provider]: "" })); + showFlash( + result.gateway_sync.synced + ? `${provider.label} 자격증명을 저장하고 게이트웨이에 적용했습니다.` + : `${provider.label} 자격증명은 저장됐지만 게이트웨이 적용은 대기 중입니다.`, + ); + await load(); + } catch (cause) { + setError( + cause instanceof Error ? cause.message : "자격증명을 저장하지 못했습니다.", + ); + } finally { + setBusy(null); + } + }; + + const deleteCredential = async (provider: AdminProviderCredentialStatus) => { + setBusy(`delete:${provider.provider}`); + setError(null); + try { + await adminProviderApi.deleteCredential(provider.provider); + showFlash(`${provider.label} 연결을 해제했습니다.`); + await load(); + } catch (cause) { + setError( + cause instanceof Error ? cause.message : "연결 해제에 실패했습니다.", + ); + } finally { + setBusy(null); + } + }; + + const verifyCredential = async (provider: AdminProviderCredentialStatus) => { + setBusy(`verify:${provider.provider}`); + setError(null); + try { + const result = await adminProviderApi.verify(provider.provider); + setVerifies((current) => ({ + ...current, + [provider.provider]: { + ok: result.ok, + detail: result.detail || (result.ok ? "연결을 확인했습니다." : "확인 실패"), + }, + })); + await load(); + } catch (cause) { + setVerifies((current) => ({ + ...current, + [provider.provider]: { + ok: false, + detail: cause instanceof Error ? cause.message : "검증 요청이 실패했습니다.", + }, + })); + } finally { + setBusy(null); + } + }; + + const providers = data?.providers ?? []; + + return ( +
+
+
+ 공급자 연결 +

AI 제공자 연결

+
+ + {data === null ? "확인 중" : data.gateway_sync.synced ? "게이트웨이 적용됨" : "적용 대기"} + +
+ + {error ? ( +
+ + {error} +
+ ) : null} + {flash ? ( +
+ + {flash} +
+ ) : null} + +
+ {providers.map((provider) => { + const saving = busy === `save:${provider.provider}`; + const verifying = busy === `verify:${provider.provider}`; + const deleting = busy === `delete:${provider.provider}`; + const verifyResult = verifies[provider.provider]; + const hasDraft = Boolean((drafts[provider.provider] ?? "").trim()); + return ( +
+
+ {provider.label} + + {provider.connected ? "연결됨" : "미연결"} + +
+

{provider.description}

+

{provider.guide}

+ {provider.token_page_url ? ( + + {provider.token_page_label} 열기 + + + ) : null} +
+ + setDrafts((current) => ({ + ...current, + [provider.provider]: event.target.value, + })) + } + /> + {provider.allowed_auth_kinds.length > 1 ? ( + + ) : null} +
+ + + +
+
+ {provider.connected ? ( +
+
+
토큰
+
+ {provider.token_hint ?? "저장됨"} · {authKindLabel(provider.auth_kind)} +
+
+
+
변경
+
+ {updatedAtLabel(provider.updated_at)} + {provider.updated_by ? ` · ${provider.updated_by}` : ""} +
+
+
+
검증
+
+ {provider.last_verify_ok === null || provider.last_verify_ok === undefined + ? "미검증" + : provider.last_verify_ok + ? `통과 · ${updatedAtLabel(provider.last_verified_at)}` + : `실패 · ${provider.last_verify_error || "원인 미상"}`} +
+
+
+ ) : null} + {verifyResult ? ( +

+ {verifyResult.ok ? "연결 검증 통과" : "연결 검증 실패"} · {verifyResult.detail} +

+ ) : null} +
+ ); + })} + {data !== null && providers.length === 0 ? ( +
연결 가능한 제공자가 정의되지 않았습니다.
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/pages/admin/admin-ai.css b/apps/web/src/pages/admin/admin-ai.css index b6d8ff0..9a3a404 100644 --- a/apps/web/src/pages/admin/admin-ai.css +++ b/apps/web/src/pages/admin/admin-ai.css @@ -828,3 +828,139 @@ transition: none; } } + +/* ── 제공자 연결 (ProviderConnectionsPanel) ───────────────────── */ +.aic-providers .aic-provider__list { + display: grid; + gap: 12px; +} + +.aic-provider { + display: grid; + gap: 8px; + padding: 12px 14px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius); + background: var(--bg-surface-2); +} + +.aic-provider.is-connected { + border-color: color-mix(in srgb, var(--ok-solid, #2e7d32) 38%, var(--border-subtle)); +} + +.aic-provider__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.aic-provider__head b { + color: var(--text-strong); + font-size: var(--fs-sm); +} + +.aic-provider__badge { + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--border-subtle); + color: var(--text-muted); + font-size: 11px; + font-weight: 600; +} + +.aic-provider__badge.is-on { + border-color: transparent; + background: color-mix(in srgb, var(--ok-solid, #2e7d32) 16%, transparent); + color: var(--ok-text, #2e7d32); +} + +.aic-provider__desc, +.aic-provider__guide { + margin: 0; + color: var(--text-muted); + font-size: 12px; + line-height: 1.55; +} + +.aic-provider__guide { + color: var(--text-body); +} + +.aic-provider__link { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--text-strong); + font-size: 12px; + font-weight: 600; + text-decoration: underline; + text-underline-offset: 3px; + width: fit-content; +} + +.aic-provider__controls { + display: grid; + gap: 8px; +} + +.aic-provider__kind { + width: 100%; +} + +.aic-provider__actions { + display: flex; + align-items: center; + gap: 8px; +} + +.aic-provider__meta { + display: grid; + gap: 4px; + margin: 0; + padding-top: 8px; + border-top: 1px dashed var(--border-subtle); +} + +.aic-provider__meta div { + display: flex; + gap: 8px; +} + +.aic-provider__meta dt { + flex: 0 0 32px; + color: var(--text-muted); + font-size: 11px; +} + +.aic-provider__meta dd { + margin: 0; + color: var(--text-body); + font-size: 11px; + overflow-wrap: anywhere; +} + +.aic-provider__verify { + margin: 0; + font-size: 12px; + line-height: 1.5; +} + +.aic-provider__verify.is-ok { + color: var(--ok-text, #2e7d32); +} + +.aic-provider__verify.is-fail { + color: var(--crit-text, #b3261e); +} + +.aic-provider__flash { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 12px; + border: 1px solid color-mix(in srgb, var(--ok-solid, #2e7d32) 34%, var(--border-subtle)); + border-radius: var(--radius); + color: var(--ok-text, #2e7d32); + font-size: var(--fs-sm); +} diff --git a/infra/.env.example b/infra/.env.example index e132a65..f2c40c7 100644 --- a/infra/.env.example +++ b/infra/.env.example @@ -63,6 +63,8 @@ SMTP_STARTTLS=true SMTP_SSL=false OPENAI_API_KEY= OPENAI_BASE_URL=https://api.openai.com/v1 +# 관리자 연결 UI(/admin/ai) provider 토큰 암호화 마스터 키. 비우면 SESSION_SECRET에서 파생. +PROVIDER_CREDENTIAL_SECRET= VIGNETTE_VOICE_STT_PROVIDER=openai DEEPGRAM_API_KEY= DEEPGRAM_STT_URL=wss://api.deepgram.com/v1/listen diff --git a/infra/docker-compose.nas.yml b/infra/docker-compose.nas.yml index bd754fd..61505ce 100644 --- a/infra/docker-compose.nas.yml +++ b/infra/docker-compose.nas.yml @@ -159,6 +159,8 @@ services: VIGNETTE_LIVE_CLIENT_PROVIDER: openai ENGINE_GATEWAY_SHARED_SECRET: ${ENGINE_GATEWAY_SHARED_SECRET:?Gateway shared secret is required} OPENAI_API_KEY: ${OPENAI_API_KEY:?OpenAI key is required for production voice and engine paths} + # 관리자 연결 UI(/admin/ai) provider 토큰 DB 암호화 키. 비우면 SESSION_SECRET에서 파생. + PROVIDER_CREDENTIAL_SECRET: ${PROVIDER_CREDENTIAL_SECRET:-} OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1} VIGNETTE_VOICE_STT_PROVIDER: openai VIGNETTE_VOICE_TTS_PROVIDER: openai diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 3fca90f..7270347 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -43,6 +43,8 @@ services: ENGINE_GATEWAY_SHARED_SECRET: ${ENGINE_GATEWAY_SHARED_SECRET:-} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} OPENAI_API_KEY: ${OPENAI_API_KEY:?set in .env} + # 관리자 연결 UI(/admin/ai) provider 토큰 DB 암호화 키. 비우면 SESSION_SECRET에서 파생. + PROVIDER_CREDENTIAL_SECRET: ${PROVIDER_CREDENTIAL_SECRET:-} OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1} VIGNETTE_VOICE_STT_PROVIDER: ${VIGNETTE_VOICE_STT_PROVIDER:-openai} DEEPGRAM_API_KEY: ${DEEPGRAM_API_KEY:-}