Skip to content

Commit ac1fbeb

Browse files
committed
Add live provider response validation
1 parent b3edc90 commit ac1fbeb

14 files changed

Lines changed: 349 additions & 25 deletions

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ Codaro의 기본 gate는 deterministic scripted provider를 사용한다. 실제
4040
- network timeout/connection: 네트워크 문제.
4141
- endpoint/header/SSE 변경: OAuth provider compatibility 점검 필요.
4242

43-
HTTP/stream/UI 경계에는 `code`, `message`, `action`, `provider`, `detail`, `recoverable`을 가진 진단 payload를 넘긴다. 기본 화면은 `message`만 보여주고, raw detail은 trace나 확장 진단에서만 본다. editor는 `connect-provider`, `relogin-provider`, `restart-login`, `check-network`, `check-provider-compatibility` action을 구분해 설정 열기/재로그인/네트워크 점검/호환성 점검으로 안내한다.
43+
HTTP/stream/UI 경계에는 `code`, `message`, `action`, `provider`, `detail`, `recoverable`을 가진 진단 payload를 넘긴다. 기본 화면은 `message`만 보여주고, raw detail은 trace나 확장 진단에서만 본다. editor는 `connect-provider`, `relogin-provider`, `restart-login`, `configure-api-key`, `configure-base-url`, `check-network`, `check-provider-compatibility` action을 구분해 설정 열기/재로그인/키 입력/base URL 입력/네트워크 점검/호환성 점검으로 안내한다.
44+
45+
Provider 설정의 검증은 `probe=availability``probe=response`를 구분한다. 목록/상태 확인은 availability probe로 충분하지만, 로그인/저장/선택 직후와 사용자가 누르는 “응답 검증”은 response probe를 써서 짧은 실제 응답까지 확인한다. `openai`는 저장된 key 또는 `OPENAI_API_KEY`, `custom`은 base URL과 key, `ollama`는 로컬 서버와 모델 응답, `oauth-chatgpt`는 저장 token과 live endpoint 응답을 기준으로 실패 메시지를 갈라야 한다.
4446

4547
## Live Smoke Gate
4648

editor/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ function App() {
100100
setAiProfile,
101101
setProviderSettingsOpen,
102102
startOauthProviderLogin,
103+
validateAiProvider,
103104
} = useProviderConnection({ apiOnline, onNotice: setNotice });
104105

