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

@ -24,6 +24,9 @@ ANTHROPIC_API_KEY=
ANTHROPIC_API_BASE=https://api.anthropic.com ANTHROPIC_API_BASE=https://api.anthropic.com
OPENAI_API_KEY= OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.openai.com/v1 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=false
VIGNETTE_VOICE_POC_SAMPLE_TTS_DIR= VIGNETTE_VOICE_POC_SAMPLE_TTS_DIR=
# 로컬 연구/개발 전용. Higgs v3 서버는 scripts/start-higgs-tts.ps1 로 127.0.0.1:9881에 띄운다. # 로컬 연구/개발 전용. Higgs v3 서버는 scripts/start-higgs-tts.ps1 로 127.0.0.1:9881에 띄운다.

View file

@ -754,6 +754,22 @@ async def ensure_runtime_tables() -> None:
ADD COLUMN IF NOT EXISTS reasoning_effort TEXT 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( await conn.execute(
""" """
CREATE TABLE IF NOT EXISTS app.admin_health_event ( CREATE TABLE IF NOT EXISTS app.admin_health_event (

View file

@ -145,6 +145,12 @@ class Settings(BaseSettings):
default="https://api.openai.com/v1", default="https://api.openai.com/v1",
validation_alias="OPENAI_BASE_URL", 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)를 # `local_whisper`는 노트북 상주 faster-whisper 사이드카(scripts/local-whisper-stt-server.py)를
# 쓴다. 오디오가 호스트를 벗어나지 않고 외부 STT 키가 필요 없다. # 쓴다. 오디오가 호스트를 벗어나지 않고 외부 STT 키가 필요 없다.
voice_stt_provider: Literal["openai", "deepgram", "local_whisper"] = Field( voice_stt_provider: Literal["openai", "deepgram", "local_whisper"] = Field(

View file

@ -21,6 +21,7 @@ EngineProvider = Literal[
"codex_cli", "codex_cli",
"agy_cli", "agy_cli",
"openai", "openai",
"openrouter",
"solar", "solar",
] ]
ReasoningEffort = Literal["low", "medium", "high", "xhigh", "max", "ultra"] ReasoningEffort = Literal["low", "medium", "high", "xhigh", "max", "ultra"]
@ -41,6 +42,7 @@ ENGINE_PROVIDERS: tuple[EngineProvider, ...] = (
"codex_cli", "codex_cli",
"agy_cli", "agy_cli",
"openai", "openai",
"openrouter",
"solar", "solar",
) )
ENGINE_REASONING_EFFORTS: tuple[ReasoningEffort, ...] = ( 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"), "codex_cli": ("gpt-5.6-terra", "medium"),
"agy_cli": ("gemini-3.6-flash-high", "high"), "agy_cli": ("gemini-3.6-flash-high", "high"),
"openai": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None), "openai": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None),
"openrouter": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None),
"solar": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None), "solar": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None),
} }

View file

@ -335,6 +335,35 @@ class EngineClient:
# DB 회기 종료 성공을 게이트웨이 정리 실패 때문에 되돌리지는 않는다. # DB 회기 종료 성공을 게이트웨이 정리 실패 때문에 되돌리지는 않는다.
return False 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) # 앱 전역 싱글톤 (main lifespan 에서 startup/shutdown)
engine_client = EngineClient() engine_client = EngineClient()

View file

@ -5,7 +5,7 @@ from __future__ import annotations
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from decimal import Decimal from decimal import Decimal
from typing import Annotated, Iterable, Literal, cast from typing import Annotated, Any, Iterable, Literal, cast
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status 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 ..engine_client import engine_client
from ..runtime_policy import require_runtime_fallback_allowed from ..runtime_policy import require_runtime_fallback_allowed
from ..services import evaluator, notifications, rag from ..services import evaluator, notifications, rag
from ..services import provider_credentials as provider_credentials_service
from ..services.llm_pricing import ( from ..services.llm_pricing import (
estimate_reference_cost, estimate_reference_cost,
provider_uses_reference_cost, provider_uses_reference_cost,
@ -327,6 +328,62 @@ class AdminEngineConfigPatch(BaseModel):
reasoning_effort: str | None = None 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): class AdminTicketPatch(BaseModel):
status: TicketStatus | None = None status: TicketStatus | None = None
priority: TicketPriority | None = None priority: TicketPriority | None = None
@ -2302,6 +2359,134 @@ async def patch_engine_config(
return next_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) @router.get("/users", response_model=AdminUsersResponse)
async def list_users(principal: AdminPrincipal) -> AdminUsersResponse: async def list_users(principal: AdminPrincipal) -> AdminUsersResponse:
"""Return users observed by the server-side auth/session boundary.""" """Return users observed by the server-side auth/session boundary."""

View file

@ -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()}

