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

@ -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."""