105106
const {
@@ -319,6 +320,7 @@ function App() {
319320
onOpenChange={setProviderSettingsOpen}
320321
onSaveApiProvider={saveApiProvider}
321322
onSelectProvider={selectAiProvider}
323+
onValidateProvider={validateAiProvider}
322324
/>
323325
</SidebarProvider>
324326
);

editor/src/components/assistant/providerSettingsSheet.tsx

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ type ProviderSettingsSheetProps = {
3333
onOauthLogout: (providerId: string) => void;
3434
onSaveApiProvider: (providerId: string, apiKey: string, baseUrl?: string) => void;
3535
onSelectProvider: (providerId: string) => void;
36+
onValidateProvider: (providerId: string) => void;
3637
};
3738

3839
type ProviderRuntime = {
@@ -70,6 +71,7 @@ export function ProviderSettingsSheet({
7071
onOauthLogout,
7172
onSaveApiProvider,
7273
onSelectProvider,
74+
onValidateProvider,
7375
}: ProviderSettingsSheetProps) {
7476
const providers = useMemo(() => providerCatalog(aiProfile), [aiProfile]);
7577
const runtime = useMemo(() => providerRuntime(aiProfile), [aiProfile]);
@@ -108,6 +110,7 @@ export function ProviderSettingsSheet({
108110
onOauthLogout={onOauthLogout}
109111
onSaveApiProvider={onSaveApiProvider}
110112
onSelectProvider={onSelectProvider}
113+
onValidateProvider={onValidateProvider}
111114
/>
112115
))
113116
) : (
@@ -133,6 +136,7 @@ function ProviderCard({
133136
onOauthLogout,
134137
onSaveApiProvider,
135138
onSelectProvider,
139+
onValidateProvider,
136140
}: {
137141
active: boolean;
138142
aiConnecting: boolean;
@@ -144,6 +148,7 @@ function ProviderCard({
144148
onOauthLogout: (providerId: string) => void;
145149
onSaveApiProvider: (providerId: string, apiKey: string, baseUrl?: string) => void;
146150
onSelectProvider: (providerId: string) => void;
151+
onValidateProvider: (providerId: string) => void;
147152
}) {
148153
const providerId = provider.id ?? "";
149154
const copy = providerCopy(provider);
@@ -209,6 +214,17 @@ function ProviderCard({
209214
로그아웃
210215
</Button>
211216
) : null}
217+
<Button
218+
className="gap-1.5"
219+
disabled={!apiOnline || aiConnecting}
220+
size="sm"
221+
type="button"
222+
variant="outline"
223+
onClick={() => onValidateProvider(providerId)}
224+
>
225+
{aiConnecting ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
226+
응답 검증
227+
</Button>
212228
</div>
213229
) : null}
214230

@@ -245,20 +261,44 @@ function ProviderCard({
245261
저장된 키 사용
246262
</Button>
247263
) : null}
264+
<Button
265+
className="gap-1.5"
266+
disabled={!apiOnline || aiConnecting}
267+
size="sm"
268+
type="button"
269+
variant="outline"
270+
onClick={() => onValidateProvider(providerId)}
271+
>
272+
{aiConnecting ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
273+
응답 검증
274+
</Button>
248275
</div>
249276
) : null}
250277

251278
{authKind === "none" ? (
252-
<Button disabled={!apiOnline || aiConnecting || active} size="sm" type="button" variant={active ? "secondary" : "outline"} onClick={() => onSelectProvider(providerId)}>
253-
{active ? (
254-
<>
255-
<Check className="size-3.5" />
256-
사용 중
257-
</>
258-
) : (
259-
"사용"
260-
)}
261-
</Button>
279+
<div className="flex flex-wrap gap-2">
280+
<Button disabled={!apiOnline || aiConnecting || active} size="sm" type="button" variant={active ? "secondary" : "outline"} onClick={() => onSelectProvider(providerId)}>
281+
{active ? (
282+
<>
283+
<Check className="size-3.5" />
284+
사용 중
285+
</>
286+
) : (
287+
"사용"
288+
)}
289+
</Button>
290+
<Button
291+
className="gap-1.5"
292+
disabled={!apiOnline || aiConnecting}
293+
size="sm"
294+
type="button"
295+
variant="outline"
296+
onClick={() => onValidateProvider(providerId)}
297+
>
298+
{aiConnecting ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
299+
응답 검증
300+
</Button>
301+
</div>
262302
) : null}
263303
</div>
264304
</section>

editor/src/hooks/useProviderConnection.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
providerAuthFailureNotice,
77
saveApiProvider as saveApiProviderAction,
88
selectProvider,
9+
validateProviderAction,
910
type ProviderActionResult,
1011
} from "@/lib/providerConnection";
1112
import type { AiProfile, AppNotice } from "@/types";
@@ -99,6 +100,18 @@ export function useProviderConnection({
99100
}
100101
}, [aiConnecting, apiOnline, applyProviderActionResult, onNotice]);
101102

103+
const validateAiProvider = useCallback(async (providerId: string) => {
104+
if (!apiOnline || aiConnecting) return;
105+
setAiConnecting(true);
106+
try {
107+
applyProviderActionResult(await validateProviderAction(providerId, providerModel(aiProfile, providerId)));
108+
} catch (error) {
109+
onNotice(providerAuthFailureNotice(error));
110+
} finally {
111+
setAiConnecting(false);
112+
}
113+
}, [aiConnecting, aiProfile, apiOnline, applyProviderActionResult, onNotice]);
114+
102115
return {
103116
aiConnecting,
104117
aiProfile,
@@ -110,5 +123,13 @@ export function useProviderConnection({
110123
setAiProfile,
111124
setProviderSettingsOpen,
112125
startOauthProviderLogin,
126+
validateAiProvider,
113127
};
114128
}
129+
130+
function providerModel(profile: AiProfile | null, providerId: string): string | null {
131+
const providers = profile?.providers;
132+
if (!providers || typeof providers !== "object" || Array.isArray(providers)) return null;
133+
const runtime = (providers as Record<string, { model?: unknown }>)[providerId];
134+
return typeof runtime?.model === "string" ? runtime.model : null;
135+
}

editor/src/lib/api.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,9 +237,10 @@ export const codaroApi = {
237237
aiProviders: () => requestJson<AiProviderCatalogPayload>("/api/ai/providers"),
238238
aiTools: () => requestJson<AiToolCatalogPayload>("/api/ai/tools"),
239239
aiProfile: () => requestJson<AiProfile>("/api/ai/profile"),
240-
validateAiProvider: (provider: string, model?: string | null) => {
240+
validateAiProvider: (provider: string, model?: string | null, probe = "availability") => {
241241
const params = new URLSearchParams({ provider });
242242
if (model) params.set("model", model);
243+
if (probe) params.set("probe", probe);
243244
return postJson<ProviderValidationPayload>(`/api/ai/provider/validate?${params.toString()}`, {});
244245
},
245246
updateAiProfile: (payload: {

editor/src/lib/providerConnection.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,26 @@ export async function saveApiProvider(providerId: string, apiKey: string, baseUr
9696
});
9797
}
9898

99+
export async function validateProviderAction(providerId: string, model?: string | null): Promise<ProviderActionResult> {
100+
const validation = await validateProvider(providerId, model, "response");
101+
if (validation.valid) {
102+
return {
103+
notice: {
104+
tone: "success",
105+
title: "Provider 응답 확인됨",
106+
detail: validation.model ? `${providerId} · ${validation.model}` : providerId,
107+
},
108+
};
109+
}
110+
return {
111+
notice: {
112+
tone: "warning",
113+
title: "Provider 확인 필요",
114+
detail: validation.diagnostic?.message ?? validation.error ?? "Provider 응답 검증에 실패했습니다.",
115+
},
116+
};
117+
}
118+
99119
export function providerAuthFailureNotice(error: unknown): AppNotice {
100120
const diagnostic = providerDiagnosticFromError(error);
101121
return {
@@ -124,7 +144,10 @@ export function providerAssistantFailure(error: unknown): ProviderAssistantFailu
124144

125145
export function isProviderAuthError(error: unknown) {
126146
const diagnostic = providerDiagnosticFromError(error);
127-
if (diagnostic?.action && ["connect-provider", "relogin-provider", "restart-login"].includes(diagnostic.action)) {
147+
if (
148+
diagnostic?.action
149+
&& ["connect-provider", "relogin-provider", "restart-login", "configure-api-key", "configure-base-url"].includes(diagnostic.action)
150+
) {
128151
return true;
129152
}
130153
const normalized = errorMessage(error).toLowerCase();
@@ -158,7 +181,7 @@ async function withProviderValidation(
158181
notice: { tone: "warning", title: "Provider 확인 필요", detail: "선택된 provider가 없습니다." },
159182
};
160183
}
161-
const validation = await validateProvider(provider, profile.activeModel);
184+
const validation = await validateProvider(provider, profile.activeModel, "response");
162185
if (validation.valid) {
163186
return {
164187
...base,
@@ -169,8 +192,9 @@ async function withProviderValidation(
169192
},
170193
};
171194
}
195+
const failureBase = base.closeSettings ? { ...base, closeSettings: false, openSettings: true } : base;
172196
return {
173-
...base,
197+
...failureBase,
174198
notice: {
175199
tone: "warning",
176200
title: "Provider 확인 필요",
@@ -179,9 +203,9 @@ async function withProviderValidation(
179203
};
180204
}
181205

182-
async function validateProvider(provider: string, model?: unknown): Promise<ProviderValidationPayload> {
206+
async function validateProvider(provider: string, model?: unknown, probe = "availability"): Promise<ProviderValidationPayload> {
183207
try {
184-
return await codaroApi.validateAiProvider(provider, typeof model === "string" ? model : undefined);
208+
return await codaroApi.validateAiProvider(provider, typeof model === "string" ? model : undefined, probe);
185209
} catch (error) {
186210
const diagnostic = providerDiagnosticFromError(error);
187211
return {

editor/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ export type ProviderValidationPayload = {
249249
model?: string | null;
250250
error?: string | null;
251251
diagnostic?: ProviderDiagnostic | null;
252+
probe?: string;
252253
};
253254

254255
export type AiToolCatalogPayload = {

0 commit comments

Comments
 (0)