관리자 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
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue