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