Skip to content

Commit fa6b3ef

Browse files
committed
fix(providers): render static connection checks neutrally
1 parent efd4382 commit fa6b3ef

13 files changed

Lines changed: 289 additions & 8 deletions

File tree

gui/src/components/provider-workspace/ProviderDetails.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@ export default function ProviderDetails({
9696
const free = useMemo(() => isFreeProvider(item), [item]);
9797
const local = useMemo(() => isLocalProvider(item), [item]);
9898
const authSurface = useMemo(() => providerAuthSurface(item), [item]);
99+
const connectionIdentity = JSON.stringify([
100+
codexController?.activeId ?? "",
101+
accounts?.find(account => account.active)?.id ?? "",
102+
keys?.find(entry => entry.active)?.id ?? "",
103+
oauth?.loggedIn === undefined ? "" : String(oauth.loggedIn),
104+
oauth?.needsReauth === undefined ? "" : String(oauth.needsReauth),
105+
oauthEmail ?? "",
106+
]);
99107
const tabs = useMemo<{ id: Tab; label: string }[]>(() => [
100108
{ id: "overview", label: t("pws.tab.overview") },
101109
{ id: "models", label: t("pws.tab.models") },
@@ -223,6 +231,8 @@ export default function ProviderDetails({
223231
/>
224232
) : undefined}
225233
item={item}
234+
apiBase={apiBase}
235+
connectionIdentity={connectionIdentity}
226236
usageTotals={usageTotals}
227237
quotaReport={quotaReport}
228238
oauthEmail={oauthEmail}

gui/src/components/provider-workspace/ProviderOverview.tsx

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* (STATS + Notes). Phase 030 of workspace design parity.
44
*/
55
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
6+
import { readJsonOrThrow } from "../../fetch-json";
67
import { useT, useI18n } from "../../i18n/shared";
78
import { IconAlert, IconCheck } from "../../icons";
89
import { binProviderStatus, type WorkspaceItem } from "../../provider-workspace/catalog";
@@ -12,8 +13,24 @@ import type { ProviderUsageTotals } from "./types";
1213
import { authModeLabel } from "./ProviderRail";
1314
import type { ProviderUpdatePatch } from "./types";
1415

16+
type ConnectionTestResult = {
17+
applicable?: boolean;
18+
ok?: boolean;
19+
latencyMs?: number;
20+
reason?: string;
21+
message?: string;
22+
error?: string;
23+
};
24+
25+
type ConnectionTestState = {
26+
key: string;
27+
testing: boolean;
28+
result: ConnectionTestResult | null;
29+
};
30+
1531
export default function ProviderOverview({
1632
item, usageTotals, quotaReport, oauthEmail,
33+
apiBase, connectionIdentity,
1734
onEditSettings, onViewUsage, onUpdateProvider,
1835
onReauthenticate, onCancelLogin, reauthBusy = false,
1936
accountPanel,
@@ -22,6 +39,9 @@ export default function ProviderOverview({
2239
usageTotals?: ProviderUsageTotals;
2340
quotaReport?: ProviderQuotaReportView;
2441
oauthEmail?: string;
42+
apiBase?: string;
43+
/** Opaque active credential identity used only to invalidate stale probe results. */
44+
connectionIdentity?: string;
2545
onEditSettings?: () => void;
2646
onViewUsage?: () => void;
2747
onUpdateProvider?: (name: string, patch: ProviderUpdatePatch) => Promise<{ ok: boolean; error?: string }>;
@@ -48,6 +68,81 @@ export default function ProviderOverview({
4868
const requests = usageTotals?.requests;
4969
const tokens = usageTotals?.totalTokens;
5070
const quota = accountQuotaFromReport(quotaReport);
71+
const connectionProbeKey = JSON.stringify([
72+
apiBase ?? null,
73+
item.name,
74+
item.adapter,
75+
item.baseUrl,
76+
item.authMode ?? null,
77+
item.apiKeyTransport ?? null,
78+
item.liveModels ?? null,
79+
item.disabled === true,
80+
item.hasApiKey === true,
81+
item.hasHeaders === true,
82+
item.allowPrivateNetwork === true,
83+
item.keyOptional === true,
84+
item.activeNeedsReauth === true,
85+
connectionIdentity ?? null,
86+
]);
87+
const [connectionTest, setConnectionTest] = useState<ConnectionTestState | null>(null);
88+
const connectionAbortRef = useRef<{ key: string; controller: AbortController } | null>(null);
89+
const testingConnection = connectionTest?.key === connectionProbeKey && connectionTest.testing;
90+
const connectionResult = connectionTest?.key === connectionProbeKey ? connectionTest.result : null;
91+
92+
useEffect(() => {
93+
return () => {
94+
if (connectionAbortRef.current?.key === connectionProbeKey) {
95+
connectionAbortRef.current.controller.abort();
96+
connectionAbortRef.current = null;
97+
}
98+
};
99+
}, [connectionProbeKey]);
100+
101+
const testConnection = useCallback(async () => {
102+
if (!apiBase) return;
103+
connectionAbortRef.current?.controller.abort();
104+
const controller = new AbortController();
105+
connectionAbortRef.current = { key: connectionProbeKey, controller };
106+
setConnectionTest({ key: connectionProbeKey, testing: true, result: null });
107+
try {
108+
const response = await fetch(`${apiBase}/api/providers/test?name=${encodeURIComponent(item.name)}`, {
109+
method: "POST",
110+
signal: controller.signal,
111+
});
112+
const result = await readJsonOrThrow<ConnectionTestResult>(response, t("pws.connectionFailed"));
113+
if (!result) throw new Error(t("pws.connectionFailed"));
114+
if (!controller.signal.aborted) {
115+
setConnectionTest({ key: connectionProbeKey, testing: false, result });
116+
}
117+
} catch (error) {
118+
if (!controller.signal.aborted) {
119+
setConnectionTest({
120+
key: connectionProbeKey,
121+
testing: false,
122+
result: {
123+
applicable: true,
124+
ok: false,
125+
error: error instanceof Error ? error.message : t("pws.connectionFailed"),
126+
},
127+
});
128+
}
129+
} finally {
130+
if (connectionAbortRef.current?.controller === controller) {
131+
connectionAbortRef.current = null;
132+
}
133+
}
134+
}, [apiBase, connectionProbeKey, item.name, t]);
135+
136+
const connectionState = connectionResult?.applicable === false
137+
? "not-applicable"
138+
: connectionResult?.ok === true
139+
? "ok"
140+
: "failed";
141+
const connectionText = connectionResult?.applicable === false
142+
? t("pws.connectionNotApplicable")
143+
: connectionResult?.ok === true
144+
? (connectionResult.message || t("pws.connectionOk"))
145+
: (connectionResult?.error || t("pws.connectionFailed"));
51146
return (
52147
<div className="pws-overview-layout">
53148
<div className="pws-overview-main">
@@ -82,6 +177,27 @@ export default function ProviderOverview({
82177
</div>
83178
)}
84179
</dl>
180+
{apiBase && (
181+
<div className="row" style={{ marginTop: 12, alignItems: "center" }}>
182+
<button
183+
type="button"
184+
className="btn btn-ghost btn-sm"
185+
disabled={testingConnection}
186+
onClick={() => void testConnection()}
187+
>
188+
{testingConnection ? t("pws.testing") : t("pws.testConnection")}
189+
</button>
190+
{connectionResult && (
191+
<span
192+
role="status"
193+
className={connectionState === "ok" ? "pws-status-ok" : connectionState === "failed" ? "pws-status-warn" : "muted"}
194+
data-connection-test-state={connectionState}
195+
>
196+
{connectionText}
197+
</span>
198+
)}
199+
</div>
200+
)}
85201
{onEditSettings && (
86202
<button type="button" className="link-btn pws-edit-settings-link" onClick={onEditSettings}>
87203
{t("pws.editSettings")}

gui/src/i18n/de.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1317,6 +1317,7 @@ export const de: Record<TKey, string> = {
13171317
"pws.testing": "Teste…",
13181318
"pws.connectionOk": "Verbindung OK",
13191319
"pws.connectionFailed": "Verbindung fehlgeschlagen",
1320+
"pws.connectionNotApplicable": "Nicht zutreffend — dieser Anbieter verwendet einen statischen Modellkatalog.",
13201321
"pws.editSettings": "Einstellungen bearbeiten",
13211322
"pws.viewUsage": "Detaillierte Nutzung anzeigen",
13221323
"pws.allSystemsOk": "Alle Systeme betriebsbereit",

gui/src/i18n/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1043,6 +1043,7 @@ export const en = {
10431043
"pws.testing": "Testing…",
10441044
"pws.connectionOk": "Connection OK",
10451045
"pws.connectionFailed": "Connection failed",
1046+
"pws.connectionNotApplicable": "Not applicable — this provider uses a static model catalog.",
10461047
"pws.editSettings": "Edit settings",
10471048
"pws.viewUsage": "View detailed usage",
10481049
"pws.allSystemsOk": "All systems operational",

gui/src/i18n/ja.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -993,6 +993,7 @@ export const ja: Record<TKey, string> = {
993993
"pws.testing": "テスト中…",
994994
"pws.connectionOk": "接続 OK",
995995
"pws.connectionFailed": "接続失敗",
996+
"pws.connectionNotApplicable": "対象外 — このプロバイダーは静的モデルカタログを使用します。",
996997
"pws.editSettings": "設定を編集",
997998
"pws.viewUsage": "詳細な使用量を表示",
998999
"pws.allSystemsOk": "すべてのシステムが稼働中",

gui/src/i18n/ko.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1344,6 +1344,7 @@ export const ko: Record<TKey, string> = {
13441344
"pws.testing": "테스트 중…",
13451345
"pws.connectionOk": "연결 성공",
13461346
"pws.connectionFailed": "연결 실패",
1347+
"pws.connectionNotApplicable": "해당 없음 — 이 프로바이더는 정적 모델 카탈로그를 사용합니다.",
13471348
"pws.editSettings": "설정 편집",
13481349
"pws.viewUsage": "사용량 상세 보기",
13491350
"pws.allSystemsOk": "모든 시스템 정상",

gui/src/i18n/ru.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1035,6 +1035,7 @@ export const ru: Record<TKey, string> = {
10351035
"pws.testing": "Проверка…",
10361036
"pws.connectionOk": "Подключение успешно",
10371037
"pws.connectionFailed": "Ошибка подключения",
1038+
"pws.connectionNotApplicable": "Не применимо — этот провайдер использует статический каталог моделей.",
10381039
"pws.editSettings": "Изменить настройки",
10391040
"pws.viewUsage": "Подробнее об использовании",
10401041
"pws.allSystemsOk": "Все системы работают штатно",

gui/src/i18n/zh.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1337,6 +1337,7 @@ export const zh: Record<TKey, string> = {
13371337
"pws.testing": "测试中…",
13381338
"pws.connectionOk": "连接成功",
13391339
"pws.connectionFailed": "连接失败",
1340+
"pws.connectionNotApplicable": "不适用 — 此提供方使用静态模型目录。",
13401341
"pws.editSettings": "编辑设置",
13411342
"pws.viewUsage": "查看详细用量",
13421343
"pws.allSystemsOk": "所有系统正常运行",

gui/tests/provider-overview-notes-save.test.tsx

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { WorkspaceItem } from "../src/provider-workspace/catalog";
99
const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const;
1010
let previousGlobals: Record<(typeof globals)[number], unknown>;
1111
let testWindow: Window;
12+
const originalFetch = globalThis.fetch;
1213

1314
beforeEach(() => {
1415
previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals;
@@ -24,6 +25,7 @@ beforeEach(() => {
2425
});
2526

2627
afterEach(() => {
28+
globalThis.fetch = originalFetch;
2729
testWindow.close();
2830
for (const key of globals) {
2931
Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] });
@@ -46,6 +48,8 @@ async function flush(): Promise<void> {
4648
async function mountOverview(
4749
onUpdateProvider: (name: string, patch: { note?: string }) => Promise<{ ok: boolean; error?: string }>,
4850
providerItem = item,
51+
apiBase?: string,
52+
connectionIdentity?: string,
4953
): Promise<{ root: Root; container: HTMLElement }> {
5054
const container = document.createElement("div");
5155
document.body.append(container);
@@ -55,7 +59,12 @@ async function mountOverview(
5559
root = createRoot(container);
5660
root.render(
5761
<LanguageProvider>
58-
<ProviderOverview item={providerItem} onUpdateProvider={onUpdateProvider} />
62+
<ProviderOverview
63+
item={providerItem}
64+
apiBase={apiBase}
65+
connectionIdentity={connectionIdentity}
66+
onUpdateProvider={onUpdateProvider}
67+
/>
5968
</LanguageProvider>,
6069
);
6170
});
@@ -141,3 +150,123 @@ test("notes save closes editor only after an acknowledged success", async () =>
141150

142151
await act(async () => { root.unmount(); });
143152
});
153+
154+
test("static catalog connection test renders as not applicable instead of failed", async () => {
155+
let requests = 0;
156+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
157+
requests += 1;
158+
expect(String(input)).toBe("http://localhost/api/providers/test?name=google-antigravity");
159+
expect(init?.method).toBe("POST");
160+
return Response.json({ applicable: false, reason: "static_catalog", latencyMs: 0 });
161+
}) as typeof fetch;
162+
const antigravity = {
163+
name: "google-antigravity",
164+
adapter: "google",
165+
baseUrl: "https://daily-cloudcode-pa.googleapis.com",
166+
authMode: "oauth",
167+
liveModels: false,
168+
} as WorkspaceItem;
169+
170+
const { root, container } = await mountOverview(async () => ({ ok: true }), antigravity, "http://localhost");
171+
const button = [...container.querySelectorAll<HTMLButtonElement>("button")]
172+
.find(candidate => candidate.textContent?.includes("Test connection"));
173+
expect(button).toBeTruthy();
174+
175+
await act(async () => {
176+
button!.click();
177+
await flush();
178+
});
179+
180+
const result = container.querySelector<HTMLElement>('[data-connection-test-state="not-applicable"]');
181+
expect(requests).toBe(1);
182+
expect(result?.textContent).toContain("Not applicable");
183+
expect(result?.classList.contains("pws-status-warn")).toBe(false);
184+
expect(container.textContent).not.toContain("Connection failed");
185+
186+
await act(async () => { root.unmount(); });
187+
});
188+
189+
test("same-provider config changes clear a rendered connection result", async () => {
190+
globalThis.fetch = (async () => Response.json({
191+
ok: true,
192+
latencyMs: 4,
193+
message: "Connected",
194+
})) as typeof fetch;
195+
const update = async () => ({ ok: true as const });
196+
const { root, container } = await mountOverview(update, item, "http://localhost", "key:key-a");
197+
const button = [...container.querySelectorAll<HTMLButtonElement>("button")]
198+
.find(candidate => candidate.textContent?.includes("Test connection"))!;
199+
200+
await act(async () => {
201+
button.click();
202+
await flush();
203+
});
204+
expect(container.querySelector('[data-connection-test-state="ok"]')).toBeTruthy();
205+
206+
await act(async () => {
207+
root.render(
208+
<LanguageProvider>
209+
<ProviderOverview
210+
item={{ ...item, disabled: true }}
211+
apiBase="http://localhost"
212+
connectionIdentity="key:key-a"
213+
onUpdateProvider={update}
214+
/>
215+
</LanguageProvider>,
216+
);
217+
await flush();
218+
});
219+
220+
expect(container.querySelector("[data-connection-test-state]")).toBeNull();
221+
expect(container.textContent).not.toContain("Connected");
222+
223+
await act(async () => { root.unmount(); });
224+
});
225+
226+
test("same-provider auth identity changes abort and ignore an in-flight result", async () => {
227+
let resolveFetch!: (response: Response) => void;
228+
let signal: AbortSignal | null = null;
229+
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
230+
signal = init?.signal ?? null;
231+
return await new Promise<Response>(resolve => {
232+
resolveFetch = resolve;
233+
});
234+
}) as typeof fetch;
235+
const update = async () => ({ ok: true as const });
236+
const { root, container } = await mountOverview(update, item, "http://localhost", "key:key-a");
237+
const button = [...container.querySelectorAll<HTMLButtonElement>("button")]
238+
.find(candidate => candidate.textContent?.includes("Test connection"))!;
239+
240+
await act(async () => {
241+
button.click();
242+
await Promise.resolve();
243+
});
244+
expect(button.disabled).toBe(true);
245+
246+
await act(async () => {
247+
root.render(
248+
<LanguageProvider>
249+
<ProviderOverview
250+
item={item}
251+
apiBase="http://localhost"
252+
connectionIdentity="key:key-b"
253+
onUpdateProvider={update}
254+
/>
255+
</LanguageProvider>,
256+
);
257+
await flush();
258+
});
259+
260+
expect(signal?.aborted).toBe(true);
261+
expect(container.querySelector("[data-connection-test-state]")).toBeNull();
262+
expect(container.querySelector<HTMLButtonElement>("button")?.disabled).toBe(false);
263+
264+
await act(async () => {
265+
resolveFetch(Response.json({ ok: true, latencyMs: 4, message: "Old result" }));
266+
await flush();
267+
});
268+
expect(container.querySelector("[data-connection-test-state]")).toBeNull();
269+
expect(container.textContent).not.toContain("Old result");
270+
271+
await act(async () => { root.unmount(); });
272+
});

0 commit comments

Comments
 (0)