관리자 AI 제공자 연결 기능 추가 (claude·codex·agy·openrouter 토큰 붙여넣기 연결)
Some checks failed
API contract / OpenAPI type drift (push) Failing after 12m57s
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:
parent
6831a79ffb
commit
4b45d33142
22 changed files with 2088 additions and 13 deletions
|
|
@ -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에 띄운다.
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
474
apps/api/app/services/provider_credentials.py
Normal file
474
apps/api/app/services/provider_credentials.py
Normal 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()}
|
||||
77
apps/api/app/test_provider_credentials_service.py
Normal file
77
apps/api/app/test_provider_credentials_service.py
Normal 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()
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@
|
|||
"codex_cli",
|
||||
"agy_cli",
|
||||
"openai",
|
||||
"openrouter",
|
||||
"solar"
|
||||
],
|
||||
"type": "string"
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
||||
|
|
|
|||
90
apps/api/engine_gateway/test_provider_credentials_push.py
Normal file
90
apps/api/engine_gateway/test_provider_credentials_push.py
Normal 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()
|
||||
142
apps/api/engine_gateway/test_provider_registry_openrouter.py
Normal file
142
apps/api/engine_gateway/test_provider_registry_openrouter.py
Normal 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()
|
||||
|
|
@ -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?: {
|
||||
|
|
|
|||
|
|
@ -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<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 = {
|
||||
get: () => api.get<AdminEngineConfigResponse>("/admin/engine-config"),
|
||||
capabilities: (engineMode: string, force = false, engineUrl?: string) => {
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
|||
codex_cli: "Codex CLI 게이트웨이",
|
||||
agy_cli: "Agy CLI 게이트웨이",
|
||||
openai: "OpenAI 호환",
|
||||
openrouter: "OpenRouter",
|
||||
solar: "Solar",
|
||||
};
|
||||
|
||||
|
|
@ -690,6 +693,7 @@ export default function AdminAi() {
|
|||
</div>
|
||||
|
||||
<div className="aic-column aic-column--side">
|
||||
<ProviderConnectionsPanel />
|
||||
<section className="aic-panel aic-engine">
|
||||
<div className="aic-panel__head">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ const ENGINE_MODE_LABEL: Record<string, string> = {
|
|||
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;
|
||||
|
||||
|
|
|
|||
310
apps/web/src/pages/admin/ProviderConnectionsPanel.tsx
Normal file
310
apps/web/src/pages/admin/ProviderConnectionsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:-}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue