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