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

@ -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?: {

View file

@ -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) => {

View file

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

View file

@ -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;

View 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>
);
}

View file

@ -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);
}