Skip to content

Commit b3edc90

Browse files
committed
Improve provider auth diagnostics
1 parent 990e54b commit b3edc90

21 files changed

Lines changed: 646 additions & 122 deletions

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ 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을 구분해 설정 열기/재로그인/네트워크 점검/호환성 점검으로 안내한다.
44+
4345
## Live Smoke Gate
4446

4547
명령:

editor/src/hooks/useAssistantTurnState.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,8 +225,7 @@ export function useAssistantTurnState({
225225
}
226226
onNotice(application.notice);
227227
} catch (error) {
228-
const detail = error instanceof Error ? error.message : String(error);
229-
const failure = providerAssistantFailure(detail);
228+
const failure = providerAssistantFailure(error);
230229
setMessages((current) => failAssistantMessage({
231230
action: failure.action,
232231
assistantMessageId,

editor/src/hooks/useProviderConnection.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,7 @@ export function useProviderConnection({
4545
onNotice({ tone: "default", title: "Provider 로그인 열림", detail: "새 탭에서 provider 로그인을 완료하세요." });
4646
applyProviderActionResult(await loginOauthProvider(providerId));
4747
} catch (error) {
48-
const detail = error instanceof Error ? error.message : String(error);
49-
onNotice(providerAuthFailureNotice(detail));
48+
onNotice(providerAuthFailureNotice(error));
5049
} finally {
5150
setAiConnecting(false);
5251
}

editor/src/lib/api.ts

Lines changed: 64 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import type {
1717
OauthStatusPayload,
1818
PackageInfo,
1919
PackageInstallResult,
20+
ProviderDiagnostic,
21+
ProviderValidationPayload,
2022
ProgressSummary,
2123
SchedulerStatus,
2224
TaskListPayload,
@@ -32,6 +34,18 @@ import {
3234

3335
const configuredApiBase = import.meta.env.VITE_CODARO_API_BASE?.replace(/\/$/, "") ?? "";
3436

37+
export class CodaroApiError extends Error {
38+
readonly status: number;
39+
readonly diagnostic?: ProviderDiagnostic;
40+
41+
constructor(status: number, message: string, diagnostic?: ProviderDiagnostic) {
42+
super(message);
43+
this.name = "CodaroApiError";
44+
this.status = status;
45+
this.diagnostic = diagnostic;
46+
}
47+
}
48+
3549
export function shouldUseApi() {
3650
if (configuredApiBase) return true;
3751
if (typeof window === "undefined") return true;
@@ -49,20 +63,16 @@ async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
4963

5064
if (!response.ok) {
5165
let detail = response.statusText;
66+
let diagnostic: ProviderDiagnostic | undefined;
5267
try {
53-
const payload = (await response.json()) as {
54-
detail?: string;
55-
message?: string;
56-
error?: {
57-
code?: string;
58-
message?: string;
59-
};
60-
};
61-
detail = payload.detail ?? payload.message ?? payload.error?.message ?? detail;
68+
const payload = (await response.json()) as ApiErrorPayload;
69+
const parsed = parseApiErrorPayload(payload, detail);
70+
detail = parsed.message;
71+
diagnostic = parsed.diagnostic;
6272
} catch {
6373
detail = response.statusText;
6474
}
65-
throw new Error(`${response.status} ${detail}`);
75+
throw new CodaroApiError(response.status, `${response.status} ${detail}`, diagnostic);
6676
}
6777

6878
if (response.status === 204) {
@@ -103,17 +113,16 @@ async function postStreamChat(
103113

104114
if (!response.ok) {
105115
let detail = response.statusText;
116+
let diagnostic: ProviderDiagnostic | undefined;
106117
try {
107-
const payload = (await response.json()) as {
108-
detail?: string;
109-
message?: string;
110-
error?: { message?: string };
111-
};
112-
detail = payload.detail ?? payload.message ?? payload.error?.message ?? detail;
118+
const payload = (await response.json()) as ApiErrorPayload;
119+
const parsed = parseApiErrorPayload(payload, detail);
120+
detail = parsed.message;
121+
diagnostic = parsed.diagnostic;
113122
} catch {
114123
detail = response.statusText;
115124
}
116-
throw new Error(`${response.status} ${detail}`);
125+
throw new CodaroApiError(response.status, `${response.status} ${detail}`, diagnostic);
117126
}
118127

119128
if (!response.body) {
@@ -149,7 +158,7 @@ async function postStreamChat(
149158
}
150159

151160
if (streamState.error) {
152-
throw new Error(streamState.error);
161+
throw new CodaroApiError(503, streamState.error, streamState.diagnostic);
153162
}
154163

155164
if (!streamState.donePayload) {
@@ -228,6 +237,11 @@ export const codaroApi = {
228237
aiProviders: () => requestJson<AiProviderCatalogPayload>("/api/ai/providers"),
229238
aiTools: () => requestJson<AiToolCatalogPayload>("/api/ai/tools"),
230239
aiProfile: () => requestJson<AiProfile>("/api/ai/profile"),
240+
validateAiProvider: (provider: string, model?: string | null) => {
241+
const params = new URLSearchParams({ provider });
242+
if (model) params.set("model", model);
243+
return postJson<ProviderValidationPayload>(`/api/ai/provider/validate?${params.toString()}`, {});
244+
},
231245
updateAiProfile: (payload: {
232246
provider?: string | null;
233247
model?: string | null;
@@ -251,6 +265,38 @@ export const codaroApi = {
251265
requestJson,
252266
};
253267

268+
type ApiErrorPayload = {
269+
detail?: string | ProviderDiagnostic;
270+
message?: string;
271+
error?: ProviderDiagnostic;
272+
};
273+
274+
function parseApiErrorPayload(payload: ApiErrorPayload, fallback: string): {
275+
message: string;
276+
diagnostic?: ProviderDiagnostic;
277+
} {
278+
const detail = payload.detail;
279+
if (isProviderDiagnostic(detail)) {
280+
return {
281+
message: detail.message ?? fallback,
282+
diagnostic: detail,
283+
};
284+
}
285+
if (payload.error) {
286+
return {
287+
message: payload.error.message ?? payload.message ?? fallback,
288+
diagnostic: payload.error,
289+
};
290+
}
291+
return {
292+
message: typeof detail === "string" ? detail : payload.message ?? fallback,
293+
};
294+
}
295+
296+
function isProviderDiagnostic(value: unknown): value is ProviderDiagnostic {
297+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
298+
}
299+
254300
export async function optional<T>(load: () => Promise<T>, fallback: T): Promise<{ data: T; online: boolean; error?: string }> {
255301
try {
256302
return { data: await load(), online: true };

editor/src/lib/assistantStream.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { AiChatRequest, AiChatResponse } from "@/types";
1+
import type { AiChatRequest, AiChatResponse, ProviderDiagnostic } from "@/types";
22

33
export type StreamEvent = {
44
type?: string;
@@ -13,12 +13,14 @@ export type StreamEvent = {
1313
toolCalls?: AiChatResponse["toolCalls"];
1414
trace?: AiChatResponse["trace"];
1515
error?: string;
16+
diagnostic?: ProviderDiagnostic;
1617
};
1718

1819
export type AssistantStreamState = {
1920
conversationId: string;
2021
donePayload: AiChatResponse | null;
2122
error: string | null;
23+
diagnostic?: ProviderDiagnostic;
2224
};
2325

2426
export function initialAssistantStreamState(request: AiChatRequest): AssistantStreamState {
@@ -39,6 +41,7 @@ export function applyAssistantStreamProtocolEvent(
3941
...state,
4042
conversationId,
4143
error: event.error ?? "provider stream failed",
44+
diagnostic: event.diagnostic,
4245
};
4346
}
4447
if (event.type === "done") {

editor/src/lib/providerConnection.ts

Lines changed: 89 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { codaroApi } from "@/lib/api";
2-
import type { AiProfile, AppNotice } from "@/types";
1+
import { CodaroApiError, codaroApi } from "@/lib/api";
2+
import type { AiProfile, AppNotice, ProviderDiagnostic, ProviderValidationPayload } from "@/types";
33

44
export type ProviderActionResult = {
55
closeSettings?: boolean;
@@ -45,13 +45,18 @@ export async function loginOauthProvider(providerId = "oauth-chatgpt"): Promise<
4545
await sleep(1000);
4646
const status = await codaroApi.oauthStatus();
4747
if (!status.done) continue;
48-
if (status.error) throw new Error(status.error);
48+
if (status.error) {
49+
throw new CodaroApiError(
50+
Number(status.diagnostic?.statusCode ?? 503),
51+
status.message ?? status.error,
52+
status.diagnostic ?? undefined,
53+
);
54+
}
4955
const profile = await codaroApi.aiProfile();
50-
return {
56+
return withProviderValidation(profile, {
5157
closeSettings: true,
52-
notice: { tone: "success", title: "Provider 연결됨", detail: providerName(profile) },
5358
profile,
54-
};
59+
});
5560
}
5661

5762
return {
@@ -75,10 +80,9 @@ export async function logoutOauthProvider(providerId = "oauth-chatgpt"): Promise
7580
export async function selectProvider(providerId: string): Promise<ProviderActionResult> {
7681
const profile = await codaroApi.updateAiProfile({ provider: providerId });
7782
const latestProfile = await codaroApi.aiProfile().catch(() => profile);
78-
return {
79-
notice: { tone: "success", title: "Provider 선택됨", detail: providerName(latestProfile) },
83+
return withProviderValidation(latestProfile, {
8084
profile: latestProfile,
81-
};
85+
});
8286
}
8387

8488
export async function saveApiProvider(providerId: string, apiKey: string, baseUrl?: string): Promise<ProviderActionResult> {
@@ -87,38 +91,43 @@ export async function saveApiProvider(providerId: string, apiKey: string, baseUr
8791
}
8892
const profile = await codaroApi.saveAiSecret(providerId, apiKey);
8993
const latestProfile = await codaroApi.aiProfile().catch(() => profile);
90-
return {
91-
notice: { tone: "success", title: "Provider 연결됨", detail: providerName(latestProfile) },
94+
return withProviderValidation(latestProfile, {
9295
profile: latestProfile,
93-
};
96+
});
9497
}
9598

96-
export function providerAuthFailureNotice(detail: string): AppNotice {
99+
export function providerAuthFailureNotice(error: unknown): AppNotice {
100+
const diagnostic = providerDiagnosticFromError(error);
97101
return {
98102
tone: "error",
99103
title: "Provider 로그인 실패",
100-
detail: isProviderAuthError(detail) ? "provider 로그인을 다시 시작하세요." : detail,
104+
detail: diagnostic?.message ?? errorMessage(error),
101105
};
102106
}
103107

104-
export function providerAssistantFailure(detail: string): ProviderAssistantFailure {
105-
const authIssue = isProviderAuthError(detail);
108+
export function providerAssistantFailure(error: unknown): ProviderAssistantFailure {
109+
const diagnostic = providerDiagnosticFromError(error);
110+
const authIssue = isProviderAuthError(error);
106111
const content = authIssue
107-
? "provider 로그인이 필요합니다. Provider 설정에서 브라우저 로그인을 완료한 뒤 다시 요청하세요."
108-
: detail;
112+
? diagnostic?.message ?? "Provider 로그인이 필요합니다. Provider 설정에서 브라우저 로그인을 완료한 뒤 다시 요청하세요."
113+
: diagnostic?.message ?? errorMessage(error);
109114
return {
110115
action: authIssue ? "connect-provider" : undefined,
111116
content,
112117
notice: {
113118
tone: "error",
114-
title: authIssue ? "Provider 연결 필요" : "AI 사용 불가",
119+
title: authIssue ? "Provider 연결 필요" : "Provider 사용 불가",
115120
detail: content,
116121
},
117122
};
118123
}
119124

120-
export function isProviderAuthError(detail: string) {
121-
const normalized = detail.toLowerCase();
125+
export function isProviderAuthError(error: unknown) {
126+
const diagnostic = providerDiagnosticFromError(error);
127+
if (diagnostic?.action && ["connect-provider", "relogin-provider", "restart-login"].includes(diagnostic.action)) {
128+
return true;
129+
}
130+
const normalized = errorMessage(error).toLowerCase();
122131
return (
123132
normalized.includes("oauth authentication required") ||
124133
normalized.includes("authentication expired") ||
@@ -132,10 +141,69 @@ export function isProviderAuthError(detail: string) {
132141
);
133142
}
134143

144+
function providerDiagnosticFromError(error: unknown): ProviderDiagnostic | undefined {
145+
if (error instanceof CodaroApiError) return error.diagnostic;
146+
if (isRecord(error) && isRecord(error.diagnostic)) return error.diagnostic as ProviderDiagnostic;
147+
return undefined;
148+
}
149+
150+
async function withProviderValidation(
151+
profile: AiProfile,
152+
base: Omit<ProviderActionResult, "notice">,
153+
): Promise<ProviderActionResult> {
154+
const provider = String(profile.activeProvider ?? profile.defaultProvider ?? "");
155+
if (!provider) {
156+
return {
157+
...base,
158+
notice: { tone: "warning", title: "Provider 확인 필요", detail: "선택된 provider가 없습니다." },
159+
};
160+
}
161+
const validation = await validateProvider(provider, profile.activeModel);
162+
if (validation.valid) {
163+
return {
164+
...base,
165+
notice: {
166+
tone: "success",
167+
title: "Provider 연결됨",
168+
detail: validation.model ? `${provider} · ${validation.model}` : providerName(profile),
169+
},
170+
};
171+
}
172+
return {
173+
...base,
174+
notice: {
175+
tone: "warning",
176+
title: "Provider 확인 필요",
177+
detail: validation.diagnostic?.message ?? validation.error ?? "Provider 연결 상태를 확인하지 못했습니다.",
178+
},
179+
};
180+
}
181+
182+
async function validateProvider(provider: string, model?: unknown): Promise<ProviderValidationPayload> {
183+
try {
184+
return await codaroApi.validateAiProvider(provider, typeof model === "string" ? model : undefined);
185+
} catch (error) {
186+
const diagnostic = providerDiagnosticFromError(error);
187+
return {
188+
valid: false,
189+
error: diagnostic?.message ?? errorMessage(error),
190+
diagnostic,
191+
};
192+
}
193+
}
194+
195+
function errorMessage(error: unknown) {
196+
return error instanceof Error ? error.message : String(error);
197+
}
198+
135199
function providerName(profile: AiProfile | null) {
136200
return String(profile?.activeProvider ?? profile?.provider ?? profile?.defaultProvider ?? "provider 없음");
137201
}
138202

203+
function isRecord(value: unknown): value is Record<string, unknown> {
204+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
205+
}
206+
139207
function sleep(milliseconds: number) {
140208
return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
141209
}

editor/src/types.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,23 @@ export type AiProviderCatalogPayload = {
234234
catalog: AiProvider[] | Record<string, AiProvider>;
235235
};
236236

237+
export type ProviderDiagnostic = {
238+
code?: string;
239+
message?: string;
240+
action?: string;
241+
provider?: string;
242+
detail?: string;
243+
recoverable?: boolean;
244+
statusCode?: number;
245+
};
246+
247+
export type ProviderValidationPayload = {
248+
valid: boolean;
249+
model?: string | null;
250+
error?: string | null;
251+
diagnostic?: ProviderDiagnostic | null;
252+
};
253+
237254
export type AiToolCatalogPayload = {
238255
groups: Array<{
239256
id: string;
@@ -367,6 +384,8 @@ export type OauthAuthorizePayload = {
367384
export type OauthStatusPayload = {
368385
done: boolean;
369386
error?: string | null;
387+
message?: string | null;
388+
diagnostic?: ProviderDiagnostic | null;
370389
};
371390

372391
export type AppNotice = {

0 commit comments

Comments
 (0)