Skip to content

Commit b2bd8de

Browse files
committed
Improve provider settings diagnostics
1 parent 9c427fc commit b2bd8de

9 files changed

Lines changed: 353 additions & 16 deletions

File tree

docs/skills/architecture/live-provider-ops.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ HTTP/stream/UI 경계에는 `code`, `message`, `action`, `provider`, `detail`, `
4444

4545
Provider 설정의 검증은 `probe=availability``probe=response`를 구분한다. 목록/상태 확인은 availability probe로 충분하지만, 로그인/저장/선택 직후와 사용자가 누르는 “응답 검증”은 response probe를 써서 짧은 실제 응답까지 확인한다. `openai`는 저장된 key 또는 `OPENAI_API_KEY`, `custom`은 base URL과 key, `ollama`는 로컬 서버와 모델 응답, `oauth-chatgpt`는 저장 token과 live endpoint 응답을 기준으로 실패 메시지를 갈라야 한다.
4646

47+
Provider 설정 UI는 검증 결과를 일회성 toast로만 처리하지 않는다. 로그인/저장/선택/응답 검증에서 나온 마지막 `ProviderValidationSnapshot`을 provider별로 보존하고 카드 안에 표시한다. 연결 전에는 “기본 안내 모드”로 실제 응답을 쓰지 않는다는 점을 보여주고, 검증 통과 후에는 “실제 응답 사용 중” 또는 “응답 검증 완료”를 표시한다. 실패 시에는 `diagnostic.action`을 사람이 이해할 수 있는 조치로 바꾼다. 예: `connect-provider`/`relogin-provider`는 다시 로그인, `configure-api-key`는 API 키 입력, `configure-base-url`은 Base URL 입력, `check-network`는 네트워크 문제, `check-provider-compatibility`는 OAuth 호환성 점검이다. raw detail은 기본 UI에 노출하지 않는다.
48+
4749
## Live Smoke Gate
4850

4951
명령:
@@ -116,3 +118,4 @@ Live 응답은 deterministic하지 않으므로 exact text를 비교하지 않
116118
- `src/codaro/ai/teacher/providerLoop.py`
117119
- `editor/src/lib/providerConnection.ts`
118120
- `editor/src/components/assistant/providerSettingsSheet.tsx`
121+
- `tests/testProviderSettingsContract.py`

editor/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ function App() {
9494
aiProfile,
9595
connectProvider: connectAiProvider,
9696
logoutOauthProvider,
97+
providerValidation,
9798
providerSettingsOpen,
9899
saveApiProvider,
99100
selectAiProvider,
@@ -315,6 +316,7 @@ function App() {
315316
aiProfile={aiProfile}
316317
apiOnline={apiOnline}
317318
open={providerSettingsOpen}
319+
providerValidation={providerValidation}
318320
onOauthLogin={startOauthProviderLogin}
319321
onOauthLogout={logoutOauthProvider}
320322
onOpenChange={setProviderSettingsOpen}

editor/src/components/assistant/providerSettingsSheet.tsx

Lines changed: 191 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import {
2+
AlertTriangle,
23
Check,
4+
CircleCheck,
5+
Info,
36
KeyRound,
47
Laptop,
58
Loader2,
69
LogIn,
710
LogOut,
811
Server,
12+
type LucideIcon,
913
} from "lucide-react";
1014
import { useMemo, useState } from "react";
1115

@@ -21,13 +25,14 @@ import {
2125
SheetTitle,
2226
} from "@/components/ui/sheet";
2327
import { cn } from "@/lib/utils";
24-
import type { AiProfile, AiProvider } from "@/types";
28+
import type { AiProfile, AiProvider, ProviderValidationSnapshot } from "@/types";
2529

2630
type ProviderSettingsSheetProps = {
2731
aiConnecting: boolean;
2832
aiProfile: AiProfile | null;
2933
apiOnline: boolean;
3034
open: boolean;
35+
providerValidation: Record<string, ProviderValidationSnapshot>;
3136
onOpenChange: (open: boolean) => void;
3237
onOauthLogin: (providerId: string) => void;
3338
onOauthLogout: (providerId: string) => void;
@@ -66,6 +71,7 @@ export function ProviderSettingsSheet({
6671
aiProfile,
6772
apiOnline,
6873
open,
74+
providerValidation,
6975
onOpenChange,
7076
onOauthLogin,
7177
onOauthLogout,
@@ -106,6 +112,7 @@ export function ProviderSettingsSheet({
106112
provider={provider}
107113
ready={provider.id === activeProvider && ready}
108114
runtime={runtime[provider.id ?? ""] ?? {}}
115+
validation={providerValidation[provider.id ?? ""]}
109116
onOauthLogin={onOauthLogin}
110117
onOauthLogout={onOauthLogout}
111118
onSaveApiProvider={onSaveApiProvider}
@@ -132,6 +139,7 @@ function ProviderCard({
132139
provider,
133140
ready,
134141
runtime,
142+
validation,
135143
onOauthLogin,
136144
onOauthLogout,
137145
onSaveApiProvider,
@@ -144,6 +152,7 @@ function ProviderCard({
144152
provider: AiProvider;
145153
ready: boolean;
146154
runtime: ProviderRuntime;
155+
validation?: ProviderValidationSnapshot;
147156
onOauthLogin: (providerId: string) => void;
148157
onOauthLogout: (providerId: string) => void;
149158
onSaveApiProvider: (providerId: string, apiKey: string, baseUrl?: string) => void;
@@ -183,6 +192,15 @@ function ProviderCard({
183192
</div>
184193
</div>
185194

195+
<ProviderConnectionStatus
196+
active={active}
197+
authKind={authKind}
198+
canUseStoredSecret={canUseStoredSecret}
199+
providerId={providerId}
200+
ready={ready}
201+
validation={validation}
202+
/>
203+
186204
<div className="mt-3 space-y-2">
187205
{authKind === "oauth" ? (
188206
<div className="flex flex-wrap gap-2">
@@ -305,6 +323,178 @@ function ProviderCard({
305323
);
306324
}
307325

326+
function ProviderConnectionStatus({
327+
active,
328+
authKind,
329+
canUseStoredSecret,
330+
providerId,
331+
ready,
332+
validation,
333+
}: {
334+
active: boolean;
335+
authKind: string;
336+
canUseStoredSecret: boolean;
337+
providerId: string;
338+
ready: boolean;
339+
validation?: ProviderValidationSnapshot;
340+
}) {
341+
const status = providerStatusCopy({
342+
active,
343+
authKind,
344+
canUseStoredSecret,
345+
providerId,
346+
ready,
347+
validation,
348+
});
349+
const StatusIcon = status.icon;
350+
351+
return (
352+
<div
353+
className={cn("mt-3 rounded-md border px-3 py-2 text-xs leading-5", status.className)}
354+
data-provider-fallback-state={status.mode}
355+
data-provider-validation-status={validation ? validation.valid ? "valid" : "invalid" : "unchecked"}
356+
>
357+
<div className="flex items-start gap-2">
358+
<StatusIcon className="mt-0.5 size-3.5 shrink-0" />
359+
<div className="min-w-0">
360+
<div className="font-medium tracking-normal">{status.title}</div>
361+
<div className="text-muted-foreground">{status.detail}</div>
362+
{status.action ? (
363+
<div className="mt-1 font-medium text-foreground">권장 조치: {status.action}</div>
364+
) : null}
365+
{status.meta ? <div className="mt-1 font-mono text-[11px] text-muted-foreground">{status.meta}</div> : null}
366+
</div>
367+
</div>
368+
</div>
369+
);
370+
}
371+
372+
function providerStatusCopy({
373+
active,
374+
authKind,
375+
canUseStoredSecret,
376+
providerId,
377+
ready,
378+
validation,
379+
}: {
380+
active: boolean;
381+
authKind: string;
382+
canUseStoredSecret: boolean;
383+
providerId: string;
384+
ready: boolean;
385+
validation?: ProviderValidationSnapshot;
386+
}): {
387+
action?: string;
388+
className: string;
389+
detail: string;
390+
icon: LucideIcon;
391+
meta?: string;
392+
mode: string;
393+
title: string;
394+
} {
395+
if (validation?.valid) {
396+
const liveActive = active && ready;
397+
return {
398+
className: "border-emerald-500/30 bg-emerald-500/10 text-emerald-900 dark:text-emerald-100",
399+
detail: liveActive
400+
? `${validation.model ?? providerId} 응답 검증을 통과했습니다.`
401+
: `${validation.model ?? providerId} 응답 검증을 통과했습니다. 사용으로 전환하면 이 provider를 쓸 수 있습니다.`,
402+
icon: CircleCheck,
403+
meta: validationMeta(validation),
404+
mode: liveActive ? "live" : "validated",
405+
title: liveActive ? "실제 응답 사용 중" : "응답 검증 완료",
406+
};
407+
}
408+
409+
if (validation && !validation.valid) {
410+
return {
411+
action: providerActionLabel(validation.diagnostic?.action),
412+
className: "border-amber-500/30 bg-amber-500/10 text-amber-950 dark:text-amber-100",
413+
detail: validation.diagnostic?.message ?? validation.error ?? "Provider 응답을 확인하지 못했습니다.",
414+
icon: AlertTriangle,
415+
meta: validationMeta(validation),
416+
mode: "needs-action",
417+
title: "연결 확인 필요",
418+
};
419+
}
420+
421+
if (active && ready) {
422+
return {
423+
className: "border-sky-500/25 bg-sky-500/10 text-sky-950 dark:text-sky-100",
424+
detail: "프로필은 연결됨 상태입니다. 응답 검증으로 현재 모델의 실제 응답까지 확인하세요.",
425+
icon: Info,
426+
mode: "live-unverified",
427+
title: "실제 응답 확인 전",
428+
};
429+
}
430+
431+
if (active) {
432+
return {
433+
action: authKind === "oauth" ? "다시 로그인 필요" : authKind === "api_key" ? "키 또는 Base URL 확인" : undefined,
434+
className: "bg-muted/40 text-muted-foreground",
435+
detail: "연결 전에는 기본 안내만 사용합니다. 로그인이나 키 저장 후 응답 검증을 통과해야 실제 응답을 사용합니다.",
436+
icon: Info,
437+
mode: "fallback",
438+
title: "기본 안내 모드",
439+
};
440+
}
441+
442+
if (canUseStoredSecret) {
443+
return {
444+
className: "bg-muted/40 text-muted-foreground",
445+
detail: "저장된 인증이 있습니다. 사용으로 전환한 뒤 응답 검증을 실행하세요.",
446+
icon: Info,
447+
mode: "candidate",
448+
title: "저장된 인증 있음",
449+
};
450+
}
451+
452+
return {
453+
action: authKind === "oauth" ? "브라우저 로그인" : authKind === "api_key" ? "키 저장" : undefined,
454+
className: "bg-muted/40 text-muted-foreground",
455+
detail: "아직 연결되지 않았습니다. 연결 전에는 이 provider의 실제 응답을 사용하지 않습니다.",
456+
icon: Info,
457+
mode: "not-configured",
458+
title: authKind === "oauth" ? "로그인 필요" : authKind === "api_key" ? "키 필요" : "미선택",
459+
};
460+
}
461+
462+
function providerActionLabel(action?: string | null) {
463+
if (!action) return undefined;
464+
const labels: Record<string, string> = {
465+
"check-network": "네트워크 문제",
466+
"check-provider": "Provider 상태 확인",
467+
"check-provider-compatibility": "OAuth 호환성 점검",
468+
"configure-api-key": "API 키 입력 필요",
469+
"configure-base-url": "Base URL 입력 필요",
470+
"connect-provider": "다시 로그인 필요",
471+
"relogin-provider": "다시 로그인 필요",
472+
"restart-login": "로그인 다시 시작",
473+
};
474+
return labels[action] ?? action;
475+
}
476+
477+
function validationMeta(validation: ProviderValidationSnapshot) {
478+
const checkedAt = formatCheckedAt(validation.checkedAt);
479+
const parts = [
480+
validation.phase ? `phase=${validation.phase}` : null,
481+
validation.probe ? `probe=${validation.probe}` : null,
482+
validation.diagnostic?.code ? `code=${validation.diagnostic.code}` : null,
483+
checkedAt ? `checked=${checkedAt}` : null,
484+
].filter(Boolean);
485+
return parts.length ? parts.join(" · ") : undefined;
486+
}
487+
488+
function formatCheckedAt(value?: string) {
489+
if (!value) return undefined;
490+
const parsed = new Date(value);
491+
if (Number.isNaN(parsed.getTime())) return undefined;
492+
return new Intl.DateTimeFormat("ko-KR", {
493+
hour: "2-digit",
494+
minute: "2-digit",
495+
}).format(parsed);
496+
}
497+
308498
function providerCatalog(profile: AiProfile | null): AiProvider[] {
309499
const catalog = profile?.catalog;
310500
if (Array.isArray(catalog)) {

0 commit comments

Comments
 (0)