View file

@ -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()

View file

@ -42,8 +42,10 @@ from app.contracts.engine_gateway import (
from engine_gateway.provider_registry import ( from engine_gateway.provider_registry import (
ProviderError, ProviderError,
_cli_subprocess_env, _cli_subprocess_env,
clear_capability_cache,
discover_capabilities, discover_capabilities,
generate_with_provider, generate_with_provider,
set_provider_auth_kind,
stream_with_provider, stream_with_provider,
) )
@ -434,6 +436,76 @@ _READY_LOCK = asyncio.Lock()
app = FastAPI(title="Vignette Engine Gateway") app = FastAPI(title="Vignette Engine Gateway")
_AUTH_EXEMPT_PATHS = frozenset({"/health"}) _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") @app.middleware("http")
async def require_gateway_shared_secret(request: Request, call_next): async def require_gateway_shared_secret(request: Request, call_next):

View file

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

View file

@ -47,6 +47,9 @@ ANTHROPIC_API_BASE = os.environ.get(
OPENAI_API_BASE = os.environ.get( OPENAI_API_BASE = os.environ.get(
"OPENAI_BASE_URL", "https://api.openai.com/v1" "OPENAI_BASE_URL", "https://api.openai.com/v1"
).rstrip("/") ).rstrip("/")
OPENROUTER_API_BASE = os.environ.get(
"OPENROUTER_API_BASE", "https://openrouter.ai/api/v1"
).rstrip("/")
_OPENAI_DEFAULT_ENGINE_MODELS = ( _OPENAI_DEFAULT_ENGINE_MODELS = (
"gpt-5.6-terra", "gpt-5.6-terra",
"gpt-5.6-luna", "gpt-5.6-luna",
@ -80,6 +83,34 @@ class ProviderStreamEvent:
_CAPABILITY_CACHE: dict[EngineProvider, tuple[float, EngineCapabilitiesResponse]] = {} _CAPABILITY_CACHE: dict[EngineProvider, tuple[float, EngineCapabilitiesResponse]] = {}
_CAPABILITY_LOCK = asyncio.Lock() _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: def clear_capability_cache() -> None:
_CAPABILITY_CACHE.clear() _CAPABILITY_CACHE.clear()
@ -490,7 +521,9 @@ async def _discover_agy_cli() -> EngineCapabilitiesResponse:
async def _discover_claude_api() -> 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: if not api_key:
return _unavailable("claude_api", "ANTHROPIC_API_KEY가 설정되지 않았습니다.") return _unavailable("claude_api", "ANTHROPIC_API_KEY가 설정되지 않았습니다.")
try: try:
@ -498,10 +531,7 @@ async def _discover_claude_api() -> EngineCapabilitiesResponse:
response = await client.get( response = await client.get(
f"{ANTHROPIC_API_BASE}/v1/models", f"{ANTHROPIC_API_BASE}/v1/models",
params={"limit": 100}, params={"limit": 100},
headers={ headers={**_anthropic_headers(api_key)},
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
},
) )
response.raise_for_status() response.raise_for_status()
payload = response.json() 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: async def _discover(provider: EngineProvider) -> EngineCapabilitiesResponse:
if provider == "claude_cli": if provider == "claude_cli":
return await _discover_claude_cli() return await _discover_claude_cli()
@ -630,6 +824,8 @@ async def _discover(provider: EngineProvider) -> EngineCapabilitiesResponse:
return await _discover_claude_api() return await _discover_claude_api()
if provider == "openai": if provider == "openai":
return await _discover_openai() return await _discover_openai()
if provider == "openrouter":
return await _discover_openrouter()
if provider == "codex_cli": if provider == "codex_cli":
return await _discover_codex_cli() return await _discover_codex_cli()
if provider == "agy_cli": if provider == "agy_cli":
@ -942,7 +1138,9 @@ async def _generate_claude_api(
req: GenerateRequest, system_prompt: str req: GenerateRequest, system_prompt: str
) -> ProviderGenerateResult: ) -> ProviderGenerateResult:
pricing_started_at = _utcnow() 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: if not api_key:
raise ProviderError("ANTHROPIC_API_KEY가 설정되지 않았습니다.") raise ProviderError("ANTHROPIC_API_KEY가 설정되지 않았습니다.")
model, effort = await _resolve_selection(req, "claude_api") 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: async with httpx.AsyncClient(timeout=CLI_TIMEOUT_SECONDS) as client:
response = await client.post( response = await client.post(
f"{ANTHROPIC_API_BASE}/v1/messages", f"{ANTHROPIC_API_BASE}/v1/messages",
headers={ headers=_anthropic_headers(api_key),
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
},
json=payload, json=payload,
) )
response.raise_for_status() response.raise_for_status()
@ -1117,6 +1312,8 @@ async def generate_with_provider(
return await _generate_claude_api(req, system_prompt) return await _generate_claude_api(req, system_prompt)
if provider == "openai": if provider == "openai":
return await _generate_openai(req, system_prompt, user_payload) 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}") 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()

View file

@ -180,6 +180,70 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/admin/tickets": {
parameters: { parameters: {
query?: never; query?: never;
@ -2694,7 +2758,7 @@ export interface components {
* Engine Mode * Engine Mode
* @enum {string} * @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 */
engine_url: string; engine_url: string;
/** Model */ /** Model */
@ -2915,6 +2979,98 @@ export interface components {
/** Version */ /** Version */
version: number; 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 */
AdminServiceHealth: { AdminServiceHealth: {
/** Detail */ /** Detail */
@ -5093,7 +5249,7 @@ export interface components {
* Provider * Provider
* @enum {string} * @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 * Source
* @enum {string} * @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: { list_tickets_admin_tickets_get: {
parameters: { parameters: {
query?: { query?: {

View file

@ -786,6 +786,34 @@ export type AdminEngineConfigResponse = ApiSchema<"AdminEngineConfigResponse">;
export type AdminEngineConfigPatchRequest = ApiSchema<"AdminEngineConfigPatch">; export type AdminEngineConfigPatchRequest = ApiSchema<"AdminEngineConfigPatch">;
export type EngineCapabilitiesResponse = ApiSchema<"EngineCapabilitiesResponse">; 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<AdminProvidersResponse>("/admin/providers"),
saveCredential: (provider: string, body: { token: string; auth_kind: "api_key" | "oauth_token" }) =>
apiFetch<AdminProviderCredentialResponse>(
`/admin/providers/${encodeURIComponent(provider)}/credential`,
{ method: "PUT", body },
),
deleteCredential: (provider: string) =>
apiFetch<AdminProviderDeleteResponse>(
`/admin/providers/${encodeURIComponent(provider)}/credential`,
{ method: "DELETE" },
),
verify: (provider: string) =>
api.post<AdminProviderVerifyResponse>(
`/admin/providers/${encodeURIComponent(provider)}/verify`,
{},
),
};
export const adminEngineApi = { export const adminEngineApi = {
get: () => api.get<AdminEngineConfigResponse>("/admin/engine-config"), get: () => api.get<AdminEngineConfigResponse>("/admin/engine-config"),
capabilities: (engineMode: string, force = false, engineUrl?: string) => { capabilities: (engineMode: string, force = false, engineUrl?: string) => {

View file

@ -10,6 +10,7 @@ import {
type EngineCapabilitiesResponse, type EngineCapabilitiesResponse,
} from "../lib/api"; } from "../lib/api";
import "./admin/admin-ai.css"; import "./admin/admin-ai.css";
import { ProviderConnectionsPanel } from "./admin/ProviderConnectionsPanel";
const WINDOW_OPTIONS = [7, 30, 90] as const; const WINDOW_OPTIONS = [7, 30, 90] as const;
const ENGINE_MODES = [ const ENGINE_MODES = [
@ -18,6 +19,7 @@ const ENGINE_MODES = [
"codex_cli", "codex_cli",
"agy_cli", "agy_cli",
"openai", "openai",
"openrouter",
"solar", "solar",
] as const; ] as const;
@ -28,6 +30,7 @@ const ENGINE_MODE_LABEL: Record<string, string> = {
codex_cli: "Codex CLI 게이트웨이", codex_cli: "Codex CLI 게이트웨이",
agy_cli: "Agy CLI 게이트웨이", agy_cli: "Agy CLI 게이트웨이",
openai: "OpenAI 호환", openai: "OpenAI 호환",
openrouter: "OpenRouter",
solar: "Solar", solar: "Solar",
}; };
@ -690,6 +693,7 @@ export default function AdminAi() {
</div> </div>
<div className="aic-column aic-column--side"> <div className="aic-column aic-column--side">
<ProviderConnectionsPanel />
<section className="aic-panel aic-engine"> <section className="aic-panel aic-engine">
<div className="aic-panel__head"> <div className="aic-panel__head">
<div> <div>

View file

@ -46,6 +46,7 @@ const ENGINE_MODE_LABEL: Record<string, string> = {
codex_cli: "Codex CLI 게이트웨이", codex_cli: "Codex CLI 게이트웨이",
agy_cli: "Agy CLI 게이트웨이", agy_cli: "Agy CLI 게이트웨이",
openai: "OpenAI 호환", openai: "OpenAI 호환",
openrouter: "OpenRouter",
solar: "Solar", solar: "Solar",
}; };
const ENGINE_MODES = [ const ENGINE_MODES = [
@ -54,6 +55,7 @@ const ENGINE_MODES = [
"codex_cli", "codex_cli",
"agy_cli", "agy_cli",
"openai", "openai",
"openrouter",
"solar", "solar",
] as const; ] as const;

View file

@ -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<AdminProvidersResponse | null>(null);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [authKinds, setAuthKinds] = useState<Record<string, string>>({});
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [flash, setFlash] = useState<string | null>(null);
const [verifies, setVerifies] = useState<
Record<string, { ok: boolean; detail: string }>
>({});
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 (
<section className="aic-panel aic-providers" data-testid="provider-connections-panel">
<div className="aic-panel__head">
<div>
<span className="aic-eyebrow"> </span>
<h2>AI </h2>
</div>
<span
className={`aic-runtime ${data?.gateway_sync.synced ? "aic-runtime--ok" : ""}`}
title={data?.gateway_sync.detail ?? undefined}
>
{data === null ? "확인 중" : data.gateway_sync.synced ? "게이트웨이 적용됨" : "적용 대기"}
</span>
</div>
{error ? (
<div className="aic-alert" role="alert">
<Icon name="alert" size={16} />
<span>{error}</span>
</div>
) : null}
{flash ? (
<div className="aic-provider__flash" role="status">
<Icon name="check" size={15} />
<span>{flash}</span>
</div>
) : null}
<div className="aic-provider__list">
{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 (
<article
key={provider.provider}
className={`aic-provider ${provider.connected ? "is-connected" : ""}`}
>
<header className="aic-provider__head">
<b>{provider.label}</b>
<span className={`aic-provider__badge ${provider.connected ? "is-on" : ""}`}>
{provider.connected ? "연결됨" : "미연결"}
</span>
</header>
<p className="aic-provider__desc">{provider.description}</p>
<p className="aic-provider__guide">{provider.guide}</p>
{provider.token_page_url ? (
<a
className="aic-provider__link"
href={provider.token_page_url}
target="_blank"
rel="noreferrer noopener"
>
{provider.token_page_label}
<Icon name="arrow-flat" size={13} />
</a>
) : null}
<div className="aic-provider__controls">
<Input
type="password"
value={drafts[provider.provider] ?? ""}
placeholder={provider.connected ? "새 토큰으로 교체" : "발급받은 토큰을 붙여넣으세요"}
aria-label={`${provider.label} 토큰`}
autoComplete="off"
onChange={(event) =>
setDrafts((current) => ({
...current,
[provider.provider]: event.target.value,
}))
}
/>
{provider.allowed_auth_kinds.length > 1 ? (
<select
className="vg-input aic-select aic-provider__kind"
value={authKinds[provider.provider] ?? provider.allowed_auth_kinds[0]}
aria-label={`${provider.label} 인증 형식`}
onChange={(event) =>
setAuthKinds((current) => ({
...current,
[provider.provider]: event.target.value,
}))
}
>
{provider.allowed_auth_kinds.map((kind) => (
<option key={kind} value={kind}>
{authKindLabel(kind)}
</option>
))}
</select>
) : null}
<div className="aic-provider__actions">
<Button
size="sm"
disabled={saving || !hasDraft}
onClick={() => void saveCredential(provider)}
>
{saving ? "저장 중" : "저장"}
</Button>
<Button
size="sm"
variant="secondary"
disabled={verifying || !provider.connected}
onClick={() => void verifyCredential(provider)}
>
{verifying ? "확인 중" : "연결 검증"}
</Button>
<Button
size="sm"
variant="ghost"
disabled={deleting || !provider.connected}
onClick={() => void deleteCredential(provider)}
>
{deleting ? "해제 중" : "해제"}
</Button>
</div>
</div>
{provider.connected ? (
<dl className="aic-provider__meta">
<div>
<dt></dt>
<dd>
{provider.token_hint ?? "저장됨"} · {authKindLabel(provider.auth_kind)}
</dd>
</div>
<div>
<dt></dt>
<dd>
{updatedAtLabel(provider.updated_at)}
{provider.updated_by ? ` · ${provider.updated_by}` : ""}
</dd>
</div>
<div>
<dt></dt>
<dd>
{provider.last_verify_ok === null || provider.last_verify_ok === undefined
? "미검증"
: provider.last_verify_ok
? `통과 · ${updatedAtLabel(provider.last_verified_at)}`
: `실패 · ${provider.last_verify_error || "원인 미상"}`}
</dd>
</div>
</dl>
) : null}
{verifyResult ? (
<p
className={`aic-provider__verify ${verifyResult.ok ? "is-ok" : "is-fail"}`}
role={verifyResult.ok ? "status" : "alert"}
>
{verifyResult.ok ? "연결 검증 통과" : "연결 검증 실패"} · {verifyResult.detail}
</p>
) : null}
</article>
);
})}
{data !== null && providers.length === 0 ? (
<div className="aic-empty"> .</div>
) : null}
</div>
</section>
);
}

View file

@ -828,3 +828,139 @@
transition: none; 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);
}

View file

@ -63,6 +63,8 @@ SMTP_STARTTLS=true
SMTP_SSL=false SMTP_SSL=false
OPENAI_API_KEY= OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.openai.com/v1 OPENAI_BASE_URL=https://api.openai.com/v1
# 관리자 연결 UI(/admin/ai) provider 토큰 암호화 마스터 키. 비우면 SESSION_SECRET에서 파생.
PROVIDER_CREDENTIAL_SECRET=
VIGNETTE_VOICE_STT_PROVIDER=openai VIGNETTE_VOICE_STT_PROVIDER=openai
DEEPGRAM_API_KEY= DEEPGRAM_API_KEY=
DEEPGRAM_STT_URL=wss://api.deepgram.com/v1/listen DEEPGRAM_STT_URL=wss://api.deepgram.com/v1/listen

View file

@ -159,6 +159,8 @@ services:
VIGNETTE_LIVE_CLIENT_PROVIDER: openai VIGNETTE_LIVE_CLIENT_PROVIDER: openai
ENGINE_GATEWAY_SHARED_SECRET: ${ENGINE_GATEWAY_SHARED_SECRET:?Gateway shared secret is required} 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} 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} OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1}
VIGNETTE_VOICE_STT_PROVIDER: openai VIGNETTE_VOICE_STT_PROVIDER: openai
VIGNETTE_VOICE_TTS_PROVIDER: openai VIGNETTE_VOICE_TTS_PROVIDER: openai

View file

@ -43,6 +43,8 @@ services:
ENGINE_GATEWAY_SHARED_SECRET: ${ENGINE_GATEWAY_SHARED_SECRET:-} ENGINE_GATEWAY_SHARED_SECRET: ${ENGINE_GATEWAY_SHARED_SECRET:-}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
OPENAI_API_KEY: ${OPENAI_API_KEY:?set in .env} 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} OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1}
VIGNETTE_VOICE_STT_PROVIDER: ${VIGNETTE_VOICE_STT_PROVIDER:-openai} VIGNETTE_VOICE_STT_PROVIDER: ${VIGNETTE_VOICE_STT_PROVIDER:-openai}
DEEPGRAM_API_KEY: ${DEEPGRAM_API_KEY:-} DEEPGRAM_API_KEY: ${DEEPGRAM_API_KEY:-}